Files
dbx-main/app/modules/cafe24/store.py
T
king 3522fc2119 feat(cafe24): 일괄수정 — 상세페이지 <style> 블록 통일
87개 상품의 상세설명 맨 위 <style> 을 정해진 내용으로 바꾸는 화면을 추가했다.

    <style>
    	div {
    		text-align: center;
    	}
    </style>

그냥 덮어쓰지 않고 검사 → 선택 → 적용 2단계로 만들었다. 상품 131번의 style 안에는
"비디오 태그 모바일 반응형 스타일" 같은 CSS 가 들어 있어서, 무엇이 지워지는지 보지
않고 87건을 일괄 실행하면 필요한 규칙이 조용히 사라진다. 검사 결과 표에 지금 들어
있는 CSS 를 그대로 보여주고, 변경이 필요한 상품만 자동 선택한다(이미 같은 내용이면
「이미 동일」로 제외).

맨 앞 <style> 블록 하나만 바꾼다. 아래쪽에 <style> 이 더 있으면 건드리지 않고
「블록 2개 · 주의」로 표시해 사람이 판단하게 한다 — 일괄 작업이 남의 CSS 를 조용히
지우는 것이 가장 위험하다. 블록이 없는 상품은 맨 앞에 넣는다.

상품 1건당 1요청으로 쪼갰다. 87건을 한 요청으로 묶으면 1분 가까이 걸려 프록시
타임아웃에 걸리고, 동시에 던지면 카페24 호출 제한(429)에 걸린다. 브라우저가 순차
호출하며 진행률을 보여주고, 한 건 실패가 나머지를 막지 않으며 어디까지 됐는지
화면에 남는다.

적용 순서는 단건 편집과 같은 원칙을 지킨다: 카페24 현재값 재조회 → BACKUP 버전 →
교체 → PUT → MANUAL 버전 + 감사로그(action=bulk_style). 검사 때 읽은 값을 재사용하지
않고 쓰기 직전에 다시 읽는다. PC/모바일 분리 상품은 모바일도 함께 바꾼다.

검증: 유닛테스트 51개 통과(신규 6개 — 앞 블록만 교체하고 뒤 블록 보존, 없을 때 삽입,
멱등, 포맷 후 탭 유지, 여러 줄 원문 정확히 절단). 실제 데이터로 미리보기 로직 확인:
비디오 CSS 가 "지워질 내용"에 잡히고, 이미 동일한 상품은 will_change=False,
style 없는 상품은 삽입 대상으로 판정. 라우트 13개 등록, 템플릿 렌더 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:12:24 +09:00

406 lines
16 KiB
Python

"""카페24 모듈 순수 로직 — DB/네트워크 I/O 없음(유닛테스트 대상).
상수, 상태 전이 규칙, HTML 치환/검증처럼 부수효과 없는 함수만 둔다.
"""
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" # 저장만 한 초안
REVISION_BACKUP = "BACKUP" # 쓰기 직전 자동 백업 ← 복원 기준
REVISION_MANUAL = "MANUAL" # 즉시 적용
REVISION_SCHEDULED = "SCHEDULED" # 예약 적용
REVISION_ROLLBACK = "ROLLBACK" # 과거 버전 되돌림
REVISION_TYPES: tuple[str, ...] = (
REVISION_SYNC,
REVISION_DRAFT,
REVISION_BACKUP,
REVISION_MANUAL,
REVISION_SCHEDULED,
REVISION_ROLLBACK,
)
REVISION_LABELS: dict[str, str] = {
REVISION_SYNC: "현재값 동기화",
REVISION_DRAFT: "초안",
REVISION_BACKUP: "적용 직전 자동백업",
REVISION_MANUAL: "즉시 적용",
REVISION_SCHEDULED: "예약 적용",
REVISION_ROLLBACK: "복원",
}
# ── 예약 상태 (cafe24_product_schedules.status) ──
STATUS_PENDING = "PENDING"
STATUS_PROCESSING = "PROCESSING"
STATUS_SUCCESS = "SUCCESS"
STATUS_FAILED = "FAILED"
STATUS_CANCELLED = "CANCELLED"
SCHEDULE_STATUSES: tuple[str, ...] = (
STATUS_PENDING,
STATUS_PROCESSING,
STATUS_SUCCESS,
STATUS_FAILED,
STATUS_CANCELLED,
)
SCHEDULE_STATUS_LABELS: dict[str, str] = {
STATUS_PENDING: "대기",
STATUS_PROCESSING: "실행중",
STATUS_SUCCESS: "완료",
STATUS_FAILED: "실패",
STATUS_CANCELLED: "취소",
}
# 사용자가 손댈 수 있는 상태 — PROCESSING/SUCCESS 는 임의 변경 금지
EDITABLE_STATUSES: tuple[str, ...] = (STATUS_PENDING,)
# 예약 실패 시 최대 재시도 횟수
MAX_RETRY = 3
# 종료 후 동작 (cafe24_product_schedules.end_action)
END_NONE = ""
END_RESTORE = "restore" # 적용 직전 BACKUP 으로 복원
END_REVISION = "revision" # 지정한 버전 적용
END_ACTIONS: tuple[str, ...] = (END_NONE, END_RESTORE, END_REVISION)
def is_editable(status: str) -> bool:
"""예약을 수정/취소할 수 있는 상태인지."""
return (status or "").strip().upper() in EDITABLE_STATUSES
def can_retry(retry_count: int) -> bool:
"""재시도 여지가 남았는지. 소진되면 FAILED 로 확정한다."""
try:
return int(retry_count) < MAX_RETRY
except (TypeError, ValueError):
return False
def retry_backoff_seconds(retry_count: int) -> int:
"""재시도 간격(초). 1분 → 5분 → 15분. 무한 재시도는 하지 않는다."""
table = (60, 300, 900)
try:
index = max(0, int(retry_count))
except (TypeError, ValueError):
index = 0
return table[min(index, len(table) - 1)]
def normalize_revision_type(value: str) -> str:
text = (value or "").strip().upper()
return text if text in REVISION_TYPES else REVISION_DRAFT
def parse_product_no(value: object) -> int:
"""상품번호 정규화. 잘못된 값이면 ValueError."""
try:
number = int(str(value).strip())
except (TypeError, ValueError):
raise ValueError("상품번호는 숫자여야 합니다.") from None
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)
# ════════════════════════════════════════════════════════════
# 소스 정리(포맷) — 태그마다 줄을 나누고 들여쓴다.
#
# ⚠️ 렌더링을 바꾸지 않는 것이 최우선이다. HTML 에서 공백은 의미가 있어서,
# 인라인 요소 사이에 줄바꿈을 넣으면 화면에 공백이 생긴다(이미지 사이가
# 벌어지는 고전적인 사고). 그래서 **블록 요소 경계에서만** 줄을 나눈다.
# img·br·span·a 같은 인라인 요소와 텍스트는 원래 줄에 그대로 둔다.
# <style>·<script>·<pre>·<textarea> 안은 한 글자도 건드리지 않는다.
# ════════════════════════════════════════════════════════════
# 앞뒤 공백이 렌더링에 영향을 주지 않는 구조 태그만 넣는다.
_BLOCK_TAGS = frozenset(
"""html head body div p table thead tbody tfoot tr td th caption colgroup col
ul ol li dl dt dd section article header footer nav aside main
figure figcaption form fieldset legend h1 h2 h3 h4 h5 h6 hr center blockquote
style script iframe noscript""".split()
)
# 안쪽을 원문 그대로 보존할 태그
_RAW_TAGS = frozenset({"style", "script", "pre", "textarea"})
# 닫는 태그가 없는 태그
_VOID_TAGS = frozenset(
"area base br col embed hr img input link meta param source track wbr".split()
)
# 들여쓰기가 무한히 깊어지지 않게 (닫는 태그를 생략한 HTML 이 흔하다)
_MAX_INDENT = 12
_TOKEN_RE = re.compile(
r"(?P<comment><!--.*?-->)"
r"|(?P<cdata><!\[CDATA\[.*?\]\]>)"
r"|(?P<decl><![^>]*>)"
r"|(?P<tag><(?P<slash>/?)\s*(?P<name>[a-zA-Z][\w:.-]*)"
r"(?P<attrs>(?:\"[^\"]*\"|'[^']*'|[^>\"'])*)>)",
re.DOTALL,
)
def format_html(html: str, *, indent: str = " ") -> str:
"""상세페이지 HTML 을 사람이 읽기 좋게 정리한다.
실패하면 원본을 그대로 돌려준다 — 정리보다 안 깨지는 게 중요하다.
같은 값을 두 번 넣어도 결과가 같다(멱등).
"""
source = html or ""
if not source.strip():
return source
try:
return _format_html(source, indent)
except Exception: # noqa: BLE001 — 어떤 이유로든 원본을 지키는 쪽을 택한다.
return source
def _format_html(source: str, indent: str) -> str:
lines: list[str] = []
buffer = ""
depth = 0
def pad(level: int) -> str:
return indent * min(max(level, 0), _MAX_INDENT)
def flush() -> None:
"""모아둔 인라인/텍스트를 내보낸다.
원문에 이미 있던 줄바꿈은 **그대로 살린다.** 이미지가 한 줄에 하나씩 적혀
있으면 그 모양이 저자의 의도이고, 한 줄로 합치면 오히려 읽기 어려워진다.
각 줄마다 현재 깊이로 들여쓴다(줄 앞 공백은 렌더링에 영향이 없다).
빈 줄은 연속 한 개까지만 남겨 구획을 유지한다.
"""
nonlocal buffer
# 양 끝 공백을 함께 제거한다. `"\n"` 만 벗기면 끝에 남은 `"\n "` 조각이
# 빈 줄로 바뀌어 실행마다 빈 줄이 하나씩 늘어난다(멱등 깨짐).
# 블록 태그 경계의 공백은 렌더링에 영향이 없으므로 제거해도 안전하다.
text = buffer.strip()
buffer = ""
if not text:
return
for raw_line in text.split("\n"):
line = raw_line.strip()
if not line:
# 문서 맨 앞이나 빈 줄 뒤에는 빈 줄을 더하지 않는다.
if lines and lines[-1] != "":
lines.append("")
continue
lines.append(pad(depth) + line)
position = 0
while True:
match = _TOKEN_RE.search(source, position)
if match is None:
buffer += source[position:]
break
buffer += source[position : match.start()]
position = match.end()
raw = match.group(0)
# 주석·DOCTYPE 등은 흐름에 그대로 둔다.
# 상세페이지에는 `<!-- 대파_타임랩스 --><img ...>` 처럼 바로 뒤 요소를
# 설명하는 주석이 많다. 줄을 강제로 나누면 라벨과 대상이 떨어져 오히려
# 읽기 나빠진다. 원문에서 줄이 나뉘어 있었다면 flush 가 그 줄바꿈을 살린다.
if match.group("comment") or match.group("cdata") or match.group("decl"):
buffer += raw
continue
name = (match.group("name") or "").lower()
closing = bool(match.group("slash"))
self_closed = (match.group("attrs") or "").rstrip().endswith("/")
# <style>/<script>/<pre>/<textarea> 안은 원문 유지
if name in _RAW_TAGS and not closing:
end = re.compile(r"</\s*%s\s*>" % re.escape(name), re.IGNORECASE).search(
source, position
)
inner = source[position : end.start()] if end else source[position:]
flush()
lines.append(pad(depth) + raw)
# 앞뒤 빈 줄은 버린다 — 남기면 매번 실행할 때마다 한 줄씩 늘어난다(멱등 깨짐).
body = inner.strip("\n")
if body:
for line in body.split("\n"):
lines.append(line.rstrip())
if end:
lines.append(pad(depth) + end.group(0))
position = end.end()
else:
position = len(source)
continue
# 인라인 태그와 텍스트는 줄을 나누지 않는다 (공백이 생기면 렌더링이 바뀐다)
if name not in _BLOCK_TAGS:
buffer += raw
continue
if closing:
flush()
depth -= 1
lines.append(pad(depth) + raw)
else:
flush()
lines.append(pad(depth) + raw)
if name not in _VOID_TAGS and not self_closed:
depth += 1
flush()
return "\n".join(_collapse_short_blocks(lines))
# 짧은 블록을 한 줄로 되돌릴 때 쓰는 패턴
_OPEN_TAG_LINE = re.compile(
r"^(?P<pad>\s*)<(?P<name>[a-zA-Z][\w:.-]*)(?:\"[^\"]*\"|'[^']*'|[^>\"'])*>$"
)
_BLOCK_TAG_IN_TEXT = re.compile(
r"</?(?:%s)\b" % "|".join(sorted(_BLOCK_TAGS)), re.IGNORECASE
)
# 한 줄로 합칠 최대 길이
_COLLAPSE_WIDTH = 120
def _collapse_short_blocks(lines: list[str]) -> list[str]:
"""`<td>\n 1\n</td>` 처럼 내용이 한 줄뿐인 짧은 블록은 한 줄로 되돌린다.
보기 좋게 하려는 것이며, 합치는 규칙이 결정적이라 멱등성은 유지된다.
"""
out: list[str] = []
index = 0
while index < len(lines):
opening = _OPEN_TAG_LINE.match(lines[index])
if opening and index + 2 < len(lines):
name = opening.group("name").lower()
middle = lines[index + 1].strip()
closing = lines[index + 2].strip()
merged = lines[index] + middle + closing
if (
name not in _VOID_TAGS
and name not in _RAW_TAGS
and closing.lower() == f"</{name}>"
and middle
and not _BLOCK_TAG_IN_TEXT.search(middle)
and len(merged) <= _COLLAPSE_WIDTH
):
out.append(merged)
index += 3
continue
out.append(lines[index])
index += 1
return out
# ════════════════════════════════════════════════════════════
# <style> 블록 일괄 교체
#
# 상세페이지 맨 위의 <style> 을 정해진 내용으로 통일할 때 쓴다.
# **맨 앞 블록 하나만** 건드린다. 아래쪽에 다른 <style> 이 더 있으면 그건 그대로
# 두고, 화면에서 "블록 2개" 로 알려 사람이 판단하게 한다 — 일괄 작업이 남의 CSS 를
# 조용히 지우는 것이 가장 위험하다.
# ════════════════════════════════════════════════════════════
_STYLE_BLOCK_RE = re.compile(r"<style\b[^>]*>.*?</style>", re.IGNORECASE | re.DOTALL)
def find_style_blocks(html: str) -> list[str]:
"""상세설명 안의 <style>…</style> 블록 전체(원문 그대로)."""
return _STYLE_BLOCK_RE.findall(html or "")
def replace_first_style_block(html: str, new_block: str) -> str:
"""맨 앞 <style> 블록을 new_block 으로 교체한다.
블록이 하나도 없으면 맨 앞에 넣는다. 두 번째 이후 블록은 건드리지 않는다.
"""
source = html or ""
match = _STYLE_BLOCK_RE.search(source)
if match is None:
return new_block + ("\n" + source.lstrip("\n") if source.strip() else "")
return source[: match.start()] + new_block + source[match.end() :]
def fingerprint(html: str) -> str:
"""편집 시작 시점의 카페24 값 지문. 적용 직전 값과 비교해 충돌을 잡는다.
편집 중에 다른 사람이 카페24 관리자에서 같은 상품을 바꿨다면, 우리가 쓰는
순간 그 변경이 조용히 사라진다. 그것을 막기 위한 낙관적 잠금이다.
"""
return hashlib.sha256((html or "").encode("utf-8")).hexdigest()[:32]