feat(cafe24): 상세페이지 HTML 편집·적용 + 이미지 경로 한글 표시

1) 한글 파일명이 %EC%9A%A9… 으로 보이던 문제

카페24는 상세페이지 안 이미지 경로를 퍼센트 인코딩해서 저장한다. 화면에서는
읽을 수 없으므로 store.decode_html_urls 로 풀어 보여주고, 저장할 때
encode_html_urls 로 되돌린다. 두 함수는 서로의 역이며 왕복이 보존된다 —
편집하지 않고 적용해도 카페24 저장값이 한 바이트도 달라지지 않는다.

깨뜨리지 않기 위한 두 가지 제약을 뒀다. 디코딩은 non-ASCII(%80~%FF)만 한다.
%20·%3C 를 풀면 URL·HTML 구조가 깨진다. 인코딩은 src/href/poster/data-src 와
CSS url() 안의 값만 한다. 본문 한글 텍스트를 인코딩하면 페이지가 망가진다.
UTF-8 로 해석되지 않는 이스케이프(EUC-KR 등)는 건드리지 않고 그대로 둔다.

2) 편집 후 적용

POST /cafe24/products/{no}/apply 는 이 순서를 지킨다.
  카페24 현재값 재조회 → BACKUP revision → 지문 대조 → PUT → MANUAL revision
현재값을 다시 읽는 것은 로컬 DB 의 마지막 버전이 지금 카페24에 올라간 값이라고
믿을 수 없기 때문이다(관리자 페이지에서 직접 고쳤을 수 있다). 지문(sha256 앞
32자)은 편집 중 남이 바꾼 내용을 조용히 덮어쓰는 것을 막는 낙관적 잠금이다.

미분리 상품(separated_mobile_description='F')은 모바일 필드도 같은 HTML 로
함께 쓴다. PC 만 바꾸면 모바일 상세가 어긋난다. 분리 상품은 모바일을 건드리지
않고 화면에 별도 반영 안내를 띄운다.

빈 내용은 거부한다(상세페이지를 통째로 날리는 실수 방지). 변경이 없으면 API 를
호출하지 않는다. 실패 시에도 BACKUP 은 남아 있으므로 오류 메시지에 버전 번호를
알려준다. 편집 중 페이지 이탈 경고도 넣었다.

버전 이력 표를 상세 화면에 붙였다(목록 조회는 html_content 를 제외하고 길이만
계산한다 — 수 MB 가 될 수 있다). 버전 선택 복원은 Phase 6.

검증: 유닛테스트 30개 통과(신규 7개 — 실제 파일명으로 왕복 동일성, ASCII
이스케이프 미변환, 본문 한글 보존, CSS url(), 잘못된 UTF-8 무시, 지문).
실제 쓰기(PUT)는 서버 배포 후 테스트 상품 1건으로 확인 필요.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 12:14:42 +09:00
parent 07626bcaf8
commit 7a2e933c16
10 changed files with 459 additions and 27 deletions
+84
View File
@@ -5,6 +5,10 @@
from __future__ import annotations
import hashlib
import re
from urllib.parse import quote
# ── 상세페이지 버전 종류 (cafe24_product_revisions.revision_type) ──
REVISION_SYNC = "SYNC" # 카페24 현재값 스냅샷
REVISION_DRAFT = "DRAFT" # 저장만 한 초안
@@ -104,3 +108,83 @@ def parse_product_no(value: object) -> int:
if number <= 0:
raise ValueError("상품번호는 1 이상이어야 합니다.")
return number
# ════════════════════════════════════════════════════════════
# 이미지 URL 의 한글 파일명 표시 (%EC%9A%A9… ↔ 용기…)
#
# 카페24는 상세페이지 HTML 안 이미지 경로를 퍼센트 인코딩해서 저장한다.
# src="/web/product/big/%EC%9A%A9%EA%B8%B0…(%ED%99%A9%ED%86%A0)_12.gif"
# 사람이 읽을 수 없으니 화면에서는 한글로 풀어 보여주고, 카페24에 쓸 때는 다시
# 원래 형식으로 되돌린다. 두 함수는 서로의 역이며 왕복이 보존돼야 한다
# (encode(decode(원본)) == 원본).
#
# 안전 규칙 두 가지:
# 1) 디코딩은 **non-ASCII 바이트(%80~%FF)** 만 한다. %20·%3C·%26 같은 ASCII
# 이스케이프를 풀면 HTML 구조나 쿼리스트링이 깨진다.
# 2) 인코딩은 **URL 속성값 안의 non-ASCII** 만 한다. 본문 한글 텍스트를
# 건드리면 페이지가 깨지므로 대상 범위를 정규식으로 좁힌다.
# ════════════════════════════════════════════════════════════
# src="..." / href='...' 같은 URL 속성값
_URL_ATTR_RE = re.compile(
r"""(?P<head>\b(?:src|href|poster|data-src|data-original)\s*=\s*(?P<q>["']))(?P<url>[^"']*)(?P=q)""",
re.IGNORECASE,
)
# CSS 의 url(...) — 인라인 <style> 안 배경 이미지
_CSS_URL_RE = re.compile(
r"""(?P<head>url\(\s*(?P<q>["']?))(?P<url>[^"')]*)(?P<tail>(?P=q)\s*\))""",
re.IGNORECASE,
)
# 연속된 %XX 중 첫 바이트가 0x80 이상인 구간(= UTF-8 멀티바이트 문자)
_NON_ASCII_PCT_RUN = re.compile(r"(?:%[89A-Fa-f][0-9A-Fa-f])+")
# 인코딩 대상에서 제외할 문자 = 모든 ASCII 출력문자.
# 결과적으로 non-ASCII 와 공백만 %XX 로 바뀐다. 괄호·밑줄·마침표는 카페24
# 원본에서도 인코딩되지 않은 채 쓰이므로 반드시 그대로 남겨야 한다.
_ASCII_SAFE = "".join(chr(code) for code in range(0x21, 0x7F))
def _decode_pct_run(match: re.Match[str]) -> str:
text = match.group(0)
try:
raw = bytes(int(text[i + 1 : i + 3], 16) for i in range(0, len(text), 3))
return raw.decode("utf-8")
except (ValueError, UnicodeDecodeError):
# UTF-8 이 아니면(EUC-KR 등) 건드리지 않는다 — 깨뜨리는 것보다 낫다.
return text
def decode_url_value(value: str) -> str:
return _NON_ASCII_PCT_RUN.sub(_decode_pct_run, value or "")
def encode_url_value(value: str) -> str:
return quote(value or "", safe=_ASCII_SAFE, encoding="utf-8")
def _map_urls(html: str, transform) -> str:
def attr(match: re.Match[str]) -> str:
return f"{match.group('head')}{transform(match.group('url'))}{match.group('q')}"
def css(match: re.Match[str]) -> str:
return f"{match.group('head')}{transform(match.group('url'))}{match.group('tail')}"
return _CSS_URL_RE.sub(css, _URL_ATTR_RE.sub(attr, html or ""))
def decode_html_urls(html: str) -> str:
"""화면 표시용 — URL 안 %XX(한글 등)를 원래 문자로 되돌린다."""
return _map_urls(html, decode_url_value)
def encode_html_urls(html: str) -> str:
"""카페24 저장용 — URL 안 non-ASCII 를 퍼센트 인코딩으로 되돌린다."""
return _map_urls(html, encode_url_value)
def fingerprint(html: str) -> str:
"""편집 시작 시점의 카페24 값 지문. 적용 직전 값과 비교해 충돌을 잡는다.
편집 중에 다른 사람이 카페24 관리자에서 같은 상품을 바꿨다면, 우리가 쓰는
순간 그 변경이 조용히 사라진다. 그것을 막기 위한 낙관적 잠금이다.
"""
return hashlib.sha256((html or "").encode("utf-8")).hexdigest()[:32]