25ed369583
사용자 결정에 따라 없던 기능으로 되돌린다. 카페24 API 로 스킨 detail.html 의
상품번호 목록을 고칠 수 없다는 것이 확인된 뒤, 대안(상세설명 CSS 주입 / 스크립트
태그)을 모두 채택하지 않기로 정리했다.
제거 대상:
store.py HIDE_PROMO_* 상수, has_hidden_promo, set_promo_hidden
routes_products 편집기 컨텍스트의 promo 상태, hide_promo 폼 필드,
적용 시 블록 주입, 분리 상품 모바일 블록 동기화, 감사로그 표기
_editor.html 「상단 공통 홍보 숨기기」 체크박스
cafe24.css .cf24-check-inline
tests promo 관련 4건
docs 2-3 절
적용 시 "변경 없음" 판정에 모바일 비교를 포함시킨 것은 남겼다. 공통 홍보와 무관하게
맞는 동작이다(PC 는 그대로여도 모바일이 PC 와 다르면 맞춰줘야 한다).
문서 정리도 함께 했다. 앞선 편집에서 3-1·2-2 절이 중복 삽입되고 절 순서가
뒤섞여 있던 것을 2 → 2-1 → 2-2 → 3 → 3-1 → 3-2 로 바로잡았다. 편집기 절에는
아직 이전 방식(크기 맞추기·sticky 줄번호)이 적혀 있었는데 실제 구현인 스크롤
동기화로 갱신하고, 크기 계산이 두 번 실패한 이유를 근거 수치와 함께 남겼다.
⚠️ 이미 이 기능으로 적용한 상품이 있다면 그 상품 상세설명에
<style id="cf24-hide-common-promo"> 블록이 남아 있다. 편집기에서 그 줄을 지우고
적용하면 된다.
검증: 유닛테스트 45개 통과, 라우트 10개 등록, 템플릿 컴파일 확인.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
378 lines
15 KiB
Python
378 lines
15 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
|
|
|
|
|
|
def fingerprint(html: str) -> str:
|
|
"""편집 시작 시점의 카페24 값 지문. 적용 직전 값과 비교해 충돌을 잡는다.
|
|
|
|
편집 중에 다른 사람이 카페24 관리자에서 같은 상품을 바꿨다면, 우리가 쓰는
|
|
순간 그 변경이 조용히 사라진다. 그것을 막기 위한 낙관적 잠금이다.
|
|
"""
|
|
return hashlib.sha256((html or "").encode("utf-8")).hexdigest()[:32]
|