Files
Aislo/A05_EduDetail/A05_EduDetail_Guide_Capture.py
T
eomsangdonandClaude Opus 5.5 eb0d488146 feat(A05 가이드): 가이드 그림 자동 캡처 스크립트 + 장면 목록 꼴 확정(PLAN 539-1 · 요구 359-6)
- A05_EduDetail_Guide_Capture.py — 헤드리스 Edge(없으면 chrome) · 1600×900 · 2배 · 밝은 테마 ·
  장면마다 새 탭 · 노란 둥근 테두리 + 검은 번호 원 · 통째 / clip 요소 · WebP 품질 85 · _manual.json 건너뜀
- 장면 목록 _scenes.json 꼴 = 머리 설명(before · after 단계 click · contextmenu · fill · hover · press ·
  eval · wait · wait_ms · frame = CAD iframe 안 · viewport · settle_ms · clip_pad · callouts pos · allow_post)
- 지킴 — 위험 단추(저장 · 확정 · 초기화 · 적용 · 삭제 · 메일) 선택자 · 실제 글자 둘 다 거절 · confirm 은
  after [취소] 필요 · 페이지 /api 쓰기 요청 끊음
- 지킴 시험 test_539_guide_capture.py 45 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 08:49:42 +09:00

557 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""사용자 가이드 그림 자동 캡처(PLAN 539-1 · 요구 359-6) — 장면 목록대로 헤드리스 Edge 로 찍어 WebP.
쓰는 법(명령으로 돎 · 서버 안 스레드 아님)
./venv/Scripts/python.exe A05_EduDetail/A05_EduDetail_Guide_Capture.py --page B05
[--scene <이름>] [--vite http://localhost:5173] [--api <vite 와 같음>] [--out <폴더>]
[--project-id <id>] [--force] [--list] [--headed]
- 기본 out = A05_EduDetail/guide/<페이지>/ · 장면 목록 = out 폴더 `_scenes.json` · 그림 = out/<이름>.webp
- --scene 여러 번 · 이름 일부(앞머리)도 됨 · --list = 브라우저 없이 목록 · 검사 · 건너뜀만 봄
- --force = `_manual.json` 그림도 찍음 · --headed = 창을 띄워 찍음(헤드리스에서 3D · 지도가 안 그려질 때)
- 프로젝트 = A05_EduDetail/guide/_projects.json `{"road": "<id>", "stab": "<id>", "plan": "<id>"}`
· --project-id 를 주면 그 id 가 모든 프로젝트 장면에 우선(자기 검증 프로젝트로 시험할 때)
- 로그인 = tmp/aislo_cred.json(시스템 관리자) · 값은 어디에도 찍지 않음
- 끝에 표(장면 · 크기 · kB · 걸린 시간 · 실패 까닭) · 실패가 하나라도 있으면 종료 코드 1
`_scenes.json` 꼴 = 장면(그림 하나) 목록 — 이 머리 설명이 기준
[
{
"name": "B05_00_overview", 필수 · <페이지>_<두 자리>_<영문 소문자·숫자·_> · 그림 = <name>.webp
"route": "#/b05-profile", 필수 · 해시 주소("#/…") · vite 주소 뒤에 붙임
"project": "road", road | stab | plan | none(기본) · none = 현재 프로젝트 비움
"viewport": [1600, 900], 선택 · 화면 크기(CSS px) · 기본 1600×900 · 그림은 늘 2배
"before": [ 단계, … ], 선택 · 찍기 전 조작(아래 단계 꼴)
"settle_ms": 500, 선택 · before 뒤 · 찍기 전 쉼(기본 500 · 3D · 지도는 2000~)
"clip": null, 선택 · null = 화면 통째 · css = 그 요소만(번호 원까지 넣어 자름)
"clip_pad": 16, 선택 · clip 둘레 여백(CSS px · 기본 16)
"callouts": [ 선택 · 노란 둥근 테두리 + 검은 원 흰 번호 · 번호 = 글 번호 표
{"n": 1, "sel": "<css>", "pos": "tl"} pos = 번호 원 자리 tl(기본) · tr · bl · br(바깥 모서리)
],
"after": [ 단계, … ], 선택 · 찍은 뒤 조작(확인창 [취소] · 모달 닫기)
"allow_post": ["/api/b05/…"] 선택 · 허락할 POST 주소 조각(읽기용 POST 만 · 아래 지킴)
}
]
단계 = 아래 열쇠 중 하나 + 덧붙임
{"click": "<css>"} 누름 · {"click": …, "confirm": true} = 확인창만 여는 위험 단추(아래)
{"fill": "<css>", "value": "글"} 칸 채움
{"hover": "<css>"} 마우스 올림(툴팁)
{"contextmenu": "<css>"} 오른쪽 누름(누름 메뉴 열기)
{"press": "Escape"} 키 · {"press": "Enter", "sel": "<css>"} = 그 요소에서
{"eval": "<JS 식>"} 페이지에서 JS 실행(펼침 · 스크롤 등)
{"wait": "<css>"} 요소가 보일 때까지
{"wait_ms": 800} 쉼(ms)
덧붙임 — "frame": "<iframe css>"(그 안 요소 · B07 CAD = "iframe[src*='b07-cad']") · "timeout": ms(기본 15000)
선택자 = Playwright 선택자(css · `text=…` · `:has-text("…")` 모두 됨) · 번호 sel · clip 도 같음
frame 은 callouts 항목에도 씀
지킴
- [저장] · [확정] · [초기화] · [적용] · [삭제] · 메일 보내기 등 위험 단추 누름은 거절 — 선택자 · eval 글(목록
읽을 때) · 실제 요소 글자(누르기 직전) 둘 다 검사 · [취소] · [닫기] 는 통과
- 확인창을 찍을 때만 `"confirm": true` 로 그 단추를 누름 — 그 장면은 after 에 누름 단계가 있어야 함([취소])
- 페이지의 /api 쓰기 요청(PUT · PATCH · DELETE · allow_post 밖 POST)은 모두 끊음 · 끊은 요청은 표에 적음
- 브라우저 확인창(window.confirm)은 늘 취소 · 새 컨텍스트라 사용자 브라우저 · 저장소와 안 섞임
- `_manual.json`(관리자가 손으로 바꾼 그림 · 이름 목록 또는 이름 열쇠 사전) 이름은 건너뜀(--force 면 찍음)
3D(WebGL) · 지도(카카오) · CAD(iframe)
- 헤드리스 = 소프트웨어 그리기(SwiftShader)라 느림 → wait · settle_ms 를 늘림 · 그래도 빈 화면이면 --headed
- 카카오 지도는 키에 등록된 출처(vite 주소)에서만 그려짐
"""
from __future__ import annotations
import argparse
import io
import json
import re
import sys
import time
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent.parent
GUIDE = Path(__file__).resolve().parent / "guide"
PROJECTS_FILE = GUIDE / "_projects.json"
CRED = ROOT / "tmp" / "aislo_cred.json"
CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠
BROWSER_ARGS = ["--enable-unsafe-swiftshader", "--ignore-gpu-blocklist"] # 헤드리스 WebGL
VIEWPORT = (1600, 900)
SCALE = 2
QUALITY = 85
STEP_TIMEOUT = 15000
SETTLE_MS = 500
CLIP_PAD = 16
PROJECT_KEYS = ("road", "stab", "plan", "none")
STEP_KINDS = ("click", "contextmenu", "fill", "hover", "press", "eval", "wait", "wait_ms")
SCENE_KEYS = {
"name", "route", "project", "viewport", "before", "settle_ms", "clip", "clip_pad",
"callouts", "after", "allow_post",
} # fmt: skip
STEP_EXTRA = {"value", "confirm", "frame", "timeout", "sel"}
POSITIONS = ("tl", "tr", "bl", "br")
NAME_RE = re.compile(r"^(?P<page>[A-Z]\d{2})_\d{2}_[a-z0-9_]+$")
DANGER_RE = re.compile(
r"저장|확정|초기화|적용|삭제|제출|발송|전송|메일|(?<!내)보내기"
r"|\b(save|confirm|reset|apply|delete|remove|submit|send|mail)",
re.I,
)
SAFE_RE = re.compile(r"취소|닫기|\b(cancel|close)", re.I)
THEME_KEY, LANG_KEY, PROJECT_KEY = "frd_theme", "frd_lang", "frd_current_project_id"
CALLOUT_CLASS = "__guide_callout"
YELLOW = "#FFD400"
RING_PAD, DOT = 4, 28 # 테두리 바깥 여백 · 번호 원 지름(CSS px)
class SceneError(Exception):
"""장면 하나 실패 — 까닭 한 줄."""
# ---------------------------------------------------------------------------
# 목록 읽기 · 검사(화면 없이 돎 — 지킴 시험 대상)
# ---------------------------------------------------------------------------
def is_danger(text: str) -> bool:
"""위험 단추 글자 · 선택자인가([취소] · [닫기] 는 아님)."""
return bool(text) and bool(DANGER_RE.search(text)) and not SAFE_RE.search(text)
def _check_steps(steps: Any, where: str) -> tuple[list[str], bool]:
"""단계 목록 검사 → (잘못, confirm 단계 있음)."""
if steps is None:
return [], False
if not isinstance(steps, list):
return [f"{where} 는 목록이어야 함"], False
errs, confirm = [], False
for i, st in enumerate(steps):
at = f"{where}[{i}]"
if not isinstance(st, dict):
errs.append(f"{at} 꼴 틀림")
continue
kinds = [k for k in STEP_KINDS if k in st]
if len(kinds) != 1:
errs.append(f"{at} 단계 열쇠는 하나({' · '.join(STEP_KINDS)})")
continue
kind = kinds[0]
extra = set(st) - {kind} - STEP_EXTRA
if extra:
errs.append(f"{at} 모르는 열쇠 {sorted(extra)}")
val = st[kind]
if kind == "wait_ms":
if not isinstance(val, (int, float)) or val < 0:
errs.append(f"{at} wait_ms 는 0 이상 수")
continue
if not isinstance(val, str) or not val.strip():
errs.append(f"{at} {kind} 값이 빈 글")
continue
if kind == "fill" and not isinstance(st.get("value"), str):
errs.append(f"{at} fill 은 value(글) 필요")
if st.get("confirm"):
if kind != "click":
errs.append(f"{at} confirm 은 click 에만")
confirm = True
elif kind in ("click", "eval") and is_danger(val):
errs.append(
f"{at} 위험 단추 거절({kind}: {val[:40]}) — 확인창만이면 confirm + after [취소]"
)
if kind == "press" and is_danger(st.get("sel", "")):
errs.append(f"{at} 위험 요소에서 키 거절")
return errs, confirm
def check_scene(scene: Any, page: str) -> list[str]:
"""장면 하나 검사 → 잘못 목록(빈 목록 = 됨)."""
if not isinstance(scene, dict):
return ["장면은 사전이어야 함"]
errs: list[str] = []
name = scene.get("name")
m = NAME_RE.match(name) if isinstance(name, str) else None
if not m:
errs.append(f"이름 규칙 틀림({name!r}) — <페이지>_<두 자리>_<영문 소문자>")
elif m["page"] != page:
errs.append(f"이름 머리 {m['page']} ≠ 페이지 {page}")
extra = set(scene) - SCENE_KEYS
if extra:
errs.append(f"모르는 열쇠 {sorted(extra)}")
route = scene.get("route")
if not isinstance(route, str) or not route.startswith("#/"):
errs.append("route 는 '#/…' 해시 주소")
if scene.get("project", "none") not in PROJECT_KEYS:
errs.append(f"project 는 {' · '.join(PROJECT_KEYS)}")
vp = scene.get("viewport")
if vp is not None and not (
isinstance(vp, list) and len(vp) == 2 and all(isinstance(v, int) and v >= 320 for v in vp)
):
errs.append("viewport 는 [폭, 높이] 정수(320 이상)")
for key in ("settle_ms", "clip_pad"):
v = scene.get(key)
if v is not None and (not isinstance(v, (int, float)) or v < 0):
errs.append(f"{key} 는 0 이상 수")
clip = scene.get("clip")
if clip is not None and (not isinstance(clip, str) or not clip.strip()):
errs.append("clip 은 null 또는 선택자")
before_errs, confirm = _check_steps(scene.get("before"), "before")
after_errs, after_confirm = _check_steps(scene.get("after"), "after")
errs += before_errs + after_errs
if after_confirm:
errs.append("after 에는 confirm 단계 못 둠")
if confirm and not any("click" in s for s in scene.get("after") or [] if isinstance(s, dict)):
errs.append("confirm 단계가 있으면 after 에 누름 단계([취소]) 필요")
callouts = scene.get("callouts") or []
if not isinstance(callouts, list):
errs.append("callouts 는 목록")
callouts = []
seen: set[int] = set()
for i, c in enumerate(callouts):
if not isinstance(c, dict) or not isinstance(c.get("sel"), str) or not c["sel"].strip():
errs.append(f"callouts[{i}] 는 {{n, sel}}")
continue
n = c.get("n")
if not isinstance(n, int) or isinstance(n, bool) or not 1 <= n <= 99:
errs.append(f"callouts[{i}] n 은 1~99 정수")
elif n in seen:
errs.append(f"callouts 번호 {n} 겹침")
else:
seen.add(n)
if c.get("pos", "tl") not in POSITIONS:
errs.append(f"callouts[{i}] pos 는 {' · '.join(POSITIONS)}")
allow = scene.get("allow_post")
if allow is not None and not (
isinstance(allow, list) and all(isinstance(a, str) and a.startswith("/api/") for a in allow)
):
errs.append("allow_post 는 '/api/…' 글 목록")
return errs
def load_scenes(folder: Path, page: str) -> list[dict[str, Any]]:
"""`_scenes.json` 읽고 검사 — 잘못이 하나라도 있으면 SceneError(전부 한꺼번에)."""
path = folder / "_scenes.json"
if not path.is_file():
raise SceneError(f"장면 목록 없음: {path}")
try:
scenes = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
raise SceneError(f"장면 목록 JSON 틀림: {e}") from e
if not isinstance(scenes, list):
raise SceneError("장면 목록은 [ … ] 목록")
errs = []
for i, s in enumerate(scenes):
label = s.get("name", f"#{i}") if isinstance(s, dict) else f"#{i}"
errs += [f"{label}: {e}" for e in check_scene(s, page)]
names = [s.get("name") for s in scenes if isinstance(s, dict)]
errs += [f"이름 겹침: {n}" for n in sorted({n for n in names if names.count(n) > 1})]
if errs:
raise SceneError("장면 목록 잘못\n " + "\n ".join(errs))
return scenes
def manual_names(folder: Path) -> set[str]:
"""`_manual.json` 손 그림 이름(.webp 뗌) — 목록이든 사전이든 · 없으면 빈 모음."""
path = folder / "_manual.json"
if not path.is_file():
return set()
data = json.loads(path.read_text(encoding="utf-8"))
return {str(n).removesuffix(".webp") for n in data}
def pick_scenes(scenes: list[dict[str, Any]], wanted: list[str] | None) -> list[dict[str, Any]]:
"""--scene 고르기 — 이름 같음 또는 앞머리 · 맞는 것 없으면 SceneError."""
if not wanted:
return scenes
picked = [s for s in scenes if any(s["name"] == w or s["name"].startswith(w) for w in wanted)]
if not picked:
raise SceneError(f"고른 장면 없음: {wanted}")
return picked
def resolve_project(key: str, cli_id: str | None, projects: dict[str, str]) -> str | None:
"""프로젝트 열쇠 → id(none = None) · --project-id 우선 · 없으면 SceneError."""
if key == "none":
return None
pid = cli_id or projects.get(key)
if not pid:
raise SceneError(f"프로젝트 '{key}' id 없음 — {PROJECTS_FILE.name} 또는 --project-id")
return pid
def save_webp(png: bytes, path: Path) -> tuple[int, int, float]:
"""PNG 바이트 → WebP(품질 85) 저장 → (폭, 높이, kB)."""
from PIL import Image
img = Image.open(io.BytesIO(png)).convert("RGB")
path.parent.mkdir(parents=True, exist_ok=True)
img.save(path, "WEBP", quality=QUALITY, method=6)
return img.width, img.height, round(path.stat().st_size / 1024, 1)
def callout_boxes(box: dict[str, float], pos: str, vw: int, vh: int) -> tuple[dict, dict]:
"""요소 상자(CSS px) → (노란 테두리 상자, 번호 원 상자) · 원은 바깥 모서리 · 화면 안으로 당김."""
ring = {
"x": box["x"] - RING_PAD,
"y": box["y"] - RING_PAD,
"w": box["width"] + 2 * RING_PAD,
"h": box["height"] + 2 * RING_PAD,
}
off = DOT - 8 # 원이 모서리에 8px 걸침
x = ring["x"] - off if pos in ("tl", "bl") else ring["x"] + ring["w"] - DOT + off
y = ring["y"] - off if pos in ("tl", "tr") else ring["y"] + ring["h"] - DOT + off
dot = {
"x": min(max(x, 2), vw - DOT - 2),
"y": min(max(y, 2), vh - DOT - 2),
"w": DOT,
"h": DOT,
}
return ring, dot
def union_clip(boxes: list[dict], pad: float, vw: int, vh: int) -> dict[str, float]:
"""상자들 합 + 여백 → 화면 안 자르기 상자(Playwright clip)."""
x0 = max(min(b["x"] for b in boxes) - pad, 0)
y0 = max(min(b["y"] for b in boxes) - pad, 0)
x1 = min(max(b["x"] + b["w"] for b in boxes) + pad, vw)
y1 = min(max(b["y"] + b["h"] for b in boxes) + pad, vh)
return {"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0}
# ---------------------------------------------------------------------------
# 브라우저
# ---------------------------------------------------------------------------
_DRAW_JS = """([cls, yellow, items]) => {
for (const it of items) {
const ring = document.createElement('div');
ring.className = cls;
ring.style.cssText = `position:fixed;left:${it.ring.x}px;top:${it.ring.y}px;width:${it.ring.w}px;`
+ `height:${it.ring.h}px;border:3px solid ${yellow};border-radius:8px;box-sizing:border-box;`
+ 'pointer-events:none;z-index:2147483646';
const dot = document.createElement('div');
dot.className = cls;
dot.textContent = String(it.n);
dot.style.cssText = `position:fixed;left:${it.dot.x}px;top:${it.dot.y}px;width:${it.dot.w}px;`
+ `height:${it.dot.h}px;border-radius:50%;background:#111;color:#fff;border:2px solid #fff;`
+ 'box-sizing:border-box;display:flex;align-items:center;justify-content:center;'
+ 'font:700 15px/1 "Malgun Gothic",sans-serif;pointer-events:none;z-index:2147483647;'
+ 'box-shadow:0 1px 3px rgba(0,0,0,.4)';
document.body.append(ring, dot);
}
}"""
_CLEAR_JS = "(cls) => document.querySelectorAll('.' + cls).forEach((e) => e.remove())"
_TEXT_JS = (
"(e) => [e.innerText, e.value, e.title, e.getAttribute('aria-label')]"
".filter(Boolean).join(' ').slice(0, 200)"
)
def _root(page: Any, frame: str | None) -> Any:
return page.frame_locator(frame) if frame else page
def _locate(page: Any, sel: str, frame: str | None) -> Any:
return _root(page, frame).locator(sel).first
def run_steps(page: Any, steps: list[dict[str, Any]]) -> None:
"""단계 차례로 — 누르기 직전 실제 요소 글자로 위험 단추 한 번 더 검사."""
for st in steps:
frame, timeout = st.get("frame"), st.get("timeout", STEP_TIMEOUT)
if "wait_ms" in st:
page.wait_for_timeout(st["wait_ms"])
elif "wait" in st:
_locate(page, st["wait"], frame).wait_for(state="visible", timeout=timeout)
elif "eval" in st:
target = page.frame_locator(frame).locator(":root") if frame else page
target.evaluate(f"() => ({st['eval']})")
elif "press" in st:
if st.get("sel"):
_locate(page, st["sel"], frame).press(st["press"], timeout=timeout)
else:
page.keyboard.press(st["press"])
else:
kind = next(k for k in ("click", "contextmenu", "fill", "hover") if k in st)
loc = _locate(page, st[kind], frame)
loc.wait_for(state="visible", timeout=timeout)
if kind == "click":
text = loc.evaluate(_TEXT_JS)
if not st.get("confirm") and is_danger(text):
raise SceneError(f"위험 단추 거절: '{text[:30]}'")
loc.click(timeout=timeout)
elif kind == "contextmenu":
loc.click(button="right", timeout=timeout)
elif kind == "fill":
loc.fill(st["value"], timeout=timeout)
else:
loc.hover(timeout=timeout)
def _box(page: Any, sel: str, frame: str | None, what: str) -> dict[str, float]:
loc = _locate(page, sel, frame)
try:
loc.wait_for(state="visible", timeout=5000)
except Exception as e:
raise SceneError(f"{what} 요소 안 보임: {sel[:50]}") from e
box = loc.bounding_box()
if not box or box["width"] < 1:
raise SceneError(f"{what} 요소 크기 없음: {sel[:50]}")
return box
def shoot_scene(
context: Any, base: str, scene: dict[str, Any], pid: str | None
) -> tuple[bytes, list[str]]:
"""장면 하나 → (PNG 2배, 끊은 쓰기 요청). 새 탭마다 sessionStorage 새로."""
vw, vh = scene.get("viewport") or VIEWPORT
allow = scene.get("allow_post") or []
blocked: list[str] = []
page = context.new_page()
try:
page.set_viewport_size({"width": vw, "height": vh})
seed = json.dumps([THEME_KEY, LANG_KEY, PROJECT_KEY, pid])
page.add_init_script(
f"(([t, l, k, pid]) => {{ localStorage.setItem(t, 'light'); localStorage.setItem(l, 'ko');"
f" pid ? localStorage.setItem(k, pid) : localStorage.removeItem(k); }})({seed});"
)
page.on("dialog", lambda d: d.dismiss())
def guard(route: Any) -> None:
req = route.request
path = req.url.split("://", 1)[-1].split("/", 1)[-1]
if req.method in ("GET", "HEAD", "OPTIONS") or (
req.method == "POST" and any(a in "/" + path for a in allow)
):
route.continue_()
else:
blocked.append(f"{req.method} /{path.split('?')[0]}")
route.abort()
page.route(re.compile(r"/api/"), guard)
page.goto(base + scene["route"], wait_until="domcontentloaded")
try:
page.wait_for_load_state("networkidle", timeout=15000)
except Exception:
pass # 지도 · 3D 는 늘 받음 — wait 단계로 기다림
page.evaluate("() => document.fonts.ready")
run_steps(page, scene.get("before") or [])
page.wait_for_timeout(scene.get("settle_ms", SETTLE_MS))
items, boxes = [], []
for c in scene.get("callouts") or []:
ring, dot = callout_boxes(_box(page, c["sel"], c.get("frame"), f"번호 {c['n']}"),
c.get("pos", "tl"), vw, vh) # fmt: skip
items.append({"n": c["n"], "ring": ring, "dot": dot})
boxes += [ring, dot]
clip = None
if scene.get("clip"):
b = _box(page, scene["clip"], None, "clip")
boxes.append({"x": b["x"], "y": b["y"], "w": b["width"], "h": b["height"]})
clip = union_clip(boxes, scene.get("clip_pad", CLIP_PAD), vw, vh)
page.evaluate(_DRAW_JS, [CALLOUT_CLASS, YELLOW, items])
png = page.screenshot(clip=clip, animations="disabled", caret="hide")
page.evaluate(_CLEAR_JS, CALLOUT_CLASS)
run_steps(page, scene.get("after") or [])
return png, blocked
finally:
page.close()
def login(context: Any, api: str) -> None:
cred = json.loads(CRED.read_text(encoding="utf-8"))
res = context.request.post(
api + "/api/auth/login/request",
data={"email": cred["email"], "password": cred["password"]},
)
if not res.ok:
raise SceneError(f"로그인 실패 HTTP {res.status}")
if res.json().get("status") == "otp_required":
raise SceneError("로그인에 메일 인증 필요 — 개발 서버(AUTH_OTP_DISABLED)로")
def capture(scenes: list[dict[str, Any]], out: Path, args: argparse.Namespace) -> list[dict]:
from playwright.sync_api import sync_playwright
projects = (
json.loads(PROJECTS_FILE.read_text(encoding="utf-8")) if PROJECTS_FILE.is_file() else {}
)
skip = set() if args.force else manual_names(out)
rows = []
with sync_playwright() as p:
browser, last = None, None
for ch in CHANNELS:
try:
browser = p.chromium.launch(channel=ch, headless=not args.headed, args=BROWSER_ARGS)
break
except Exception as e: # noqa: BLE001 — 다음 채널
last = e
if browser is None:
raise SceneError(f"브라우저(Edge · Chrome)를 못 띄움: {str(last).splitlines()[0]}")
context = browser.new_context(
viewport={"width": VIEWPORT[0], "height": VIEWPORT[1]},
device_scale_factor=SCALE,
locale="ko-KR",
color_scheme="light",
)
login(context, args.api)
for sc in scenes:
row = {"name": sc["name"], "size": "", "kb": "", "sec": "", "why": ""}
t0 = time.perf_counter()
if sc["name"] in skip:
row["why"] = "건너뜀(_manual)"
else:
try:
pid = resolve_project(sc.get("project", "none"), args.project_id, projects)
png, blocked = shoot_scene(context, args.vite, sc, pid)
w, h, kb = save_webp(png, out / f"{sc['name']}.webp")
row.update(size=f"{w}×{h}", kb=kb)
if blocked:
row["why"] = f"끊은 쓰기 {len(blocked)}: {blocked[0]}"
except Exception as e: # noqa: BLE001 — 장면 하나 실패가 나머지를 막지 않음
row["why"] = (
"실패: " + (str(e).strip().splitlines() or [type(e).__name__])[0][:120]
)
row["sec"] = round(time.perf_counter() - t0, 1)
rows.append(row)
print(
f" {row['name']} {row['size']} {row['kb']}kB {row['sec']}s {row['why']}",
flush=True,
)
browser.close()
return rows
def print_table(rows: list[dict]) -> None:
print("\n| 장면 | 크기(px) | kB | 초 | 까닭 |\n| --- | --- | --- | --- | --- |")
for r in rows:
print(f"| {r['name']} | {r['size']} | {r['kb']} | {r['sec']} | {r['why']} |")
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="가이드 그림 자동 캡처(장면 목록 → WebP)")
ap.add_argument("--page", required=True, help="페이지 폴더(예 B05)")
ap.add_argument("--scene", action="append", help="이 장면만(여러 번 · 앞머리)")
ap.add_argument("--vite", default="http://localhost:5173")
ap.add_argument("--api", help="로그인 주소(기본 = vite · 프록시)")
ap.add_argument("--out", type=Path, help="장면 목록 · 그림 폴더(기본 guide/<페이지>)")
ap.add_argument("--project-id", help="모든 프로젝트 장면에 이 id(_projects.json 보다 우선)")
ap.add_argument("--force", action="store_true", help="_manual.json 그림도 찍음")
ap.add_argument("--list", action="store_true", help="목록 · 검사만(브라우저 안 띄움)")
ap.add_argument("--headed", action="store_true", help="창 띄워 찍음(3D · 지도 안 그려질 때)")
args = ap.parse_args(argv)
args.vite = args.vite.rstrip("/")
args.api = (args.api or args.vite).rstrip("/")
out = args.out or GUIDE / args.page
try:
scenes = pick_scenes(load_scenes(out, args.page), args.scene)
if args.list:
skip = manual_names(out)
for s in scenes:
mark = " (건너뜀 _manual)" if s["name"] in skip and not args.force else ""
print(f"{s['name']} {s['route']} {s.get('project', 'none')}{mark}")
return 0
rows = capture(scenes, out, args)
except SceneError as e:
print(f"[캡처] {e}", file=sys.stderr)
return 2
print_table(rows)
return 1 if any(r["why"].startswith("실패") for r in rows) else 0
if __name__ == "__main__":
sys.exit(main())