Files
Aislo/B08_Quantity/B08_Quantity_Milestone_Capture.py
T
eomsangdonandClaude Opus 5.5 8f96b16380 feat(b08): 거리이정표 경로 그림 = 서버 자동 캡처 · 브라우저 캡처 없앰 (244-11)
- [등록] · [길찾기] 뒤 길 선 · 표지 → 서버 헤드리스 Edge(없으면 chrome)로 카카오 지도 1200×800 PNG → auto/<경로 id>.png
- 페이지 주소 = KAKAO_CAPTURE_ORIGIN(기본 http://localhost:5173) + /__route_capture page.route 채움
- 사용자 그림 우선 · 자동 그림으로 되돌리기 · 실패 = 까닭 한 줄 · 동시 요청 잠금 · 좌표 저장 없음
- [지도 캡처] · [화면 캡처] · getDisplayMedia · 244-10 캡처 안내 없앰(지도 SDK 도메인 안내는 그대로)
- 새 시험 test_244_11 · 244_4 · 244_9 · 244_10 기대값 고침 · requirements playwright

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 22:59:25 +09:00

143 lines
6.6 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.
"""B08 거리이정표 경로 그림 자동 캡처(PLAN 244-11 · 사용자 139번) — 서버 헤드리스 Edge.
[등록] · [길찾기] 뒤 화면이 그 경로의 길 선(마지막 길찾기 경로 좌표) · 표지를 보냄 →
서버가 헤드리스 Edge(Playwright channel msedge · 없으면 chrome · 번들 chromium 안 씀)로
카카오 지도 JS + 길 선 + 표지 + 경로 맞춤 → tilesloaded 뒤 1200×800 PNG → `<폴더>/<경로 id>.png`
(사용자 그림이 있으면 화면은 사용자 그림 우선).
페이지 주소 = .env KAKAO_CAPTURE_ORIGIN(없으면 http://localhost:5173) + /__route_capture —
page.route 로 가로채 채움(카카오 JS 키 도메인 검사만 통과 · vite 안 떠도 됨).
경로 좌표는 그림 만든 뒤 버림(저장 = 그림 · km) · 동시 요청 = 하나씩(잠금) · 실패 = 까닭 한 줄 ·
키 값은 돌려주지도 적지도 않음.
"""
from __future__ import annotations
import json
import os
import threading
import time
import urllib.parse
from pathlib import Path
from typing import Any
KAKAO_JS = "KAKAO_JS_KEY"
ORIGIN_KEY = "KAKAO_CAPTURE_ORIGIN"
DEFAULT_ORIGIN = "http://localhost:5173"
PAGE = "/__route_capture"
WIDTH, HEIGHT = 1200, 800
TIMEOUT_S = 20
CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠
LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색
TAIL = " — 그림 올리기를 쓰세요."
_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나(2~3초)
class CaptureError(Exception):
"""까닭 한 줄(키 · 예외 원문 없음)."""
def page_html(key: str, lines: list[Any], marks: list[dict[str, Any]]) -> str:
"""카카오 지도 + 길 선 + 표지 + 경로 맞춤 · 다 그림 = window.__done · SDK 못 받음 = __fail."""
data = json.dumps({"lines": lines, "marks": marks}, ensure_ascii=False).replace("<", "\\u003c")
sdk = f"https://dapi.kakao.com/v2/maps/sdk.js?appkey={urllib.parse.quote(key)}&autoload=false"
return f"""<!doctype html><html><head><meta charset="utf-8"></head><body style="margin:0">
<div id="m" style="width:{WIDTH}px;height:{HEIGHT}px"></div>
<script src="{sdk}" onerror="window.__fail=1"></script>
<script>window.kakao && kakao.maps.load(() => {{
const D = {data}, K = kakao.maps, at = (p) => new K.LatLng(p[0], p[1]);
const pts = [...D.marks.map((m) => [m.lat, m.lon]), ...D.lines.flat()].map(at);
const map = new K.Map(document.getElementById("m"), {{ center: pts[0], level: 7 }});
D.lines.forEach((l) => new K.Polyline({{ map, path: l.map(at), strokeWeight: 6,
strokeColor: "{LINE_COLOR}", strokeOpacity: 0.9 }}));
D.marks.forEach((m) => {{
const p = at([m.lat, m.lon]);
new K.Marker({{ map, position: p, title: m.name }});
const s = document.createElement("span");
s.textContent = m.name;
s.style.cssText = "background:#fff;border:1px solid #333;border-radius:4px;padding:2px 6px;"
+ "font:600 13px sans-serif;white-space:nowrap";
new K.CustomOverlay({{ map, position: p, content: s, yAnchor: 2.6 }});
}});
K.event.addListener(map, "tilesloaded", () => (window.__done = true));
const b = new K.LatLngBounds();
pts.forEach((p) => b.extend(p));
map.setBounds(b, 60, 60, 60, 60);
}});</script></body></html>"""
def shoot(html: str, origin: str) -> bytes:
"""헤드리스 브라우저로 그 페이지 → 지도 칸 PNG. 실패 = CaptureError(까닭 한 줄)."""
try:
from playwright.sync_api import Error as PwError
from playwright.sync_api import TimeoutError as PwTimeout
from playwright.sync_api import sync_playwright
except ImportError as error:
raise CaptureError("서버에 Playwright 가 없습니다(pip install playwright)") from error
url = origin + PAGE
deadline = time.monotonic() + TIMEOUT_S
def left() -> float:
return max(1.0, deadline - time.monotonic()) * 1000
try:
with sync_playwright() as p:
browser = None
for channel in CHANNELS:
try:
browser = p.chromium.launch(channel=channel, headless=True, timeout=left())
break
except PwError:
continue
if browser is None:
raise CaptureError("서버에서 브라우저(Edge · Chrome)를 못 띄움")
try:
page = browser.new_page(viewport={"width": WIDTH, "height": HEIGHT})
page.route(
url,
lambda route: route.fulfill(
status=200, content_type="text/html; charset=utf-8", body=html
),
)
page.goto(url, timeout=left())
page.wait_for_function("window.__done || window.__fail", timeout=left())
if page.evaluate("window.__fail"):
raise CaptureError(
f"카카오 지도 JS 가 캡처 주소({origin})를 받지 않음(키 도메인 등록 확인)"
)
page.wait_for_timeout(500) # 마지막 타일 · 표지 그림
return page.locator("#m").screenshot(timeout=left())
finally:
browser.close()
except CaptureError:
raise
except PwTimeout as error:
raise CaptureError(f"지도 그림이 {TIMEOUT_S}초 안에 안 끝남") from error
except Exception as error: # 드라이버 못 뜸 등 — 예외 글은 내지 않음
raise CaptureError("서버에서 브라우저(Edge · Chrome)를 못 띄움") from error
def make_route_image(
out_dir: Path, route_id: str, lines: list[Any], marks: list[dict[str, Any]]
) -> dict[str, Any]:
"""그 경로 자동 그림 → {saved, reason} — 좌표는 그림에만 쓰고 버림."""
key = os.environ.get(KAKAO_JS, "").strip()
if not key:
return {
"saved": False,
"reason": f"카카오 지도 JavaScript 키가 없습니다(.env {KAKAO_JS}){TAIL}",
}
if not any(lines) and not marks:
return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{TAIL}"}
origin = (os.environ.get(ORIGIN_KEY, "").strip() or DEFAULT_ORIGIN).rstrip("/")
with _lock:
try:
png = shoot(page_html(key, lines, marks), origin)
except CaptureError as error:
return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {error}{TAIL}"}
out_dir.mkdir(parents=True, exist_ok=True)
new = out_dir / f".{route_id}.png.new"
new.write_bytes(png)
new.replace(out_dir / f"{route_id}.png")
return {"saved": True, "reason": None}