Files
Aislo/B04_PreProcess/B04_PreProcess_Engine_SheetStore.py
eomsangdonandClaude Fable 5 f7528a4aa4 refactor(B04): B04_wf1_Surface -> B04_PreProcess 전면 개명
- 폴더·내부 파일 51개 접두사 개명 (git mv, 이력 보존)
- 저장소 전체 참조 치환 67파일: import 경로, 라우트 슬러그(b04-preprocess),
  라우트 키(B04_PREPROCESS), storage 경로 상수, locale, SQL 주석
- 로직 변경 없음 (기계적 치환). typecheck·백엔드 import 검증 통과

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 10:01:36 +09:00

358 lines
14 KiB
Python

# B04_PreProcess_Engine_SheetStore.py
# 1:5,000 수치지형도 도엽 zip — 프로젝트 영구저장소 관리.
# 저장 위치: {프로젝트 저장소}/B04_PreProcess/processed/map_sheets/{도엽번호}.zip
# (위성지도·GIS 벡터 등 주변데이터와 동일한 프로젝트 processed 계층)
#
# 조사 결과(2026-07-26 실측):
# - 도엽번호 산출: 브이월드 오픈 API(api.vworld.kr)에는 색인 레이어가 없어
# 자체 알고리즘(B04_PreProcess_Engine_MapSheet) 사용 — 보유 도엽 10매 정합 확인.
# - 도엽 검색: 지도서비스 내부 API `map.vworld.kr/ws3dmap/getDisitalMapList.do`
# (type=M02, searchNum=도엽번호, scale, version) — 무로그인 조회 가능.
# Ver2.0 = zip(SHP), Ver1.0 = dxf.
# - 도엽 다운로드: `map.vworld.kr/dtkmap/digitalDownload.do?DS_SQ=..&FILE_SQ=..`
# 로그인 세션 필요. 세션 만료 시 id/pw 자동 재로그인(Base64 POST, 캡차 없음 실측).
from __future__ import annotations
import base64
import datetime
import http.cookiejar
import json
import logging
import re
import shutil
import tempfile
import urllib.parse
import urllib.request
import zipfile
from pathlib import Path
from config.config_system import (
MAP_SHEETS_DIRNAME,
MAP_SHEETS_INDEX_FILENAME,
VWORLD_LOGIN_ID,
VWORLD_LOGIN_PW,
VWORLD_SESSION_COOKIE,
)
from .B04_PreProcess_Engine_MapSheet import latlon_to_sheet5k, sheet5k_to_bounds
logger = logging.getLogger(__name__)
# 도곽선 레이어 코드 (수치지형도 v2.0 도엽본)
_SHEET_FRAME_CODE = "A0010000"
_SHEET_NO_RE = re.compile(r"(\d{8})")
# 도엽본 좌표계 후보: 동부원점(5187)이 기본, 오라벨 zip은 중부원점(5186) 선언 사례 실측됨
_CRS_CANDIDATES = ("EPSG:5187", "EPSG:5186", "EPSG:5185", "EPSG:5188")
VWORLD_MAP_URL = "https://map.vworld.kr/map/dtkmap.do"
_SEARCH_API = "https://map.vworld.kr/ws3dmap/getDisitalMapList.do"
_DOWNLOAD_API = "https://map.vworld.kr/dtkmap/digitalDownload.do"
_LOGIN_API = "https://map.vworld.kr/map/im_usrlogin_a004.do"
# 프로세스 내 로그인 세션 캐시 (만료 시 자동 재로그인으로 갱신)
_session_cookie_cache: str = ""
def get_project_map_sheets_dir(project_storage_path: str | Path) -> Path:
"""프로젝트 영구저장소 내 도엽 저장 폴더 경로.
project_storage_path: config_system.get_project_storage_path() 결과
(storage/{회사}/{사용자}/{프로젝트ID}).
"""
return Path(project_storage_path) / "B04_PreProcess" / "processed" / MAP_SHEETS_DIRNAME
# ─────────────────────────────────────────────────────────────────────────
# 브이월드 접근 (로그인 / 검색 / 다운로드)
# ─────────────────────────────────────────────────────────────────────────
def vworld_login() -> str:
"""id/pw로 브이월드 지도서비스 로그인, Cookie 헤더 문자열 반환.
로그인 폼은 Base64 인코딩 필드(usrIdeE/usrPwdE)를 받는 단순 POST — 캡차 없음 실측.
"""
if not (VWORLD_LOGIN_ID and VWORLD_LOGIN_PW):
raise PermissionError(".env에 VWORLD_LOGIN_ID / VWORLD_LOGIN_PW 설정 필요")
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
opener.open(VWORLD_MAP_URL, timeout=30).read()
form = urllib.parse.urlencode(
{
"prevUrl": "",
"nextUrl": "",
"usrIdeE": base64.b64encode(VWORLD_LOGIN_ID.encode()).decode(),
"usrPwdE": base64.b64encode(VWORLD_LOGIN_PW.encode()).decode(),
"callback": "loginSuccessCall",
"isJson": "1",
"usrIde": "",
"usrPwd": "",
}
).encode()
req = urllib.request.Request(
_LOGIN_API,
data=form,
headers={"Referer": VWORLD_MAP_URL, "X-Requested-With": "XMLHttpRequest"},
)
body = opener.open(req, timeout=30).read().decode("utf-8", errors="replace")
if '"result" : "success"' not in body and '"result":"success"' not in body:
raise PermissionError(f"브이월드 로그인 실패: {body.strip()[:200]}")
global _session_cookie_cache
_session_cookie_cache = "; ".join(f"{c.name}={c.value}" for c in jar)
return _session_cookie_cache
def search_sheet_files(sheet_no: str, version: int = 2, scale: int = 5000) -> list[dict]:
"""브이월드 지도서비스 도엽검색 API. 무로그인. version: 1=Ver1.0(dxf), 2=Ver2.0(zip), 3=둘 다.
반환 행: ds_sq, file_no, file_nm, file_extsn, file_size, dtm_id, dtm_nm,
scale_nm, open_isscntm(좌표계), last_upt_dat
"""
params = urllib.parse.urlencode(
{
"type": "M02",
"searchNum": str(sheet_no),
"scale": str(scale),
"cod": "1",
"version": str(version),
}
)
req = urllib.request.Request(f"{_SEARCH_API}?{params}")
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode("utf-8"))
return data.get("RSLT_DATA") or []
def _fetch_sheet_payload(row: dict, session_cookie: str) -> bytes:
"""도엽 파일 바이너리 요청. zip 시그니처가 아니면 PermissionError(세션 만료)."""
params = urllib.parse.urlencode({"DS_SQ": row["ds_sq"], "FILE_SQ": row["file_no"]})
req = urllib.request.Request(
f"{_DOWNLOAD_API}?{params}",
headers={"Cookie": session_cookie, "Referer": VWORLD_MAP_URL},
)
with urllib.request.urlopen(req, timeout=300) as resp:
payload = resp.read()
if payload[:2] != b"PK":
snippet = payload[:200].decode("utf-8", errors="replace")
raise PermissionError(f"다운로드 실패(로그인 세션 확인 필요): {snippet}")
return payload
def download_sheet(store_dir: str | Path, sheet_no: str, version: int = 2) -> dict:
"""도엽 다운로드 후 프로젝트 영구저장소 등록. 세션 만료 시 자동 재로그인 1회 재시도.
쿠키 우선순위: 프로세스 캐시 > .env VWORLD_SESSION_COOKIE > 자동 로그인.
"""
rows = search_sheet_files(sheet_no, version=version)
if not rows:
raise LookupError(f"도엽 {sheet_no}: 브이월드 검색 결과 없음")
row = rows[0]
cookie = _session_cookie_cache or VWORLD_SESSION_COOKIE
payload = None
if cookie:
try:
payload = _fetch_sheet_payload(row, cookie)
except PermissionError:
payload = None # 세션 만료 → 자동 재로그인 폴백
if payload is None:
cookie = vworld_login()
try:
payload = _fetch_sheet_payload(row, cookie)
except PermissionError as e:
raise PermissionError(f"도엽 {sheet_no}: 재로그인 후에도 실패: {e}") from e
store = Path(store_dir)
store.mkdir(parents=True, exist_ok=True)
tmp_path = store / f"_dl_{row['file_nm']}"
tmp_path.write_bytes(payload)
try:
entry = ingest_zip(store, tmp_path, sheet_no=str(sheet_no))
entry["source"] = "vworld 지도서비스 자동 다운로드"
index = _load_index(store)
index["sheets"][str(sheet_no)] = entry
_save_index(store, index)
return entry
finally:
tmp_path.unlink(missing_ok=True)
def ensure_sheets(store_dir: str | Path, sheet_nos: list[str]) -> dict:
"""도엽 목록 확보: 없는 도엽은 자동 다운로드. {available, failed} 반환."""
store = Path(store_dir)
available: dict[str, str] = {}
failed: dict[str, str] = {}
for s in sheet_nos:
s = str(s)
path = get_sheet_path(store, s)
if path is None:
try:
download_sheet(store, s)
path = get_sheet_path(store, s)
except Exception as e:
failed[s] = f"{type(e).__name__}: {e}"
continue
available[s] = str(path)
return {"available": available, "failed": failed}
# ─────────────────────────────────────────────────────────────────────────
# 영구저장소 인덱스 / 조회 / 인제스트
# ─────────────────────────────────────────────────────────────────────────
def _load_index(store_dir: Path) -> dict:
p = store_dir / MAP_SHEETS_INDEX_FILENAME
if p.exists():
return json.loads(p.read_text(encoding="utf-8"))
return {"sheets": {}}
def _save_index(store_dir: Path, index: dict) -> None:
store_dir.mkdir(parents=True, exist_ok=True)
(store_dir / MAP_SHEETS_INDEX_FILENAME).write_text(
json.dumps(index, ensure_ascii=False, indent=2), encoding="utf-8"
)
def get_sheet_path(store_dir: str | Path, sheet_no: str) -> Path | None:
"""영구저장소에 보관된 도엽 zip 경로. 없으면 None."""
store = Path(store_dir)
entry = _load_index(store)["sheets"].get(str(sheet_no))
if not entry:
return None
path = store / entry["file"]
return path if path.exists() else None
def prune_sheets(store_dir: str | Path, keep_sheet_nos: list[str]) -> list[str]:
"""선정 도엽에 없는 zip을 지우고 지운 도엽번호를 돌려준다.
도엽 기준이 바뀌거나 다른 지역 파일이 섞여 들어오면 쓰지 않는 zip이 계속 쌓인다
(표본 프로젝트에서 30매 중 21매가 다른 지역 잔재였다, 2026-08-01).
병합에는 선정 도엽만 쓰이므로 산출물은 그대로다.
"""
store = Path(store_dir)
if not store.is_dir():
return []
keep = {str(sheet_no) for sheet_no in keep_sheet_nos}
index = _load_index(store)
removed: list[str] = []
for zip_path in sorted(store.glob("*.zip")):
match = _SHEET_NO_RE.fullmatch(zip_path.stem)
if not match or match.group(1) in keep:
continue
sheet_no = match.group(1)
try:
zip_path.unlink()
except OSError as exc:
logger.warning("도엽 정리: %s 삭제 실패 (%s)", zip_path.name, exc)
continue
index["sheets"].pop(sheet_no, None)
removed.append(sheet_no)
if removed:
_save_index(store, index)
logger.info("도엽 정리: 미사용 %d매 삭제 (%s)", len(removed), ", ".join(removed))
return removed
def missing_sheets(store_dir: str | Path, sheet_nos: list[str]) -> list[str]:
"""요청 도엽 중 영구저장소에 없는 번호 목록."""
store = Path(store_dir)
return [s for s in sheet_nos if get_sheet_path(store, str(s)) is None]
def ingest_zip(store_dir: str | Path, src_path: str | Path, sheet_no: str | None = None) -> dict:
"""도엽 zip을 검증 후 영구저장소에 등록한다 (수동 다운로드 폴백 겸용).
- 도엽번호: 인자 우선, 없으면 파일명에서 8자리 추출.
- 도곽선 실측 bounds가 도엽번호 예측 도곽 안에 들어야 등록(해안 도엽은 부분 도곽 허용).
- PRJ 오라벨은 CRS 후보 대조로 자동 교정하고 crs 필드에 기록.
"""
store = Path(store_dir)
src = Path(src_path)
if not src.exists():
raise FileNotFoundError(str(src))
if sheet_no is None:
m = _SHEET_NO_RE.search(src.name)
if not m:
raise ValueError(f"파일명에서 도엽번호(8자리)를 찾을 수 없음: {src.name}")
sheet_no = m.group(1)
sheet_no = str(sheet_no)
bounds, crs = _read_frame_bounds_wgs84(src)
center_no = latlon_to_sheet5k((bounds[1] + bounds[3]) / 2, (bounds[0] + bounds[2]) / 2)
if center_no != sheet_no:
raise ValueError(f"도곽 실측 중심의 도엽번호({center_no})가 요청 번호({sheet_no})와 불일치")
store.mkdir(parents=True, exist_ok=True)
dest_name = f"{sheet_no}.zip"
dest = store / dest_name
if src.resolve() != dest.resolve():
shutil.copy2(src, dest)
index = _load_index(store)
index["sheets"][sheet_no] = {
"file": dest_name,
"original_name": src.name,
"crs": crs,
"bounds_wgs84": [round(v, 8) for v in bounds],
"acquired": datetime.date.today().isoformat(),
"source": "수동 다운로드 인제스트",
}
_save_index(store, index)
return index["sheets"][sheet_no]
def _read_frame_bounds_wgs84(zip_path: Path) -> tuple[tuple[float, float, float, float], str]:
"""zip 내 도곽선(A0010000) SHP bounds를 WGS84로 반환. (bounds, 사용된 CRS).
선언된 PRJ가 오라벨(중부/동부원점 뒤바뀜)인 실측 사례가 있어, 파일명 도엽번호
기준 예측 도곽과 대조해 정합한 CRS를 자동 판별한다.
"""
import geopandas as gpd
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
shp = [n for n in names if n.upper().endswith(f"{_SHEET_FRAME_CODE}.SHP")]
if not shp:
raise ValueError(f"{zip_path.name}: 도곽선({_SHEET_FRAME_CODE}) SHP 없음")
base = shp[0][:-4]
members = {}
for ext in (".shp", ".shx", ".dbf", ".prj", ".cpg"):
for n in names:
if n.lower() == (base + ext).lower():
members[ext] = zf.read(n)
m = _SHEET_NO_RE.search(zip_path.name)
expected = sheet5k_to_bounds(m.group(1)) if m else None
with tempfile.TemporaryDirectory() as td:
for ext, data in members.items():
(Path(td) / f"frame{ext}").write_bytes(data)
shp_path = Path(td) / "frame.shp"
for crs in _CRS_CANDIDATES:
gdf = gpd.read_file(shp_path)
gdf = gdf.set_crs(crs, allow_override=True)
b = tuple(gdf.to_crs("EPSG:4326").total_bounds)
if expected is None:
return b, crs
tol = 0.002
if (
b[0] >= expected[0] - tol
and b[1] >= expected[1] - tol
and b[2] <= expected[2] + tol
and b[3] <= expected[3] + tol
):
return b, crs
raise ValueError(f"{zip_path.name}: 어떤 좌표계 후보로도 도엽번호 도곽과 정합하지 않음")