521 lines
26 KiB
Python
521 lines
26 KiB
Python
"""사용자 가이드 그림 자동 캡처(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())
|