o2o-site-AEO/solution/site/src/lib/derive.ts
Mina Choi 8410380769 [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건으로 거부.
2026-09-02 12:00:16 +09:00

304 lines
11 KiB
TypeScript

import {
LinkChannel,
PlaceCategory,
factText,
sanitizeUnits,
selectPublishable,
selectPublishableFaqs,
type ChannelLink,
type FactEntry,
type MediaItem,
type SitePayload,
type UnitInfo,
} from '@o2o/shared';
import {UNIT_SPEC} from '@/seo/jsonld';
/**
* payload → 화면이 바로 쓰는 모양.
*
* 컴포넌트가 fact 배열을 직접 뒤지지 않게 한 층 둔다. 여기 한 곳에서만
* selectPublishable() 을 통과시키므로, 컴포넌트가 실수로 미검증 값을 그릴 수가 없다.
*/
export interface InfoRow {
label: string;
value: string;
/** 부가 설명. 출처가 아니라 사장님이 붙인 보충 문구. */
note?: string;
}
/** 사업장 단위 이용 정보 표. 확인된 것만 들어간다. */
export function essentialRows(payload: SitePayload): InfoRow[] {
return selectPublishable(payload.facts)
.filter((fact) => fact.scope === 'place')
.map((fact) => ({
label: fact.label,
value: displayValue(fact, payload.facts),
}))
.filter((row) => row.value !== '');
}
function displayValue(fact: FactEntry, all: FactEntry[]): string {
const text = factText(all, fact.key);
if (!text) return '';
if (fact.type !== 'bool') return text;
if (text === 'true') return '가능';
if (text === 'false') return '불가';
return text;
}
export interface UnitView {
unitId: string;
slug: string;
name: string;
intro?: string;
/** 스펙 칩 — 인원 · 침대 · 면적처럼 한눈에 보는 값. */
chips: {label: string; value: string}[];
rows: InfoRow[];
images: MediaItem[];
priceText?: string;
href: string;
}
export function unitViews(payload: SitePayload): UnitView[] {
const spec = UNIT_SPEC[payload.place.category];
const mediaById = new Map(payload.media.map((m) => [m.mediaId, m]));
return sanitizeUnits(payload.units).map((unit) => {
const intro = factText(unit.facts, 'room_intro') ?? factText(unit.facts, 'description');
const chipKeys =
payload.place.category === PlaceCategory.LODGING
? ['max_capacity', 'bed_type', 'room_size']
: ['price', 'volume', 'origin'];
return {
unitId: unit.unitId,
slug: unit.slug,
name: unit.name,
intro,
chips: chipKeys
.map((key) => {
const value = factText(unit.facts, key);
const label = unit.facts.find((f) => f.key === key)?.label ?? key;
return value ? {label, value} : null;
})
.filter((chip): chip is {label: string; value: string} => chip !== null),
rows: unit.facts
.map((fact) => ({label: fact.label, value: displayValue(fact, unit.facts)}))
.filter((row) => row.value !== ''),
images: unit.mediaIds
.map((id) => mediaById.get(id))
.filter((m): m is MediaItem => Boolean(m?.alt?.trim())),
priceText: unitPriceText(unit),
href: `/${spec.path}/${unit.slug}`,
};
});
}
function unitPriceText(unit: UnitInfo): string | undefined {
const weekday = Number(factText(unit.facts, 'weekday_price')?.replace(/[^0-9]/g, ''));
const price = Number(factText(unit.facts, 'price')?.replace(/[^0-9]/g, ''));
const base = Number.isFinite(weekday) && weekday > 0 ? weekday : price;
if (!Number.isFinite(base) || base <= 0) return undefined;
return `${base.toLocaleString('ko-KR')}원부터`;
}
/** 갤러리에 낼 이미지 — 대체 텍스트 없는 것은 뺀다(검색·낭독기 모두 못 읽는다). */
export function galleryImages(payload: SitePayload): MediaItem[] {
return payload.media.filter((m) => m.alt?.trim() && !m.unitId);
}
export function faqList(payload: SitePayload) {
return selectPublishableFaqs(payload.faqs);
}
/** 켜져 있는 섹션인지. 순서도 payload 가 정한다. */
export function enabledSections(payload: SitePayload) {
return payload.theme.sections.filter((section) => section.enabled);
}
export function isSectionEnabled(payload: SitePayload, id: string): boolean {
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) {
return UNIT_SPEC[payload.place.category];
}
/**
* 섹션 제목 — 사장님이 [섹션] 패널에서 붙인 이름(`theme.sections[].name`)을 그대로 쓴다.
*
* ★ 왜 필요한가
* 지금까지 발행본 소제목은 컴포넌트에 박힌 문자열이었다. 사장님이 "객실 안내"를
* "우리 방 소개"로 바꿔도 에디터 캔버스만 바뀌고 발행본은 옛 문구로 나갔다 —
* 사장님 입장에서는 고친 게 반영이 안 된 것이고, 실제로 반영이 안 된 게 맞다.
*
* ★ **모든 섹션이 이걸 쓰는 건 아니다.** 기준은 하나다 — 에디터 캔버스가 그 섹션에서
* 무엇을 제목으로 쓰는가. 두 화면이 같아야 하므로 발행본은 에디터를 따라간다.
*
* 이걸 쓰는 섹션 rules · booking · inquiry · space · exhibition · rooms/menu/programs
* (에디터: `title={section.name}`)
* 쓰지 않는 섹션 intro · info · photos · map · local · faq
* (에디터가 자체 제목을 쓴다: "공간 갤러리", "오시는 길", `${상호} 소개` …)
*
* 한때 발행본에서 여섯 섹션 전부에 이걸 걸었다가 소개 제목이 "조이모텔 소개" 에서
* "소개" 로 짧아졌다. `theme.sections[].name` 은 사장님이 붙인 이름이기도 하지만,
* 아직 아무것도 안 바꿨으면 서버 기본표(_DEFAULT_THEME)의 짧은 목록 라벨이다 —
* 그 라벨은 좌측 패널의 navigation 용이지 <h2> 용이 아니다.
*
* ★ 폴백을 두는 이유
* name 이 비어 있는 payload(옛 버전·손으로 만든 fixture)에서 제목 없는 <h2> 가
* 나가면 문서 구조가 무너지고 검색·낭독기가 섹션을 못 읽는다. 제목은 반드시 채운다.
*/
export function sectionName(payload: SitePayload, id: string, fallback: string): string {
const name = payload.theme.sections.find((section) => section.id === id)?.name?.trim();
return name ? name : fallback;
}
/**
* key 목록 순서대로 확인된 place fact 를 표 행으로.
*
* ★ 순서가 곧 화면 순서다. 값이 없거나 미검증인 key 는 조용히 빠진다 —
* "확인 중"이라는 빈 줄을 그리면 손님은 그걸 규정으로 읽는다.
*/
function placeRowsByKeys(payload: SitePayload, keys: readonly string[]): InfoRow[] {
const map = new Map(
selectPublishable(payload.facts)
.filter((fact) => fact.scope === 'place')
.map((fact) => [fact.key, fact] as const),
);
return keys
.map((key) => map.get(key))
.filter((fact): fact is FactEntry => fact !== undefined)
.map((fact) => ({label: fact.label, value: displayValue(fact, payload.facts)}))
.filter((row) => row.value !== '');
}
/**
* 이용 규정으로 읽히는 fact key.
*
* ★ 관리자 캔버스의 `builder/canvas/variants/common.ts` RULE_FIELD_IDS 와 같은 목록이다.
* 에디터에서 규정으로 보인 항목이 발행본에서 다른 항목이 되면 사장님은 어느 쪽을
* 믿어야 할지 모른다. 목록이 바뀌면 양쪽을 같이 고친다.
* ★ 여기 없는 규정은 만들어 내지 않는다. 업종 스키마(lodging.json)에 있는 key 만 적는다.
*/
const RULE_FACT_KEYS = [
'check_in_time',
'check_out_time',
'cancel_policy',
'cooking_allowed',
'pet_allowed',
'smoking',
'extra_person_fee',
] as const;
/** 이용 규정 줄. 확인된 fact 에서만 만든다 — 없으면 빈 목록이고 섹션 자체가 안 나간다. */
export function ruleRows(payload: SitePayload): InfoRow[] {
return placeRowsByKeys(payload, RULE_FACT_KEYS);
}
/**
* 예약 안내에 실을 fact.
*
* 숙박에는 없고(예약은 채널이 받는다) 음식점·관광체험 스키마에만 있는 key 다.
* 값이 없는 업종에서는 그냥 빠진다.
*/
const BOOKING_FACT_KEYS = ['reservation_required', 'reservation_channel'] as const;
export function bookingRows(payload: SitePayload): InfoRow[] {
return placeRowsByKeys(payload, BOOKING_FACT_KEYS);
}
/**
* 공간 안내에 실을 fact — "자리가 어떻게 생겼나"에 답하는 것만.
*
* ★ 주차·와이파이 같은 편의시설은 넣지 않는다. 그건 이용 정보(info) 표가 이미 낸다.
*/
const SPACE_FACT_KEYS = [
'seat_count',
'terrace',
'room_available',
'group_seat_max',
'power_outlet',
'study_allowed',
'wheelchair_accessible',
] as const;
export function spaceRows(payload: SitePayload): InfoRow[] {
return placeRowsByKeys(payload, SPACE_FACT_KEYS);
}
/**
* 관람 안내에 실을 fact — 관광체험 스키마(tour_activity.json)의 관람 관련 key.
*
* ★ 준비물·안전 유의사항은 뺐다. 그건 "관람 안내"가 아니라 체험 전 주의사항이고,
* 이용 정보(info) 표에 이미 나간다.
*/
const EXHIBITION_FACT_KEYS = [
'operating_hours',
'session_times',
'closed_days',
'age_limit',
'guide_language',
] as const;
export function exhibitionRows(payload: SitePayload): InfoRow[] {
return placeRowsByKeys(payload, EXHIBITION_FACT_KEYS);
}
/** 채널 코드 → 사람이 읽는 이름. link.title 이 있으면 그쪽이 우선이다. */
export const CHANNEL_LABEL: Record<number, string> = {
[LinkChannel.YANOLJA]: '야놀자',
[LinkChannel.GOODCHOICE]: '여기어때',
[LinkChannel.NAVER_PLACE]: '네이버 플레이스',
[LinkChannel.INSTAGRAM]: '인스타그램',
[LinkChannel.OFFICIAL_SITE]: '공식 사이트',
[LinkChannel.BLOG]: '블로그',
[LinkChannel.ETC]: '채널',
};
export function channelLabel(link: ChannelLink): string {
return link.title ?? CHANNEL_LABEL[link.channel] ?? '채널';
}
/**
* 예약을 실제로 받는 채널.
*
* ★ 블로그·인스타그램은 뺀다. 눌러도 예약 화면이 안 나오는 링크를 "예약하기" 자리에
* 두면 손님이 예약한 줄 알고 안 온다. 공식 사이트도 뺀다 — 지금 보고 있는 이 사이트가
* 그 자리라, 자기 자신으로 돌려보내는 버튼이 된다.
*/
const BOOKING_CHANNELS: readonly number[] = [
LinkChannel.YANOLJA,
LinkChannel.GOODCHOICE,
LinkChannel.NAVER_PLACE,
];
/**
* 문의를 실제로 받을 수 있는 채널 — 네이버 톡톡·인스타 DM 처럼 말을 걸 수 있는 곳만.
*
* ★ 블로그·공식 사이트·기타(ETC)는 뺀다. 읽기만 되는 링크를 "문의" 버튼으로 두면
* 손님이 남긴 말이 아무 데도 도착하지 않는다.
*/
const CONTACT_CHANNELS: readonly number[] = [LinkChannel.NAVER_PLACE, LinkChannel.INSTAGRAM];
/** 확정된 링크만. 확정 전 URL 은 동명 업소일 수 있다(sanitizePayloadForPublish 와 같은 규칙). */
function confirmedLinks(payload: SitePayload, channels: readonly number[]): ChannelLink[] {
return payload.links.filter((link) => link.confirmed && channels.includes(link.channel));
}
export function bookingLinks(payload: SitePayload): ChannelLink[] {
return confirmedLinks(payload, BOOKING_CHANNELS);
}
export function contactLinks(payload: SitePayload): ChannelLink[] {
return confirmedLinks(payload, CONTACT_CHANNELS);
}