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>
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
# 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}: 어떤 좌표계 후보로도 도엽번호 도곽과 정합하지 않음")
|
||||
Reference in New Issue
Block a user