Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-10-26 08:50:55 +09:00
2 changed files with 738 additions and 0 deletions
@@ -0,0 +1,556 @@
"""사용자 가이드 그림 자동 캡처(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())
+182
View File
@@ -0,0 +1,182 @@
"""539-1 가이드 캡처 — 화면 없이 도는 부분(장면 목록 읽기 · 검사 · 이름 규칙 · _manual 건너뜀 · WebP)."""
import io
import json
import sys
import tempfile
from pathlib import Path
import pytest
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "A05_EduDetail"))
import A05_EduDetail_Guide_Capture as G # noqa: E402
def _scene(**kw):
base = {"name": "B05_01_plan", "route": "#/b05-profile", "project": "road"}
return {**base, **kw}
def _folder(scenes, manual=None) -> Path:
d = Path(tempfile.mkdtemp())
(d / "_scenes.json").write_text(json.dumps(scenes, ensure_ascii=False), encoding="utf-8")
if manual is not None:
(d / "_manual.json").write_text(json.dumps(manual), encoding="utf-8")
return d
def test_good_scene_passes():
sc = _scene(
before=[{"click": ".card"}, {"wait": ".modal"}, {"wait_ms": 300}, {"hover": ".tip"}],
clip=".modal",
callouts=[{"n": 1, "sel": ".a"}, {"n": 2, "sel": ".b", "pos": "br"}],
after=[{"press": "Escape"}],
)
assert G.check_scene(sc, "B05") == []
assert G.load_scenes(_folder([sc]), "B05")[0]["name"] == "B05_01_plan"
@pytest.mark.parametrize(
"name",
["B05_1_plan", "b05_01_plan", "B05_01_Plan", "B05_01_평면", "B05-01-plan", "B06_01_plan", None],
)
def test_name_rule(name):
assert G.check_scene(_scene(name=name), "B05")
@pytest.mark.parametrize(
"bad",
[
{"route": "b05-profile"},
{"project": "mine"},
{"viewport": [100, 900]},
{"clip": ""},
{"callouts": [{"n": 1, "sel": ".a"}, {"n": 1, "sel": ".b"}]},
{"callouts": [{"n": 0, "sel": ".a"}]},
{"callouts": [{"n": 1, "sel": ".a", "pos": "mid"}]},
{"before": [{"click": ".a", "wait": ".b"}]},
{"before": [{"fill": ".a"}]},
{"before": [{"jump": ".a"}]},
{"allow_post": ["projects"]},
{"extra": 1},
],
)
def test_bad_fields(bad):
assert G.check_scene(_scene(**bad), "B05")
@pytest.mark.parametrize(
"sel",
[
"button:has-text('저장')",
"text=확정",
".reset-btn",
"button:has-text('초기화')",
"button:has-text('적용')",
"button:has-text('메일 보내기')",
"#save",
"button:has-text('삭제')",
],
)
def test_danger_click_rejected(sel):
errs = G.check_scene(_scene(before=[{"click": sel}]), "B05")
assert any("위험" in e for e in errs)
def test_danger_eval_rejected():
errs = G.check_scene(
_scene(before=[{"eval": "document.querySelector('.save').click()"}]), "B05"
)
assert any("위험" in e for e in errs)
@pytest.mark.parametrize(
"sel", ["button:has-text('취소')", "text=닫기", ".cancel", "button:has-text('내보내기')"]
)
def test_safe_click_passes(sel):
assert G.check_scene(_scene(before=[{"click": sel}]), "B05") == []
def test_confirm_needs_cancel_after():
sc = _scene(before=[{"click": "button:has-text('저장')", "confirm": True}])
assert any("after" in e for e in G.check_scene(sc, "B05"))
sc["after"] = [{"click": "button:has-text('취소')"}]
assert G.check_scene(sc, "B05") == []
sc["after"] = [{"click": "button:has-text('확인')", "confirm": True}]
assert G.check_scene(sc, "B05")
def test_is_danger_runtime_text():
assert G.is_danger("저장")
assert G.is_danger("추천 노선 적용")
assert not G.is_danger("취소")
assert not G.is_danger("엑셀 내보내기")
assert not G.is_danger("")
def test_load_reports_all_errors_and_duplicates():
d = _folder([_scene(), _scene(), _scene(name="B05_02_x", route="x")])
with pytest.raises(G.SceneError) as e:
G.load_scenes(d, "B05")
assert "이름 겹침: B05_01_plan" in str(e.value) and "route" in str(e.value)
def test_load_missing_and_broken():
with pytest.raises(G.SceneError):
G.load_scenes(Path(tempfile.mkdtemp()), "B05")
d = Path(tempfile.mkdtemp())
(d / "_scenes.json").write_text("[{", encoding="utf-8")
with pytest.raises(G.SceneError):
G.load_scenes(d, "B05")
@pytest.mark.parametrize(
"manual", [["B05_01_plan.webp"], ["B05_01_plan"], {"B05_01_plan.webp": {"by": "admin"}}]
)
def test_manual_names(manual):
assert G.manual_names(_folder([_scene()], manual)) == {"B05_01_plan"}
def test_manual_skipped_in_list(capsys):
d = _folder([_scene(), _scene(name="B05_02_cut")], ["B05_01_plan.webp"])
assert G.main(["--page", "B05", "--out", str(d), "--list"]) == 0
out = capsys.readouterr().out.splitlines()
assert "건너뜀" in out[0] and "건너뜀" not in out[1]
def test_pick_scenes():
scenes = [_scene(), _scene(name="B05_02_cut")]
assert [s["name"] for s in G.pick_scenes(scenes, ["B05_02"])] == ["B05_02_cut"]
assert len(G.pick_scenes(scenes, None)) == 2
with pytest.raises(G.SceneError):
G.pick_scenes(scenes, ["B05_09"])
def test_resolve_project():
assert G.resolve_project("none", "x", {}) is None
assert G.resolve_project("road", None, {"road": "r1"}) == "r1"
assert G.resolve_project("road", "cli", {"road": "r1"}) == "cli"
with pytest.raises(G.SceneError):
G.resolve_project("stab", None, {"road": "r1"})
def test_save_webp():
buf = io.BytesIO()
Image.new("RGBA", (64, 32), (255, 212, 0, 255)).save(buf, "PNG")
path = Path(tempfile.mkdtemp()) / "B05_01_plan.webp"
w, h, kb = G.save_webp(buf.getvalue(), path)
assert (w, h) == (64, 32) and kb > 0
with Image.open(path) as img:
assert img.format == "WEBP" and img.mode == "RGB"
def test_callout_and_clip_boxes():
ring, dot = G.callout_boxes({"x": 100, "y": 100, "width": 50, "height": 20}, "tl", 1600, 900)
assert ring == {"x": 96, "y": 96, "w": 58, "h": 28}
assert dot["x"] < ring["x"] and dot["y"] < ring["y"] # 바깥 왼쪽 위
_, edge = G.callout_boxes({"x": 0, "y": 0, "width": 50, "height": 20}, "tl", 1600, 900)
assert edge["x"] >= 0 and edge["y"] >= 0 # 화면 밖이면 안으로
clip = G.union_clip([ring, dot], 16, 1600, 900)
assert clip["x"] <= dot["x"] - 16 + 0.01 and clip["width"] > ring["w"]
assert G.union_clip([{"x": -50, "y": 0, "w": 2000, "h": 10}], 16, 1600, 900)["width"] == 1600