"""카페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
\b(?:src|href|poster|data-src|data-original)\s*=\s*(?P["']))(?P[^"']*)(?P=q)""", re.IGNORECASE, ) # CSS 의 url(...) — 인라인 " ) _HIDE_PROMO_RE = re.compile( r"[ \t]*\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]