8ec38db6d1
사용자 결정에 따라 화면·라우트·로직·문서를 모두 제거했다. 제거 대상: routes_bulk.py (파일 삭제) templates/cafe24/bulk.html (파일 삭제) _nav.html 「일괄수정」 탭 router.py import·include·설명 store.py find_style_blocks / replace_first_style_block / 정규식 tests <style> 블록 교체 테스트 6건 docs 2-3 절, 경로 표 3줄, 구조도, Phase 7 표기 DB 는 손대지 않았다. 일괄수정은 전용 스키마를 만들지 않았고, 기존 테이블에 남은 기록은 **실제로 상품에 적용된 변경의 이력**이다. 특히 그때 만들어진 BACKUP revision 은 일괄 적용 이전 내용을 되찾을 유일한 수단이라 지우면 복구가 불가능해진다. 감사로그(action='bulk_style')도 누가 언제 무엇을 바꿨는지 남기는 기록이라 보존한다. 정말 지워야 한다면 별도로 요청받아 진행한다. 부수 수정: 예약 시각 검증 테스트가 실행 시각의 초에 따라 실패할 수 있었다. `datetime-local` 은 초를 버리므로 지금 이 분(分)을 고르면 최대 59초 과거가 되는데, 테스트가 now 의 초를 고정하지 않아 경계에서 흔들렸다. now 를 고정해 결정적으로 만들었다 (3회 반복 실행으로 확인). 검증: 유닛테스트 59개 통과, 라우트 13개, 템플릿 컴파일 확인, 모듈에 bulk 참조 0건. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
442 lines
18 KiB
Python
442 lines
18 KiB
Python
"""카페24 모듈 순수 로직 — DB/네트워크 I/O 없음(유닛테스트 대상).
|
|
|
|
상수, 상태 전이 규칙, HTML 치환/검증처럼 부수효과 없는 함수만 둔다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import re
|
|
from datetime import datetime, timedelta
|
|
from urllib.parse import quote
|
|
|
|
from app.timezone import KST
|
|
|
|
# ── 상세페이지 버전 종류 (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 normalize_schedule_status(value: str) -> str:
|
|
"""DB CHECK 제약에 걸리지 않게 상태값을 정규화한다."""
|
|
text = (value or "").strip().upper()
|
|
return text if text in SCHEDULE_STATUSES else STATUS_PENDING
|
|
|
|
|
|
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
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# 예약 입력 검증
|
|
#
|
|
# 되돌리기(자동 복원)는 쓰지 않는다. 예약은 "지정 시각에 이 내용을 적용" 뿐이다.
|
|
# 세 가지를 각각 선택할 수 있다 — 상세페이지 HTML / 진열 / 판매.
|
|
# ════════════════════════════════════════════════════════════
|
|
# 화면의 select 값 → 3-상태. 빈 값·미지정이면 "변경하지 않음"(None).
|
|
_TRISTATE: dict[str, bool] = {
|
|
"on": True, "off": False,
|
|
"t": True, "f": False,
|
|
"true": True, "false": False,
|
|
"1": True, "0": False,
|
|
}
|
|
|
|
|
|
def parse_tristate(value: object) -> bool | None:
|
|
"""'on'/'off'/'' → True/False/None. 알 수 없는 값은 "변경하지 않음"으로 본다."""
|
|
return _TRISTATE.get(str(value or "").strip().lower())
|
|
|
|
|
|
def parse_schedule_at(value: object, *, now: datetime | None = None) -> datetime:
|
|
"""`datetime-local` 입력('2026-08-20T14:00') → KST aware datetime.
|
|
|
|
타임존 표기가 없으므로 KST 로 해석한다(운영 기준 시간대).
|
|
과거 시각은 거부한다 — worker 가 즉시 실행해버려 "예약"의 의미가 없어진다.
|
|
"""
|
|
text = str(value or "").strip().replace(" ", "T")
|
|
if not text:
|
|
raise ValueError("예약 시각을 입력하세요.")
|
|
try:
|
|
parsed = datetime.fromisoformat(text)
|
|
except ValueError:
|
|
raise ValueError("예약 시각 형식이 올바르지 않습니다.") from None
|
|
aware = parsed if parsed.tzinfo else parsed.replace(tzinfo=KST)
|
|
current = now or datetime.now(KST)
|
|
# 1분 여유 — 폼을 채우는 동안 시간이 흐른 경우를 걸러내지 않기 위해.
|
|
if aware < current - timedelta(minutes=1):
|
|
raise ValueError("예약 시각이 이미 지났습니다. 앞으로의 시각을 지정하세요.")
|
|
return aware
|
|
|
|
|
|
def describe_schedule_action(
|
|
*, has_html: bool, set_display: bool | None, set_selling: bool | None
|
|
) -> str:
|
|
"""예약 내용을 한 줄로 요약(목록·로그 표시용)."""
|
|
parts: list[str] = []
|
|
if has_html:
|
|
parts.append("상세페이지")
|
|
if set_display is not None:
|
|
parts.append("진열" if set_display else "미진열")
|
|
if set_selling is not None:
|
|
parts.append("판매" if set_selling else "판매중지")
|
|
return " · ".join(parts) if parts else "없음"
|
|
|
|
|
|
def fingerprint(html: str) -> str:
|
|
"""편집 시작 시점의 카페24 값 지문. 적용 직전 값과 비교해 충돌을 잡는다.
|
|
|
|
편집 중에 다른 사람이 카페24 관리자에서 같은 상품을 바꿨다면, 우리가 쓰는
|
|
순간 그 변경이 조용히 사라진다. 그것을 막기 위한 낙관적 잠금이다.
|
|
"""
|
|
return hashlib.sha256((html or "").encode("utf-8")).hexdigest()[:32]
|