08ebe7b53d
요청은 스킨 detail.html 의 `const numbers = [...]` 를 고치는 것이었지만, 카페24
Admin API 로는 스킨 HTML 파일을 읽거나 쓸 수 없다. 확인 결과 테마는 조회만
가능하고(GET /admin/themes) 스킨 파일 엔드포인트가 없다. 쓸 수 있는 것은 테마
페이지와 스크립트 태그뿐이다.
그래서 같은 결과를 상품 상세설명 안의 CSS 로 낸다. 상세설명은 이미 우리가 쓸 수
있는 영역이고, 상품별로 켜고 끌 수 있으며, 상태가 그 상품 소스에 그대로 보인다.
새 권한이나 재인증도 필요하지 않다.
<style id="cf24-hide-common-promo">.edb-img-tag-w{display:none !important}</style>
id 로 우리 블록만 찾으므로 사람이 쓴 <style> 은 건드리지 않는다. 넣기/빼기는
멱등이고 소스 정리(format_html)를 거쳐도 상태가 유지된다.
PC·모바일 모두 반영한다. 미분리 상품은 같은 HTML 이 양쪽에 들어가고, 분리 상품은
모바일 본문을 건드리지 않되 이 블록만 모바일에도 맞춘다 — 양쪽에 걸지 않으면 한쪽에
홍보가 그대로 남는다. PC/모바일 상태가 다르면 화면에 불일치를 알린다.
"변경 없음" 판정에 모바일 변경도 포함시켰다. PC 는 그대로인데 모바일 숨김만
바뀌는 경우가 있어서, 예전 조건이면 아무 일도 하지 않고 끝났다.
스킨의 numbers 목록과는 독립이며 충돌하지 않는다(스킨은 요소 제거, 이쪽은 CSS 숨김).
이미 목록에 있는 상품은 그대로 두면 된다.
검증: 유닛테스트 49개 통과(신규 4개 — 추가/제거 왕복, 멱등, 포맷 통과 후 인식,
사람이 쓴 style 보존).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
419 lines
17 KiB
Python
419 lines
17 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
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# 상단 공통 홍보 숨기기
|
|
#
|
|
# 스킨(detail.html)에는 공통 홍보를 지울 상품번호 목록이 박혀 있다.
|
|
# const numbers = [12,31,32, ...]; // .edb-img-tag-w 를 remove()
|
|
# 그런데 카페24 Admin API 는 **스킨 파일을 읽거나 쓸 수 없다**(테마는 조회만).
|
|
# 그래서 같은 결과를 상품 상세설명 안의 CSS 로 낸다 — 상세설명은 우리가 쓸 수 있고,
|
|
# 상품별로 켜고 끌 수 있으며, 상태가 그 상품 소스에 그대로 보인다.
|
|
#
|
|
# id 를 붙여 우리가 넣은 블록임을 표시한다. 사람이 쓴 <style> 은 건드리지 않는다.
|
|
# ════════════════════════════════════════════════════════════
|
|
HIDE_PROMO_ID = "cf24-hide-common-promo"
|
|
HIDE_PROMO_BLOCK = (
|
|
f'<style id="{HIDE_PROMO_ID}">/* DBX ERP: 상단 공통 홍보 숨김 */\n'
|
|
".edb-img-tag-w{display:none !important}\n"
|
|
"</style>"
|
|
)
|
|
_HIDE_PROMO_RE = re.compile(
|
|
r"[ \t]*<style[^>]*\bid\s*=\s*[\"']%s[\"'][^>]*>.*?</style>\s*" % re.escape(HIDE_PROMO_ID),
|
|
re.IGNORECASE | re.DOTALL,
|
|
)
|
|
|
|
|
|
def has_hidden_promo(html: str) -> bool:
|
|
"""이 상품의 상세설명에 공통 홍보 숨김 블록이 들어 있는가."""
|
|
return bool(_HIDE_PROMO_RE.search(html or ""))
|
|
|
|
|
|
def set_promo_hidden(html: str, hidden: bool) -> str:
|
|
"""숨김 블록을 넣거나 뺀다. 여러 번 호출해도 결과가 같다(멱등).
|
|
|
|
넣을 때는 맨 앞에 둔다 — 찾기 쉽고, 상세설명 어디에 있어도 CSS 효과는 같다.
|
|
"""
|
|
stripped = _HIDE_PROMO_RE.sub("", html or "")
|
|
if not hidden:
|
|
return stripped
|
|
if not stripped.strip():
|
|
return HIDE_PROMO_BLOCK
|
|
return HIDE_PROMO_BLOCK + "\n" + stripped.lstrip("\n")
|
|
|
|
|
|
def fingerprint(html: str) -> str:
|
|
"""편집 시작 시점의 카페24 값 지문. 적용 직전 값과 비교해 충돌을 잡는다.
|
|
|
|
편집 중에 다른 사람이 카페24 관리자에서 같은 상품을 바꿨다면, 우리가 쓰는
|
|
순간 그 변경이 조용히 사라진다. 그것을 막기 위한 낙관적 잠금이다.
|
|
"""
|
|
return hashlib.sha256((html or "").encode("utf-8")).hexdigest()[:32]
|