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>
This commit is contained in:
@@ -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())
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user