3522fc2119
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>
406 lines
16 KiB
Python
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]
|