feat(cafe24): 상단 공통 홍보 숨기기 체크박스 (PC·모바일 동시)
요청은 스킨 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>
This commit is contained in:
@@ -116,6 +116,9 @@ def _editor_ctx(st: Any, product_no: int) -> dict[str, Any]:
|
|||||||
),
|
),
|
||||||
# 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로).
|
# 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로).
|
||||||
"fingerprint": store.fingerprint(desc.description) if desc else "",
|
"fingerprint": store.fingerprint(desc.description) if desc else "",
|
||||||
|
# 상단 공통 홍보 숨김 상태(상세설명 안 CSS 블록으로 판단)
|
||||||
|
"promo_hidden": store.has_hidden_promo(desc.description) if desc else False,
|
||||||
|
"promo_hidden_mobile": store.has_hidden_promo(desc.mobile_description) if desc else False,
|
||||||
"revisions": st.list_revisions(product_no, limit=20),
|
"revisions": st.list_revisions(product_no, limit=20),
|
||||||
"editor_error": error,
|
"editor_error": error,
|
||||||
}
|
}
|
||||||
@@ -214,6 +217,7 @@ def product_apply(
|
|||||||
base_fingerprint: str = Form(""),
|
base_fingerprint: str = Form(""),
|
||||||
memo: str = Form(""),
|
memo: str = Form(""),
|
||||||
list_query: str = Form(""),
|
list_query: str = Form(""),
|
||||||
|
hide_promo: str = Form(""),
|
||||||
):
|
):
|
||||||
"""편집한 HTML 을 카페24에 즉시 적용한다.
|
"""편집한 HTML 을 카페24에 즉시 적용한다.
|
||||||
|
|
||||||
@@ -238,7 +242,10 @@ def product_apply(
|
|||||||
|
|
||||||
# 화면에서 보던 그대로(정리된 소스)를 카페24에 반영한다. 한글 이미지 경로는
|
# 화면에서 보던 그대로(정리된 소스)를 카페24에 반영한다. 한글 이미지 경로는
|
||||||
# 원래의 퍼센트 인코딩으로 되돌린다.
|
# 원래의 퍼센트 인코딩으로 되돌린다.
|
||||||
submitted = store.format_html(store.encode_html_urls(html or ""))
|
want_hide_promo = bool(hide_promo)
|
||||||
|
submitted = store.format_html(
|
||||||
|
store.set_promo_hidden(store.encode_html_urls(html or ""), want_hide_promo)
|
||||||
|
)
|
||||||
if not submitted.strip():
|
if not submitted.strip():
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url=f"{back}&err=내용이 비어 있습니다. 상세페이지를 비우려면 카페24 관리자에서 하세요.",
|
url=f"{back}&err=내용이 비어 있습니다. 상세페이지를 비우려면 카페24 관리자에서 하세요.",
|
||||||
@@ -273,11 +280,19 @@ def product_apply(
|
|||||||
status_code=303,
|
status_code=303,
|
||||||
)
|
)
|
||||||
|
|
||||||
if submitted == current.description:
|
if current.separated_mobile:
|
||||||
return RedirectResponse(url=f"{back}&msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303)
|
# 분리 상품은 모바일 본문을 건드리지 않는다. 다만 공통 홍보 숨김은 PC/모바일
|
||||||
|
# 양쪽에 걸어야 효과가 있으므로 그 블록만 모바일에도 맞춰준다.
|
||||||
|
mobile_target = store.set_promo_hidden(current.mobile_description, want_hide_promo)
|
||||||
|
mobile_html = mobile_target if mobile_target != current.mobile_description else None
|
||||||
|
else:
|
||||||
|
# 미분리 상품은 모바일도 함께 맞춘다(PC 만 바꾸면 모바일이 어긋난다).
|
||||||
|
mobile_html = submitted
|
||||||
|
|
||||||
# 미분리 상품은 모바일도 함께 맞춘다(PC 만 바꾸면 모바일이 어긋난다).
|
if submitted == current.description and (
|
||||||
mobile_html = None if current.separated_mobile else submitted
|
mobile_html is None or mobile_html == current.mobile_description
|
||||||
|
):
|
||||||
|
return RedirectResponse(url=f"{back}&msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303)
|
||||||
try:
|
try:
|
||||||
products.update_descriptions(
|
products.update_descriptions(
|
||||||
api.client, product_no, description=submitted, mobile_description=mobile_html
|
api.client, product_no, description=submitted, mobile_description=mobile_html
|
||||||
@@ -304,7 +319,9 @@ def product_apply(
|
|||||||
actor=actor, action="apply_description", product_no=product_no,
|
actor=actor, action="apply_description", product_no=product_no,
|
||||||
revision_id=revision_id, result="SUCCESS",
|
revision_id=revision_id, result="SUCCESS",
|
||||||
detail=f"{len(submitted)}자 적용 (백업 {backup_id}"
|
detail=f"{len(submitted)}자 적용 (백업 {backup_id}"
|
||||||
+ (", 모바일 동시 반영)" if mobile_html is not None else ")"),
|
+ (", 모바일 동시 반영" if mobile_html is not None else "")
|
||||||
|
+ (", 공통홍보 숨김" if want_hide_promo else "")
|
||||||
|
+ ")",
|
||||||
)
|
)
|
||||||
logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor)
|
logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor)
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
|
|||||||
@@ -368,6 +368,47 @@ def _collapse_short_blocks(lines: list[str]) -> list[str]:
|
|||||||
return out
|
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:
|
def fingerprint(html: str) -> str:
|
||||||
"""편집 시작 시점의 카페24 값 지문. 적용 직전 값과 비교해 충돌을 잡는다.
|
"""편집 시작 시점의 카페24 값 지문. 적용 직전 값과 비교해 충돌을 잡는다.
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,17 @@
|
|||||||
<input type="hidden" name="list_query" value="{{ list_query }}" />
|
<input type="hidden" name="list_query" value="{{ list_query }}" />
|
||||||
|
|
||||||
<div class="cf24-editor-bar">
|
<div class="cf24-editor-bar">
|
||||||
<span class="cf24-muted">PC 상세설명 HTML · {{ desc.description | length }}자</span>
|
<span class="cf24-muted">
|
||||||
|
PC 상세설명 HTML · {{ desc.description | length }}자
|
||||||
|
<label class="cf24-check-inline" title="상세페이지 상단의 공통 홍보 영역(.edb-img-tag-w)을 이 상품에서만 숨깁니다. PC·모바일 모두 적용됩니다.">
|
||||||
|
<input type="checkbox" name="hide_promo" value="1"
|
||||||
|
{% if promo_hidden %}checked{% endif %} />
|
||||||
|
상단 공통 홍보 숨기기
|
||||||
|
{% if promo_hidden != promo_hidden_mobile %}
|
||||||
|
<span class="cf24-warn">(PC/모바일 상태 불일치 — 적용하면 맞춰집니다)</span>
|
||||||
|
{% endif %}
|
||||||
|
</label>
|
||||||
|
</span>
|
||||||
<span class="cf24-editor-bar-right">
|
<span class="cf24-editor-bar-right">
|
||||||
<input class="cf24-memo" type="text" name="memo" maxlength="200"
|
<input class="cf24-memo" type="text" name="memo" maxlength="200"
|
||||||
placeholder="변경 메모 (버전 이력에 남습니다)" />
|
placeholder="변경 메모 (버전 이력에 남습니다)" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814h" />
|
<link rel="stylesheet" href="/static/cafe24.css?v=20260814i" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814h" />
|
<link rel="stylesheet" href="/static/cafe24.css?v=20260814i" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/static/cafe24.css?v=20260814h" />
|
<link rel="stylesheet" href="/static/cafe24.css?v=20260814i" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@@ -535,6 +535,45 @@ def test_format_then_encode_roundtrip():
|
|||||||
assert saved == raw
|
assert saved == raw
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 상단 공통 홍보 숨기기 (스킨 파일을 못 고치므로 상세설명 CSS 로 처리)
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def test_promo_hidden_add_and_remove():
|
||||||
|
body = "<div><img src=\"a.gif\"></div>"
|
||||||
|
on = store.set_promo_hidden(body, True)
|
||||||
|
assert store.has_hidden_promo(on) is True
|
||||||
|
assert ".edb-img-tag-w{display:none !important}" in on
|
||||||
|
assert body in on # 원래 내용은 그대로 남는다
|
||||||
|
|
||||||
|
off = store.set_promo_hidden(on, False)
|
||||||
|
assert store.has_hidden_promo(off) is False
|
||||||
|
assert off.strip() == body
|
||||||
|
|
||||||
|
|
||||||
|
def test_promo_hidden_is_idempotent():
|
||||||
|
body = "<div>x</div>"
|
||||||
|
once = store.set_promo_hidden(body, True)
|
||||||
|
assert store.set_promo_hidden(once, True) == once
|
||||||
|
twice_off = store.set_promo_hidden(store.set_promo_hidden(once, False), False)
|
||||||
|
assert twice_off.strip() == body
|
||||||
|
|
||||||
|
|
||||||
|
def test_promo_hidden_survives_formatting():
|
||||||
|
"""정리(포맷)를 거쳐도 숨김 상태가 유지되고 인식된다."""
|
||||||
|
formatted = store.format_html(store.set_promo_hidden("<div><p>내용</p></div>", True))
|
||||||
|
assert store.has_hidden_promo(formatted) is True
|
||||||
|
assert store.has_hidden_promo(store.format_html(store.set_promo_hidden(formatted, False))) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_promo_hidden_keeps_other_style_blocks():
|
||||||
|
"""사람이 쓴 <style> 은 건드리지 않는다 — 우리 블록만 id 로 찾는다."""
|
||||||
|
body = "<style>.v{max-width:100%}</style><div>x</div>"
|
||||||
|
on = store.set_promo_hidden(body, True)
|
||||||
|
off = store.set_promo_hidden(on, False)
|
||||||
|
assert ".v{max-width:100%}" in off
|
||||||
|
assert off.strip() == body
|
||||||
|
|
||||||
|
|
||||||
def test_fingerprint_detects_change():
|
def test_fingerprint_detects_change():
|
||||||
a = store.fingerprint("<p>A</p>")
|
a = store.fingerprint("<p>A</p>")
|
||||||
assert a == store.fingerprint("<p>A</p>")
|
assert a == store.fingerprint("<p>A</p>")
|
||||||
|
|||||||
@@ -240,6 +240,15 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cf24-check-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: var(--sp-12, 12px);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-rich-black, #0a0a0a);
|
||||||
|
}
|
||||||
|
|
||||||
.cf24-memo {
|
.cf24-memo {
|
||||||
width: 260px;
|
width: 260px;
|
||||||
padding: 6px var(--sp-10, 10px);
|
padding: 6px var(--sp-10, 10px);
|
||||||
|
|||||||
@@ -196,6 +196,40 @@ cafe24_oauth_tokens 저장
|
|||||||
- 닫는 태그가 빠진 HTML 이 흔하므로 들여쓰기 깊이에 상한(12)을 둔다. 어떤 이유로든
|
- 닫는 태그가 빠진 HTML 이 흔하므로 들여쓰기 깊이에 상한(12)을 둔다. 어떤 이유로든
|
||||||
실패하면 **원본을 그대로** 돌려준다(정리보다 안 깨지는 게 중요).
|
실패하면 **원본을 그대로** 돌려준다(정리보다 안 깨지는 게 중요).
|
||||||
|
|
||||||
|
### 2-3. 상단 공통 홍보 숨기기
|
||||||
|
|
||||||
|
스킨(`detail.html`)에는 공통 홍보를 지울 상품번호가 박혀 있다.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const numbers = [12,31,32, ...]; // .edb-img-tag-w 를 remove()
|
||||||
|
```
|
||||||
|
|
||||||
|
**카페24 Admin API 로는 이 파일을 고칠 수 없다.** 확인 결과 스킨/테마는 조회만
|
||||||
|
가능하고(`GET /admin/themes`), 스킨 HTML 파일을 읽거나 쓰는 엔드포인트가 없다.
|
||||||
|
쓸 수 있는 것은 테마 페이지와 스크립트 태그(`/admin/scripttags`)뿐이다.
|
||||||
|
|
||||||
|
그래서 같은 결과를 **상품 상세설명 안의 CSS** 로 낸다. 상세설명은 우리가 쓸 수 있고,
|
||||||
|
상품별로 켜고 끌 수 있으며, 상태가 그 상품 소스에 그대로 보인다.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<style id="cf24-hide-common-promo">/* DBX ERP: 상단 공통 홍보 숨김 */
|
||||||
|
.edb-img-tag-w{display:none !important}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
- 편집기 상단 **「상단 공통 홍보 숨기기」** 체크박스로 켜고 끈다. 적용할 때 반영된다.
|
||||||
|
- `id` 로 우리 블록만 찾는다 — 사람이 쓴 `<style>` 은 건드리지 않는다.
|
||||||
|
- **PC·모바일 모두 적용된다.** 미분리 상품은 같은 HTML 이 양쪽에 들어가고,
|
||||||
|
분리 상품은 모바일 본문은 그대로 두고 이 블록만 모바일에도 맞춘다(양쪽에 걸어야
|
||||||
|
효과가 있다).
|
||||||
|
- 넣기/빼기가 멱등이며 포맷을 거쳐도 상태가 유지된다(테스트로 고정).
|
||||||
|
- 스킨의 `numbers` 목록과는 독립이다. 이미 목록에 있는 상품은 그대로 두면 된다
|
||||||
|
(스킨은 요소를 제거하고, 이쪽은 CSS 로 숨긴다 — 결과는 같고 충돌하지 않는다).
|
||||||
|
|
||||||
|
> 스킨의 `numbers` 방식을 그대로 자동화하려면 `/admin/scripttags` 로 전역 스크립트를
|
||||||
|
> 주입하는 방법이 있다. 다만 **디자인 쓰기 권한 추가 + 재인증**이 필요하고, 상품별
|
||||||
|
> 상태가 한 곳에 몰려 관리가 어려워진다. 지금은 채택하지 않았다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3-2. 편집·적용 규칙 (`POST /products/{no}/apply`)
|
## 3-2. 편집·적용 규칙 (`POST /products/{no}/apply`)
|
||||||
|
|||||||
Reference in New Issue
Block a user