Files
Aislo/A05_EduDetail/A05_EduDetail_Guide_Capture.py
T

521 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 가 모든 프로젝트 장면에 우선(자기 검증 프로젝트로 시험할 때)
- 로그인 = 장면 칸 account 대로 — sys(기본) = tmp/aislo_cred.json · company_admin · user = tmp/verify_accounts.json
의 「회사관리자」 · 「일반」 · 계정마다 컨텍스트를 따로 로그인(쓰는 것만 · 처음 쓸 때) · 값은 어디에도 찍지 않음
- 끝에 표(장면 · 크기 · kB · 걸린 시간 · 실패 까닭) · 실패가 하나라도 있으면 종료 코드 1
`_scenes.json` 꼴 = 장면(그림 하나) 목록 — 이 머리 설명이 기준
[
{
"name": "B05_00_overview", 필수 · <페이지>_<두 자리>_<영문 소문자·숫자·_> · 그림 = <name>.webp
"route": "#/b05-profile", 필수 · 해시 주소("#/…") · vite 주소 뒤에 붙임
"account": "sys", 선택 · sys(기본 · 시스템 관리자) | company_admin | user · 역할마다 화면이 다른 페이지용
"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(바깥 모서리)
{"n": 2, "sel": ["<css>", "<css>"]} sel 목록 = 여러 요소의 둘레를 하나로 묶은 상자 하나 + 번호 하나
] 번호 원이 화면 밖으로 나가면 안쪽으로 밀어 넣음(넘칠 때만) · clip 은 원까지 넣어 자름
"after": [ 단계, … ], 선택 · 찍은 뒤 조작(확인창 [취소] · 모달 닫기)
"allow_post": ["/api/b05/…"] 선택 · 허락할 POST 주소 조각(읽기용 POST 만 · 아래 지킴)
}
]
단계 = 아래 열쇠 중 하나 + 덧붙임
{"click": "<css>"} 누름 · {"click": …, "confirm": true} = 확인창만 여는 위험 단추(아래)
{"fill": "<css>", "value": "글"} 칸 채움
{"select": "<css>", "value": "값"} 고르기 칸(<select>) 값 고름(option value 또는 글)
{"scroll": "<css>"} 그 요소가 화면에 들어오게 스크롤
{"hover": "<css>"} 마우스 올림(툴팁)
{"contextmenu": "<css>"} 오른쪽 누름(누름 메뉴 열기)
{"press": "Escape"} 키 · {"press": "Enter", "sel": "<css>"} = 그 요소에서
{"eval": "<JS 식>"} 페이지에서 JS 실행(펼침 · 스크롤 등)
{"wait": "<css>"} 요소가 보일 때까지
{"wait_ms": 800} 쉼(ms)
{"mouse_click": [x, y]} 좌표 누름(page.mouse · CSS px · 캔버스 · 3D 처럼 요소가 없는 자리) ·
덧붙임 "sel": "<css>" = 그 요소 왼쪽 위 기준 상대 좌표(스크롤해 들어오게 함) ·
"button": "left"(기본) | "right" · "double": true = 더블클릭 ·
위험 단추 검사는 누른 자리 요소(가까운 단추 · 링크) 글자로
덧붙임 — "optional": true(요소가 있는 단계 어디든 · 요소가 3초 안에 안 보이면 그 단계만 건너뜀 ·
"있으면 누름" — 장면마다 열린 모양이 다른 카드 · 모달 · 누름 단계에 씀) ·
"frame": "<iframe css>"(그 안 요소 · B07 CAD = "iframe[src*='b07-cad']") · "timeout": ms(기본 15000)
선택자 = Playwright 선택자(css · `text=…` · `:has-text("…")` 모두 됨) · 번호 sel · clip 도 같음
frame 은 callouts 항목에도 씀
지킴
- [저장] · [확정] · [초기화] · [적용] · [삭제] · 메일 보내기 등 위험 단추 누름은 거절 — 선택자 속 글자
(`text=` · `:has-text("…")`만 · class · id 이름은 안 봄) · eval 글 · 실제 요소 글자(누르기 직전) 검사 ·
[취소] · [닫기] 는 통과
- 확인창을 찍을 때만 `"confirm": true` 로 그 단추를 누름 — 그 장면은 after 에 누름 단계가 있어야 함([취소])
- 페이지의 /api 쓰기 요청(PUT · PATCH · DELETE · allow_post 밖 POST)은 모두 끊음 · 끊은 요청은 표에 적음
- 브라우저 확인창(window.confirm)은 늘 취소 · 새 컨텍스트라 사용자 브라우저 · 저장소와 안 섞임
- 장면마다 새 탭 · sessionStorage 비우고 시작(앞 장면 조작이 안 남음) · localStorage 는 프로젝트 · 테마 ·
말만 세움
- 번호 원은 늘 화면 안 · clip 은 원을 포함하도록 넓힘(원이 잘리지 않음)
- clip · callouts 대상은 찍기 전 화면 안으로 스크롤(clip 먼저 · 번호 차례) 뒤 자리를 잼
- `_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
from A05_EduDetail_Guide_Capture_Check import ( # noqa: F401 — 시험 · 바깥이 이 파일 이름으로 부름
ACCOUNT_KEYS,
ACCOUNTS,
PROJECT_KEYS,
SceneError,
callout_sels,
check_scene,
is_danger,
load_scenes,
manual_names,
pick_scenes,
sel_text,
step_target,
)
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
OPTIONAL_MS = 3000 # optional 단계 · 요소를 기다리는 시간
SETTLE_MS = 500
CLIP_PAD = 16
ACCOUNTS_FILE = ROOT / "tmp" / "verify_accounts.json"
THEME_KEY, LANG_KEY, PROJECT_KEY = "frd_theme", "frd_lang", "frd_current_project_id"
CALLOUT_CLASS = "__guide_callout"
SEEDED = "__guide_seeded" # 탭 이름(window.name)에 남겨 첫 적재를 가림
YELLOW = "#FFD400"
RING_PAD, DOT = 4, 28 # 테두리 바깥 여백 · 번호 원 지름(CSS px)
# ---------------------------------------------------------------------------
# 목록 읽기 · 검사 = A05_EduDetail_Guide_Capture_Check.py · 아래는 화면 없이 도는 계산
# ---------------------------------------------------------------------------
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 union_box(boxes: list[dict[str, float]]) -> dict[str, float]:
"""요소 상자들(x · y · width · height)의 둘레 상자 하나."""
x0, y0 = min(b["x"] for b in boxes), min(b["y"] for b in boxes)
x1 = max(b["x"] + b["width"] for b in boxes)
y1 = max(b["y"] + b["height"] for b in boxes)
return {"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0}
def account_cred(account: str) -> dict[str, str]:
"""장면 account → {email, password} · 파일 없음 · 칸 없음이면 SceneError(값은 안 찍음)."""
path = CRED if account == "sys" else ACCOUNTS_FILE
if not path.is_file():
raise SceneError(f"계정 파일 없음: tmp/{path.name}")
data = json.loads(path.read_text(encoding="utf-8"))
cred = data if account == "sys" else data.get(ACCOUNT_KEYS[account])
if not isinstance(cred, dict) or not cred.get("email") or not cred.get("password"):
raise SceneError(f"계정 '{account}' 의 email · password 없음")
return cred
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())"
_POINT_JS = """([x, y]) => {
const e = document.elementFromPoint(x, y);
if (!e) return '';
const b = e.closest('button,a,[role=button],[title]') || e;
return [b.innerText, b.value, b.title, b.getAttribute('aria-label')]
.filter(Boolean).join(' ').slice(0, 200);
}"""
_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 _point_text(page: Any, x: float, y: float, frame: str | None) -> str:
"""화면 (x, y) 에 있는 요소(가까운 단추 · 링크 포함)의 글자 — frame 이면 그 안 요소."""
if not frame:
return page.evaluate(_POINT_JS, [x, y]) or ""
off = page.locator(frame).first.bounding_box() or {"x": 0, "y": 0}
root = page.frame_locator(frame).locator(":root")
return root.evaluate(f"(_, a) => ({_POINT_JS})(a)", [x - off["x"], y - off["y"]]) or ""
def _mouse_click(page: Any, st: dict[str, Any], frame: str | None, timeout: int) -> None:
"""좌표 누름 — sel 이 있으면 그 요소 왼쪽 위 기준 상대 좌표 · 누른 자리 요소 글자로 위험 검사."""
x, y = st["mouse_click"]
if st.get("sel"):
loc = _locate(page, st["sel"], frame)
loc.scroll_into_view_if_needed(timeout=timeout)
box = loc.bounding_box()
if not box:
raise SceneError(f"mouse_click 기준 요소 크기 없음: {st['sel'][:50]}")
x, y = box["x"] + x, box["y"] + y
text = _point_text(page, x, y, frame)
if is_danger(text):
raise SceneError(f"위험 단추 거절: '{text[:30]}'")
button = st.get("button", "left")
if st.get("double"):
page.mouse.dblclick(x, y, button=button)
else:
page.mouse.click(x, y, button=button)
def _absent(page: Any, st: dict[str, Any]) -> bool:
"""optional 단계의 요소가 없음(OPTIONAL_MS 안에 안 보임)."""
sel = step_target(st)
if not st.get("optional") or not sel:
return False
try:
_locate(page, sel, st.get("frame")).wait_for(state="visible", timeout=OPTIONAL_MS)
except Exception: # noqa: BLE001 — 없음 = 건너뜀
return True
return False
def run_steps(page: Any, steps: list[dict[str, Any]]) -> None:
"""단계 차례로 — 누르기 직전 실제 요소 글자로 위험 단추 한 번 더 검사 · optional 은 요소 없으면 건너뜀."""
for st in steps:
frame, timeout = st.get("frame"), st.get("timeout", STEP_TIMEOUT)
if _absent(page, st):
continue
if "mouse_click" in st:
_mouse_click(page, st, frame, timeout)
elif "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 "scroll" in st:
_locate(page, st["scroll"], frame).scroll_into_view_if_needed(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", "select", "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)
elif kind == "select":
loc.select_option(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);"
# 첫 적재만 비움 — 장면 안 새로고침 · 이동은 그 장면 조작을 지킴
f" if (!window.name.includes('{SEEDED}')) {{ sessionStorage.clear();"
f" window.name += '{SEEDED}'; }} }})({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))
# 찍을 대상을 화면 안으로 — clip 먼저 · 번호 차례(어긋난 번호 · 잘린 그림 막음)
targets = [
(sel, c.get("frame"))
for c in scene.get("callouts") or []
for sel in callout_sels(c["sel"])
]
if scene.get("clip"):
targets.insert(0, (scene["clip"], None))
for sel, frame in targets:
try:
_locate(page, sel, frame).scroll_into_view_if_needed(timeout=5000)
except Exception:
pass # 안 보이면 아래 _box 가 까닭을 냄
items, boxes = [], []
for c in scene.get("callouts") or []:
frame = c.get("frame")
els = [_box(page, sel, frame, f"번호 {c['n']}") for sel in callout_sels(c["sel"])]
ring, dot = callout_boxes(union_box(els), c.get("pos", "tl"), vw, vh)
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, account: str = "sys") -> None:
cred = account_cred(account)
res = context.request.post(
api + "/api/auth/login/request",
data={"email": cred["email"], "password": cred["password"]},
)
if not res.ok:
raise SceneError(f"로그인 실패({account}) 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]}")
contexts: dict[str, Any] = {} # 계정별 로그인한 컨텍스트 — 처음 쓸 때 만듦
def context_for(account: str) -> Any:
if account not in contexts:
ctx = browser.new_context(
viewport={"width": VIEWPORT[0], "height": VIEWPORT[1]},
device_scale_factor=SCALE,
locale="ko-KR",
color_scheme="light",
)
login(ctx, args.api, account)
contexts[account] = ctx
return contexts[account]
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)
ctx = context_for(sc.get("account", "sys"))
png, blocked = shoot_scene(ctx, 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 ""
acct = s.get("account", "sys")
print(f"{s['name']} {s['route']} {s.get('project', 'none')} {acct}{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())