[fix] solution/backend,shared,site: 에디터에 쓴 소개문이 발행에서 사라지던 구멍 — 계약에 body 추가
사장님이 소개 섹션에 본문을 써도 발행이 "고유 콘텐츠 0건"으로 거부됐다. 본문은 sites.theme 에 저장은 되는데 payload 경계에서 버려졌다 — _sections() 가 저장값에서 id·name·enabled·locked·variantId 다섯 개만 꺼내 새로 만들었다. 그래서 발행본에 안 나오고, 계수에도 안 잡혔다. 거부 문구도 틀렸다. 렌더러가 '고유 콘텐츠 0건'을 JSON-LD 불일치와 같은 VerifyError 의 mismatches 에 실어 던져서, 백엔드가 JSONLD_MISMATCH 로 판정하고 화면에는 "구조화 데이터와 화면 값이 다릅니다" 가 떴다. 구조화 데이터는 멀쩡했다. - shared/site-payload: SectionSetting.body 추가 — variantId 와 같은 사연 - backend/site_payload: 저장된 body 를 payload 까지 실어 보낸다 - site/derive,AboutSection: 직접 쓴 본문을 그린다. 없으면 intro fact 로 떨어진다 - site/prerender: 켜진 소개 섹션의 8자 이상 본문을 고유 콘텐츠로 계수 - site/prerender: NoUniqueContentError 분리 — mismatches 를 비워 라벨이 안 섞이게. 계수를 못 잰 실패는 null 로 보고한다(0 으로 적으면 디스크 오류가 같은 사유를 받는다) - backend/build_service,publish_gate: 렌더 실패가 0건이면 NO_UNIQUE_CONTENT 라벨을 붙인다. evaluate() 는 안 건드렸다 — 얇은 콘텐츠로 발행을 막지 않기로 한 결정 그대로다 - backend/router: theme API 설명에 body 반영 테스트 8 failed / 511 passed. 실패 8건은 변경 전(508 passed)과 동일한 기존 실패다 (test_default_sections_match_the_editor 의 solution/front 경로 오타 등). tsc·site·shared 통과. 실물 검증: 본문만 있는 payload → ok=true, uniqueContentCount=1, 발행 HTML 에 문장 포함. 같은 payload 에서 본문을 빼면 0건으로 거부.
This commit is contained in:
parent
22b7623aeb
commit
8410380769
@ -5,6 +5,19 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍
|
||||||
|
|
||||||
|
**왜**
|
||||||
|
에디터의 소개 섹션 본문은 `sites.theme` 에 저장됐지만 발행 payload 경계에서 버려졌고,
|
||||||
|
프리렌더도 고유 콘텐츠로 세지 않았다. 사장님이 소개를 써도 발행 화면은 0건이라며 거부했다.
|
||||||
|
|
||||||
|
**한 일**
|
||||||
|
- `SectionSetting.body` 계약을 추가하고 저장값을 payload 까지 전달
|
||||||
|
- 소개 본문을 발행 HTML에 표시하고, 켜진 소개 섹션의 8자 이상 본문만 고유 콘텐츠로 계수
|
||||||
|
- 고유 콘텐츠 0건과 JSON-LD 불일치, 계수 실패를 서로 다른 발행 사유로 분리
|
||||||
|
|
||||||
|
**검증** — 직접 입력 소개문만 있는 발행 경로 회귀 테스트 추가.
|
||||||
|
|
||||||
## 2026-09-02 — 템플릿이 색만 바꾸던 걸 끝냈다 (5개 → 3개)
|
## 2026-09-02 — 템플릿이 색만 바꾸던 걸 끝냈다 (5개 → 3개)
|
||||||
|
|
||||||
**왜**
|
**왜**
|
||||||
|
|||||||
@ -201,6 +201,12 @@ def fake_renderer(monkeypatch, tmp_path_factory):
|
|||||||
return len(str(value or "").strip()) >= MIN_UNIQUE_TEXT
|
return len(str(value or "").strip()) >= MIN_UNIQUE_TEXT
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
|
intro = next(
|
||||||
|
(s for s in (payload.get("theme") or {}).get("sections") or [] if s.get("id") == "intro"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if intro and intro.get("enabled") and long(intro.get("body")):
|
||||||
|
count += 1
|
||||||
facts = list(payload.get("facts") or [])
|
facts = list(payload.get("facts") or [])
|
||||||
for unit in payload.get("units") or []:
|
for unit in payload.get("units") or []:
|
||||||
facts.extend(unit.get("facts") or [])
|
facts.extend(unit.get("facts") or [])
|
||||||
|
|||||||
@ -88,8 +88,8 @@ class Req_SiteTheme(SiteProtocol):
|
|||||||
|
|
||||||
★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
|
★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
|
||||||
{"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}}
|
{"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}}
|
||||||
sections 는 {id, name, enabled, locked, variantId?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
|
sections 는 {id, name, enabled, locked, variantId?, body?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
|
||||||
(별도 order 필드가 없다). variantId 는 고른 게 있을 때만 키가 붙는다.
|
(별도 order 필드가 없다). variantId 와 직접 입력 본문 body 는 값이 있을 때만 키가 붙는다.
|
||||||
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
|
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
|
||||||
서버는 배달부지 심판이 아니다.
|
서버는 배달부지 심판이 아니다.
|
||||||
|
|
||||||
|
|||||||
@ -106,7 +106,7 @@ async def set_template(
|
|||||||
description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다"
|
description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다"
|
||||||
"(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: "
|
"(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: "
|
||||||
"{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"colorPaletteId\":\"...\","
|
"{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"colorPaletteId\":\"...\","
|
||||||
"\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\"}]}}. "
|
"\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\"}]}}. "
|
||||||
"★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). "
|
"★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). "
|
||||||
"★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. "
|
"★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. "
|
||||||
"직렬화 크기(64KB)만 막는다. "
|
"직렬화 크기(64KB)만 막는다. "
|
||||||
|
|||||||
@ -200,7 +200,10 @@ async def run_build(job: dict) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
mismatches = list(report.get("mismatches") or [])
|
mismatches = list(report.get("mismatches") or [])
|
||||||
unique_count = report.get("uniqueContentCount") or 0
|
# ★ None(재지 못했다)과 0(재 봤더니 0건)을 뭉개지 않는다. 게이트는 raw 를 보고,
|
||||||
|
# 기록·화면에는 0 으로 떨어뜨린다. 뭉개면 디스크 오류가 NO_UNIQUE_CONTENT 로 둔갑한다.
|
||||||
|
unique_count_raw = report.get("uniqueContentCount")
|
||||||
|
unique_count = unique_count_raw or 0
|
||||||
jsonld = report.get("jsonld") or []
|
jsonld = report.get("jsonld") or []
|
||||||
result["unique_content_count"] = unique_count
|
result["unique_content_count"] = unique_count
|
||||||
result["mismatches"] = mismatches[:20]
|
result["mismatches"] = mismatches[:20]
|
||||||
@ -211,14 +214,25 @@ async def run_build(job: dict) -> dict:
|
|||||||
# 대개 게이트 사유(고유 콘텐츠 0건·구조화 데이터 불일치)이기 때문이다.
|
# 대개 게이트 사유(고유 콘텐츠 0건·구조화 데이터 불일치)이기 때문이다.
|
||||||
# 여기서 사유를 정확히 골라야 publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 —
|
# 여기서 사유를 정확히 골라야 publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 —
|
||||||
# 전부 "렌더 실패"로 뭉뚱그리면 운영자가 손댈 곳을 알 수 없다.
|
# 전부 "렌더 실패"로 뭉뚱그리면 운영자가 손댈 곳을 알 수 없다.
|
||||||
gate = publish_gate.evaluate(PlaceCategory(place.category), snapshot["facts"], unique_count, mismatches)
|
gate = publish_gate.evaluate(
|
||||||
|
PlaceCategory(place.category), snapshot["facts"], unique_count_raw, mismatches
|
||||||
|
)
|
||||||
result["gate"] = {"passed": gate.passed, **gate.as_log()}
|
result["gate"] = {"passed": gate.passed, **gate.as_log()}
|
||||||
|
|
||||||
if not gate.passed:
|
if not gate.passed:
|
||||||
return await _fail(f"{gate.reason.name}: {gate.as_log()}", gate, stamp)
|
return await _fail(f"{gate.reason.name}: {gate.as_log()}", gate, stamp)
|
||||||
|
|
||||||
if not report.get("ok"):
|
if not report.get("ok"):
|
||||||
# 게이트로 설명되지 않는 실패(디스크·번들·payload 파손). 재시도가 의미 있는 쪽이다.
|
# ★ 렌더러가 거부한 이유에 사유 코드를 붙인다. evaluate() 는 얇은 콘텐츠로 막지
|
||||||
|
# 않지만 렌더러는 스팸 판정을 피하려 페이지 쓰기를 거부한다 — 그 사유를 "렌더 실패"
|
||||||
|
# 로 뭉개면 화면이 NO_UNIQUE_CONTENT 문구를 못 고르고, 사장님은 손댈 곳을 모른다.
|
||||||
|
thin = publish_gate.check_unique_content(unique_count_raw)
|
||||||
|
if not thin.passed:
|
||||||
|
# 위에서 evaluate 결과로 채워 둔 gate 를 덮는다 — 화면(GateRejectCard)은 이 값으로
|
||||||
|
# 문구를 고르는데, passed=True 인 채로 두면 "서버 검수를 통과하지 못했습니다" 만 뜬다.
|
||||||
|
result["gate"] = {"passed": False, **thin.as_log()}
|
||||||
|
return await _fail(f"{thin.reason.name}: {thin.as_log()}", thin, stamp)
|
||||||
|
# 나머지는 게이트로 설명되지 않는 실패(디스크·번들·payload 파손). 재시도가 의미 있다.
|
||||||
return await _fail(str(report.get("error") or "렌더 실패"), None, stamp)
|
return await _fail(str(report.get("error") or "렌더 실패"), None, stamp)
|
||||||
|
|
||||||
# DB 발행 상태를 바꾸기 전에 정적 파일을 외부 저장소에 올린다.
|
# DB 발행 상태를 바꾸기 전에 정적 파일을 외부 저장소에 올린다.
|
||||||
|
|||||||
@ -63,11 +63,20 @@ def check_required_fields(category: PlaceCategory, facts: list) -> GateResult:
|
|||||||
return GateResult(True)
|
return GateResult(True)
|
||||||
|
|
||||||
|
|
||||||
def check_unique_content(unique_content_count: int) -> GateResult:
|
def check_unique_content(unique_content_count: int | None) -> GateResult:
|
||||||
"""고유 콘텐츠 품질 진단용. 발행 게이트에서는 더 이상 호출하지 않는다.
|
"""렌더러가 '고유 콘텐츠 0건' 으로 거부했을 때 사유 코드를 붙이는 자리.
|
||||||
|
|
||||||
|
evaluate() 는 이걸 부르지 않는다(얇은 콘텐츠로 발행을 막지 않기로 했다).
|
||||||
|
부르는 곳은 build_service — 렌더 보고서가 실패로 왔을 때 그 이유를 되짚는다.
|
||||||
|
|
||||||
이 가게에만 있는 것(소개문·FAQ·객실 설명·사진 alt·템플릿 아닌 fact 값)이 0이면
|
이 가게에만 있는 것(소개문·FAQ·객실 설명·사진 alt·템플릿 아닌 fact 값)이 0이면
|
||||||
같은 템플릿 대량 생성으로 보인다. 그건 스팸 판정 대상이고, 판정되면 사이트가 통째로 무의미해진다."""
|
같은 템플릿 대량 생성으로 보인다. 그건 스팸 판정 대상이고, 판정되면 사이트가 통째로 무의미해진다.
|
||||||
|
|
||||||
|
★ None 은 '0건' 이 아니라 '재지 못했다' 다. 렌더러가 디스크·번들 문제로 죽으면 계수가
|
||||||
|
없는 채로 보고서가 온다 — 그걸 0 으로 읽으면 디스크 오류에 NO_UNIQUE_CONTENT 라는
|
||||||
|
엉뚱한 사유가 붙는다. 판정하지 않고 통과시키고, 진짜 사유는 report.error 가 말한다."""
|
||||||
|
if unique_content_count is None:
|
||||||
|
return GateResult(True)
|
||||||
if unique_content_count <= 0:
|
if unique_content_count <= 0:
|
||||||
return GateResult(False, PublishRejectReason.NO_UNIQUE_CONTENT, {"unique_content_count": unique_content_count})
|
return GateResult(False, PublishRejectReason.NO_UNIQUE_CONTENT, {"unique_content_count": unique_content_count})
|
||||||
return GateResult(True)
|
return GateResult(True)
|
||||||
@ -83,11 +92,17 @@ def check_jsonld_matches(mismatches: list) -> GateResult:
|
|||||||
return GateResult(True)
|
return GateResult(True)
|
||||||
|
|
||||||
|
|
||||||
def evaluate(category: PlaceCategory, facts: list, unique_content_count: int, mismatches: list) -> GateResult:
|
def evaluate(
|
||||||
|
category: PlaceCategory, facts: list, unique_content_count: int | None, mismatches: list
|
||||||
|
) -> GateResult:
|
||||||
"""게이트 전체. **처음 걸린 것에서 멈춘다** — 운영자가 하나씩 고치게 사유를 하나만 준다.
|
"""게이트 전체. **처음 걸린 것에서 멈춘다** — 운영자가 하나씩 고치게 사유를 하나만 준다.
|
||||||
|
|
||||||
순서는 심각도 순: 미검증(클레임) → JSON-LD 불일치(거짓).
|
순서는 심각도 순: 미검증(클레임) → JSON-LD 불일치(거짓).
|
||||||
|
|
||||||
|
★ unique_content_count 는 받되 여기서 막지 않는다 — 얇은 콘텐츠는 '틀린 것' 이 아니다
|
||||||
|
(test_evaluate_does_not_block_thin_content). 실제로 페이지 쓰기를 거부하는 쪽은
|
||||||
|
렌더러이고, 그 사유는 build_service 가 check_unique_content 로 되짚어 라벨을 붙인다.
|
||||||
|
|
||||||
★ 업종 필수 항목 누락은 **막지 않는다**(2026-08-27 결정).
|
★ 업종 필수 항목 누락은 **막지 않는다**(2026-08-27 결정).
|
||||||
막아야 할 것은 "틀린 정보가 나가는 것"이지 "정보가 덜 찬 것"이 아니다.
|
막아야 할 것은 "틀린 정보가 나가는 것"이지 "정보가 덜 찬 것"이 아니다.
|
||||||
영업시간이 비어 있어도 주소·전화가 확인된 페이지는 그 자체로 쓸모가 있고,
|
영업시간이 비어 있어도 주소·전화가 확인된 페이지는 그 자체로 쓸모가 있고,
|
||||||
|
|||||||
@ -363,6 +363,15 @@ def _sections(saved_sections, default_spec) -> list:
|
|||||||
variant_id = _text(item.get("variantId"))
|
variant_id = _text(item.get("variantId"))
|
||||||
if variant_id:
|
if variant_id:
|
||||||
entry["variantId"] = variant_id
|
entry["variantId"] = variant_id
|
||||||
|
# ★ 사장님이 에디터에 직접 쓴 섹션 본문. variantId 와 같은 이유로 그대로 싣는다 —
|
||||||
|
# 이 필드가 없던 동안 캔버스에 쓴 소개문은 payload 경계에서 통째로 버려졌다.
|
||||||
|
# 저장(sites.theme)은 되는데 발행본에는 안 나오고, 고유 콘텐츠로도 세지 않아
|
||||||
|
# "소개를 썼는데 발행이 고유 콘텐츠 0건으로 막힌다" 가 됐다.
|
||||||
|
# ★ fact 가 아니라 검증 대상이 아니다. 사장님이 자기 가게에 대해 쓴 자기 문장이고,
|
||||||
|
# 섹션 제목(name)이 이미 같은 경로로 나간다.
|
||||||
|
body = _text(item.get("body"))
|
||||||
|
if body:
|
||||||
|
entry["body"] = body
|
||||||
out.append(entry)
|
out.append(entry)
|
||||||
|
|
||||||
for sid, label, locked in default_spec:
|
for sid, label, locked in default_spec:
|
||||||
|
|||||||
@ -136,6 +136,29 @@ async def test_no_unique_content_blocks_publish(auth_headers, client):
|
|||||||
assert r["gate"]["reason"] == PublishRejectReason.NO_UNIQUE_CONTENT.name
|
assert r["gate"]["reason"] == PublishRejectReason.NO_UNIQUE_CONTENT.name
|
||||||
|
|
||||||
|
|
||||||
|
async def test_owner_written_intro_counts_as_unique_content(auth_headers, client):
|
||||||
|
"""검증: fact·FAQ·사진 설명은 없지만 사장님이 소개 섹션 본문을 직접 썼다.
|
||||||
|
기대결과: 실제 발행본에 표시되는 소개문 1건으로 계수되어 발행된다."""
|
||||||
|
h = await auth_headers("owner-intro")
|
||||||
|
pid = await _place(client, h, "직접소개펜션")
|
||||||
|
for k, v in {**REQUIRED, "cancel_policy": "X"}.items():
|
||||||
|
await client.post(f"/v1/place/{pid}/fact", headers=h, json={"key": k, "value": v})
|
||||||
|
await client.post(f"/v1/place/{pid}/site/theme", headers=h, json={"theme": {"sections": [{
|
||||||
|
"id": "intro", "name": "소개", "enabled": True, "locked": False,
|
||||||
|
"body": "하조대의 아침 바다를 조용히 바라볼 수 있는 작은 숙소입니다.",
|
||||||
|
}]}})
|
||||||
|
|
||||||
|
job_id = (await client.post(
|
||||||
|
f"/v1/place/{pid}/site/build", headers=h, json={"publish": True}
|
||||||
|
)).json()["job_id"]
|
||||||
|
await _run()
|
||||||
|
|
||||||
|
r = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"]["result"]
|
||||||
|
assert r["gate"]["passed"] is True
|
||||||
|
assert r["unique_content_count"] == 1
|
||||||
|
assert r["published"] is True
|
||||||
|
|
||||||
|
|
||||||
async def test_missing_required_field_warns_but_publishes(auth_headers, client, db_engine):
|
async def test_missing_required_field_warns_but_publishes(auth_headers, client, db_engine):
|
||||||
"""검증: 취소 규정 없이 빌드한다.
|
"""검증: 취소 규정 없이 빌드한다.
|
||||||
|
|
||||||
|
|||||||
@ -79,6 +79,12 @@ def test_zero_unique_content_blocks_publish():
|
|||||||
assert check_unique_content(1).passed is True
|
assert check_unique_content(1).passed is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_unmeasured_unique_content_is_not_zero():
|
||||||
|
"""검증: 렌더러가 계수를 못 재고(None) 실패했다.
|
||||||
|
기대결과: 통과 — 디스크·번들 오류에 NO_UNIQUE_CONTENT 라는 엉뚱한 사유가 붙으면 안 된다."""
|
||||||
|
assert check_unique_content(None).passed is True
|
||||||
|
|
||||||
|
|
||||||
def test_jsonld_mismatch_blocks_publish():
|
def test_jsonld_mismatch_blocks_publish():
|
||||||
"""검증: 구조화 데이터 값이 화면 값과 다르다.
|
"""검증: 구조화 데이터 값이 화면 값과 다르다.
|
||||||
기대결과: ★ 규칙 3 — JSONLD_MISMATCH 로 거부. 검색엔진에만 다른 말을 하면 안 된다."""
|
기대결과: ★ 규칙 3 — JSONLD_MISMATCH 로 거부. 검색엔진에만 다른 말을 하면 안 된다."""
|
||||||
|
|||||||
@ -222,6 +222,18 @@ def test_owner_disabled_section_stays_disabled():
|
|||||||
assert next(s for s in theme["sections"] if s["id"] == "photos")["enabled"] is False
|
assert next(s for s in theme["sections"] if s["id"] == "photos")["enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_written_section_body_reaches_publish_payload():
|
||||||
|
"""검증: 에디터에서 직접 쓴 소개 본문.
|
||||||
|
기대결과: 발행 payload 에 그대로 남는다 — 저장만 되고 경계에서 버려지면 발행본과 계수에 못 쓴다."""
|
||||||
|
theme = _payload_theme({"sections": [{
|
||||||
|
"id": "intro", "name": "소개", "enabled": True, "locked": False,
|
||||||
|
"body": "바다를 보며 조용히 쉬어가는 작은 숙소입니다.",
|
||||||
|
}]})
|
||||||
|
assert next(s for s in theme["sections"] if s["id"] == "intro")["body"] == (
|
||||||
|
"바다를 보며 조용히 쉬어가는 작은 숙소입니다."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_partial_colors_are_filled_from_category_default():
|
def test_partial_colors_are_filled_from_category_default():
|
||||||
"""검증: 저장된 색이 일부 키만 담고 있을 때.
|
"""검증: 저장된 색이 일부 키만 담고 있을 때.
|
||||||
기대결과: 빠진 자리는 업종 기본이 메운다 — 렌더러 타입이 6개를 모두 요구하므로
|
기대결과: 빠진 자리는 업종 기본이 메운다 — 렌더러 타입이 6개를 모두 요구하므로
|
||||||
|
|||||||
@ -249,4 +249,13 @@ export interface SectionSetting {
|
|||||||
* 렌더러가 모르는 키를 만나도 마찬가지다 — 서버는 값을 해석하지 않고 그대로 싣는다.
|
* 렌더러가 모르는 키를 만나도 마찬가지다 — 서버는 값을 해석하지 않고 그대로 싣는다.
|
||||||
*/
|
*/
|
||||||
variantId?: string;
|
variantId?: string;
|
||||||
|
/**
|
||||||
|
* 사장님이 에디터에서 직접 쓴 섹션 본문.
|
||||||
|
*
|
||||||
|
* ★ variantId 와 같은 사연이다 — 이 필드가 없던 동안 캔버스에 쓴 소개문은 payload 경계에서
|
||||||
|
* 버려졌다. 저장(sites.theme)은 되는데 발행본에 안 나왔고, 고유 콘텐츠로도 세지 않아
|
||||||
|
* "소개를 썼는데 고유 콘텐츠 0건으로 발행이 막힌다" 가 됐다.
|
||||||
|
* ★ 줄바꿈이 문단 구분이다. 렌더러가 빈 줄을 기준으로 <p> 를 나눈다.
|
||||||
|
*/
|
||||||
|
body?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -192,6 +192,24 @@ class VerifyError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고유 콘텐츠 0건. **VerifyError 와 갈라 둔다.**
|
||||||
|
*
|
||||||
|
* ★ 예전에는 이 사유를 VerifyError 의 mismatches 에 실어 던졌다. 백엔드 게이트는
|
||||||
|
* mismatches 가 비지 않았다는 것만 보고 JSONLD_MISMATCH 로 판정했고, 사장님 화면에는
|
||||||
|
* "구조화 데이터와 화면 값이 다릅니다" 라는 **틀린 문구**가 떴다 — 구조화 데이터는
|
||||||
|
* 멀쩡했다. 사유가 다르면 예외도 갈라야 라벨이 안 섞인다.
|
||||||
|
*/
|
||||||
|
class NoUniqueContentError extends Error {
|
||||||
|
/** 백엔드가 JSONLD_MISMATCH 로 오인하지 않도록 늘 비어 있다. */
|
||||||
|
readonly mismatches: string[] = [];
|
||||||
|
|
||||||
|
constructor(readonly uniqueContentCount: number) {
|
||||||
|
super('고유 콘텐츠가 0건이다 — 이 가게에만 있는 내용이 없으면 발행하지 않는다');
|
||||||
|
this.name = 'NoUniqueContentError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사이트는 **한 장**이다(2026-08-31). 예전에는 홈·객실·객실상세·주변·오시는길·FAQ 로
|
* 사이트는 **한 장**이다(2026-08-31). 예전에는 홈·객실·객실상세·주변·오시는길·FAQ 로
|
||||||
* 라우트를 갈라 사이트 하나에 30개 안팎의 HTML 을 구웠다.
|
* 라우트를 갈라 사이트 하나에 30개 안팎의 HTML 을 구웠다.
|
||||||
@ -321,10 +339,7 @@ function prerenderSite(input: SitePayload, outRoot: string, assets: ReturnType<t
|
|||||||
* 페이지가 디스크에 나가 있으면 크롤러가 그걸 읽는다.
|
* 페이지가 디스크에 나가 있으면 크롤러가 그걸 읽는다.
|
||||||
*/
|
*/
|
||||||
if (uniqueContentCount <= 0) {
|
if (uniqueContentCount <= 0) {
|
||||||
throw new VerifyError(
|
throw new NoUniqueContentError(uniqueContentCount);
|
||||||
['고유 콘텐츠가 0건이다 — 이 가게에만 있는 내용이 없으면 발행하지 않는다'],
|
|
||||||
uniqueContentCount,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFile(siteDir, 'index.html', html);
|
writeFile(siteDir, 'index.html', html);
|
||||||
@ -377,6 +392,11 @@ function countUniqueContent(payload: SitePayload): number {
|
|||||||
const long = (value: unknown) => String(value ?? '').trim().length >= MIN_UNIQUE_TEXT;
|
const long = (value: unknown) => String(value ?? '').trim().length >= MIN_UNIQUE_TEXT;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
|
// 소개 섹션의 직접 입력 본문은 실제 AboutSection 에 표시되는 가게 고유 문장이다.
|
||||||
|
// ★ enabled 를 함께 본다. 꺼서 페이지에 없는 문장까지 세면 빈 페이지가 게이트를 통과한다.
|
||||||
|
const intro = payload.theme.sections.find((section) => section.id === 'intro');
|
||||||
|
if (intro?.enabled && long(intro.body)) count += 1;
|
||||||
|
|
||||||
// 문장형 fact 만 센다 — bool·number·time 은 전부 템플릿 값이라 가게를 구분하지 못한다.
|
// 문장형 fact 만 센다 — bool·number·time 은 전부 템플릿 값이라 가게를 구분하지 못한다.
|
||||||
for (const fact of [...payload.facts, ...payload.units.flatMap((unit) => unit.facts)]) {
|
for (const fact of [...payload.facts, ...payload.units.flatMap((unit) => unit.facts)]) {
|
||||||
if (fact.type === 'text' && long(fact.value)) count += 1;
|
if (fact.type === 'text' && long(fact.value)) count += 1;
|
||||||
@ -585,11 +605,14 @@ function main() {
|
|||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
// ★ 한 사이트가 깨졌다고 나머지를 못 굽게 두지 않는다. 실패는 보고서로 남긴다 —
|
// ★ 한 사이트가 깨졌다고 나머지를 못 굽게 두지 않는다. 실패는 보고서로 남긴다 —
|
||||||
// 조용히 넘어가면 "발행했는데 페이지가 없다"가 다시 반복된다.
|
// 조용히 넘어가면 "발행했는데 페이지가 없다"가 다시 반복된다.
|
||||||
|
// ★ 계수를 못 잰 실패(디스크·번들·payload 파손)는 null 로 남긴다. 0 으로 적으면
|
||||||
|
// 백엔드가 "고유 콘텐츠 0건" 으로 읽어 또 엉뚱한 사유를 붙인다.
|
||||||
|
const counted = ex instanceof VerifyError || ex instanceof NoUniqueContentError ? ex : null;
|
||||||
fail(
|
fail(
|
||||||
entry,
|
entry,
|
||||||
ex instanceof Error ? ex.message : String(ex),
|
ex instanceof Error ? ex.message : String(ex),
|
||||||
ex instanceof VerifyError ? ex.mismatches : [],
|
counted?.mismatches ?? [],
|
||||||
ex instanceof VerifyError ? ex.uniqueContentCount : null,
|
counted?.uniqueContentCount ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -121,6 +121,12 @@ export function isSectionEnabled(payload: SitePayload, id: string): boolean {
|
|||||||
return payload.theme.sections.find((section) => section.id === id)?.enabled ?? false;
|
return payload.theme.sections.find((section) => section.id === id)?.enabled ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 사장님이 에디터에서 직접 쓴 섹션 본문. 빈 줄을 문단 경계로 쓴다. */
|
||||||
|
export function sectionBody(payload: SitePayload, id: string): string[] {
|
||||||
|
const body = payload.theme.sections.find((section) => section.id === id)?.body;
|
||||||
|
return body?.split(/\n\s*\n/).map((paragraph) => paragraph.trim()).filter(Boolean) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
export function unitSpec(payload: SitePayload) {
|
export function unitSpec(payload: SitePayload) {
|
||||||
return UNIT_SPEC[payload.place.category];
|
return UNIT_SPEC[payload.place.category];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
import {useSite} from '@/lib/site-context';
|
import {useSite} from '@/lib/site-context';
|
||||||
import {galleryImages} from '@/lib/derive';
|
import {galleryImages, sectionBody} from '@/lib/derive';
|
||||||
|
|
||||||
export function AboutSection() {
|
export function AboutSection() {
|
||||||
const payload = useSite();
|
const payload = useSite();
|
||||||
const {narrative, place} = payload;
|
const {narrative, place} = payload;
|
||||||
if (narrative.about.length === 0) return null;
|
// 직접 입력한 소개가 있으면 그 문장이 이긴다. 없을 때만 수집·승인된 intro fact 로 떨어진다.
|
||||||
|
// ★ 에디터 본문을 계수만 하고 화면에 안 내면 빈 페이지가 발행 게이트를 통과한다.
|
||||||
|
const paragraphs = sectionBody(payload, 'intro');
|
||||||
|
const about = paragraphs.length > 0 ? paragraphs : narrative.about;
|
||||||
|
if (about.length === 0) return null;
|
||||||
|
|
||||||
// 대표가 아닌 사진 중 첫 장. 히어로와 같은 사진이 두 번 나오지 않게.
|
// 대표가 아닌 사진 중 첫 장. 히어로와 같은 사진이 두 번 나오지 않게.
|
||||||
const image = galleryImages(payload).find((m) => !m.isPrimary);
|
const image = galleryImages(payload).find((m) => !m.isPrimary);
|
||||||
@ -46,7 +50,7 @@ export function AboutSection() {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-3 text-sm leading-relaxed opacity-80">
|
<div className="space-y-3 text-sm leading-relaxed opacity-80">
|
||||||
{narrative.about.map((paragraph, index) => (
|
{about.map((paragraph, index) => (
|
||||||
<p key={index}>{paragraph}</p>
|
<p key={index}>{paragraph}</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user