[feat] solution/site,frontend,backend: 숙박 예약 구성 — 요금·인원·창구를 한자리에

숙박으로 발행하면 서버 기본표가 booking 섹션을 켜는데, 발행본이 읽는 fact
(reservation_required·reservation_channel)가 **숙박 스키마에 없다**. 그래서 펜션·민박의
"실시간 예약" 섹션에는 전화번호 한 줄만 남았다 — 요금도 인원도 취소 규정도 없었다.
숙박은 예약이 곧 매출이고 "얼마예요 / 몇 명까지 / 어떻게 예약해요" 가 이 업종 질의의
대부분인데, 그 답의 근거가 페이지에 없으면 AI 는 OTA 후기에서 추측한다.

★ 예약을 처리하게 만든 게 아니다. 재고도 결제도 갖지 않는다(PRODUCT.md 6절) — 날짜
선택기·예약 폼을 그리지 않았고, "여기서 결제되지 않는다" 를 화면 맨 앞과 llms.txt 에
명시했다. 없는 기능을 흉내내면 손님은 예약한 줄 알고 안 온다.

- site/sections/StayBookingSection: 객실별 요금·인원 / 예약 창구(전화 + 확정 채널) /
  예약 전 확인 8항목. 근거가 없으면 섹션째 안 나간다
- site/lib/derive: stayBookingView() 가 그릴지 말지까지 판단한다 — 내비·탭이 같은 함수를
  본다(각자 판단하면 눌러도 아무 일 없는 탭이 생긴다). 예약 채널은 문의 목록에서 뺀다
- site/seo/jsonld: unitBaseRate() 를 요금 숫자의 단일 출처로. makesOffer(객실별 1박) ·
  potentialAction(확정 채널만) 추가. availability 는 안 넣는다 — 빈 방을 모른다
- site/seo/llms: 숙박 ## 예약 블록을 위쪽에. 아래에만 있으면 답에 안 실린다
- frontend/industryData, backend/site_payload: 기본 섹션명 "실시간 예약" → "예약 안내".
  실시간 예약을 하지 않는데 제목이 그렇게 말했다(두 파일은 parity 테스트가 묶는다)
- site/seo/verify: 데모 payload 가 원래 굽히지 않던 오탐 둘을 고쳤다(main 에서 재현) —
  속성의 & 이스케이프 때문에 화면에 있는 이미지 URL 을 못 찾던 것, ㎡ 의 단위 코드
  MTK 를 본문에서 찾던 것. 되돌린 사본에서도 못 찾으면 그대로 실패다

tsc·eslint 통과, vitest 43 passed(신규 21). 데모 재굽기 성공 → /s/moonlight-stay-jeju 200.
백엔드 pytest 는 venv 가 없어 미실행 — 섹션표 parity 는 같은 방식으로 손대조했다.
This commit is contained in:
hbyang 2026-09-07 15:27:29 +09:00
parent 6df125d840
commit d498d36ccf
15 changed files with 846 additions and 24 deletions

View File

@ -5,6 +5,57 @@
--- ---
## 2026-09-07 — 숙박 예약 구성 — "실시간 예약" 섹션이 전화번호 한 줄이었다
**왜**
숙박으로 발행하면 서버 기본표(`site_payload._DEFAULT_THEME`)가 `booking` 섹션을 켠다. 그런데
발행본의 `BookingSection` 이 읽는 fact 는 `reservation_required`·`reservation_channel` 두 개이고,
**둘 다 숙박 스키마(`lodging.json`)에 없다.** 그래서 펜션·민박 페이지의 "실시간 예약" 섹션에는
전화번호 한 줄만 남았다 — 요금도, 인원도, 취소 규정도, 예약 창구도 없었다. 숙박은 예약이 곧
매출이고 "얼마예요 / 몇 명까지 / 어떻게 예약해요" 가 이 업종 질의의 대부분인데, 그 답의 근거가
페이지에 없으면 AI 는 OTA 후기에서 추측한다.
★ **예약을 처리하게 만든 게 아니다.** 빈 방 재고도 결제도 갖지 않는다([PRODUCT.md 6절](PRODUCT.md)
— "사이트는 예약 채널로 보낸다"). 날짜 선택기·예약 폼을 그리지 않았다 — 없는 기능을 화면으로
흉내내면 손님은 예약한 줄 알고 안 오고, 그 전화는 사장님이 받는다. 대신 **예약에 필요한 사실 +
실제로 예약이 되는 창구**를 한자리에 모았고, "여기서 결제되지 않는다"를 화면 맨 앞과 llms.txt 에
명시했다.
**바꾼 것**
- `site/src/sections/StayBookingSection.tsx` (신규) — 객실별 요금·인원 / 예약 창구(전화 + 확정
채널) / 예약 전 확인(체크인·체크아웃·취소환불·추가인원·프런트 시간·취사·반려동물·흡연).
근거가 하나도 없으면 섹션째 안 나간다
- `site/src/lib/derive.ts` — `stayBookingView()` 가 **그릴지 말지까지** 판단한다. 상단 내비·하단
탭이 같은 함수를 본다 — 세 곳이 각자 판단하면 눌러도 아무 일 없는 "예약" 탭이 생긴다.
예약 창구로 나가는 채널은 문의 목록에서 뺀다(네이버 플레이스가 두 번 보였다)
- `site/src/seo/jsonld.ts` — `unitBaseRate()` 를 **요금 숫자의 단일 출처**로 만들고 화면과
`makesOffer.price` 가 같이 쓴다(각자 계산하면 절대규칙 3 위반으로 발행이 멈춘다).
`makesOffer`(객실별 1박 요금) · `potentialAction: ReserveAction`(확정 채널만) 추가.
**`availability` 는 넣지 않았다** — 빈 방을 모르는데 InStock 을 주장하면 그게 거짓이다
- `site/src/seo/llms.ts` — 숙박 `## 예약` 블록. LLM 은 위에서부터 읽는다. 예약 경로가 "공식 채널"
절 맨 아래에만 있으면 답에 안 실린다
- `frontend/src/data/industryData.ts` · `backend/services/site_payload.py` — 숙박 기본 섹션 이름을
**"실시간 예약" → "예약 안내"**. 실시간 예약을 하지 않는데 제목이 그렇게 말하고 있었다.
두 파일은 `tests/test_site_theme.py` 가 1:1 로 묶어 두므로 같이 고쳤다
- 데모 fixture 의 theme 에 `rules`·`booking` 을 넣었다 — 서버 기본표에는 있는데 fixture 에만
없어서, 개발 서버로는 이 두 섹션을 아예 볼 수 없었다
**곁에서 나온 것 — 데모 payload 는 원래 굽히지 않았다**
`npm run prerender`(payload 미지정 = 데모)가 **절대규칙 3 대조 9건으로 실패**하고 있었다.
내 변경 전에도 같은 건수로 실패했다(main 에서 재현 확인).
1. `verify.ts` 가 URL 을 **원본 HTML 문자열**에서 찾았다. 속성으로 나갈 때 `&` 가 `&` 로
이스케이프되므로 쿼리스트링 있는 이미지 URL 은 **화면에 있는데도** 절대 안 찾아진다.
→ 엔티티를 되돌린 사본에서도 찾아본다. 표기 차이는 거짓이 아니다(숫자 `asShown()` 과 같은 이유).
되돌린 사본에서도 못 찾으면 그대로 실패다 — 느슨해지지 않았다.
2. `unitCode: 'MTK'`(㎡ 의 UN/CEFACT 코드)를 본문에서 찾고 있었다. 한국어 페이지에 'MTK' 가
찍힐 일은 없다 — `priceCurrency`('KRW')와 같은 종류의 메타값이라 `STRUCTURAL` 로 옮겼다.
★ 사람이 읽는 `unitText` 는 옮기지 않았다 — 그건 화면에 있어야 하는 말이다.
**검증** — `tsc·eslint` 통과, `vitest` 43 passed(신규 21건: 예약 뷰·발행 HTML·JSON-LD 대조·llms.txt).
데모 payload 재굽기 성공(1개 중 1개) → `npm run serve` 로 `/s/moonlight-stay-jeju` 200 확인.
백엔드 pytest 는 이 환경에 venv 가 없어 못 돌렸다 — 에디터↔서버 섹션표 parity 는 그 테스트와
같은 방식으로 손으로 대조했다(stay: `예약 안내` 양쪽 일치).
## 2026-09-07 — (사고 2) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다 ## 2026-09-07 — (사고 2) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다
**무슨 일** **무슨 일**

View File

@ -86,7 +86,7 @@ _DEFAULT_THEME = {
"card": "#fafafa", "text": "#09090b", "accent": "#2563eb"}, "card": "#fafafa", "text": "#09090b", "accent": "#2563eb"},
"sections": [ "sections": [
("hero", "히어로", True), ("intro", "소개", False), ("rooms", "객실 안내", False), ("hero", "히어로", True), ("intro", "소개", False), ("rooms", "객실 안내", False),
("info", "기본 정보", True), ("rules", "이용 규정", False), ("booking", "실시간 예약", False), ("info", "기본 정보", True), ("rules", "이용 규정", False), ("booking", "예약 안내", False),
("photos", "사진 갤러리", False), ("map", "오시는 길", True), ("weather", "날씨", False), ("photos", "사진 갤러리", False), ("map", "오시는 길", True), ("weather", "날씨", False),
("local", "지역 정보", False), ("faq", "자주 묻는 질문", False), ("local", "지역 정보", False), ("faq", "자주 묻는 질문", False),
], ],

View File

@ -126,7 +126,7 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
{ id: 'rooms', type: 'rooms', name: '객실 안내', isLocked: false, isEnabled: true, description: '객실 타입, 구조, 비치 물품' }, { id: 'rooms', type: 'rooms', name: '객실 안내', isLocked: false, isEnabled: true, description: '객실 타입, 구조, 비치 물품' },
{ id: 'info', type: 'info', name: '기본 정보', isLocked: true, isEnabled: true, description: '체크인, 주차, 시설 핵심 정보' }, { id: 'info', type: 'info', name: '기본 정보', isLocked: true, isEnabled: true, description: '체크인, 주차, 시설 핵심 정보' },
{ id: 'rules', type: 'rules', name: '이용 규정', isLocked: false, isEnabled: true, description: '환불 규정, 입실 수칙 및 에티켓' }, { id: 'rules', type: 'rules', name: '이용 규정', isLocked: false, isEnabled: true, description: '환불 규정, 입실 수칙 및 에티켓' },
{ id: 'booking', type: 'booking', name: '실시간 예약', isLocked: false, isEnabled: true, description: '예약 현황 및 예약 신청' }, { id: 'booking', type: 'booking', name: '예약 안내', isLocked: false, isEnabled: true, description: '요금 · 예약 창구 안내' },
{ id: 'photos', type: 'photos', name: '사진 갤러리', isLocked: false, isEnabled: true, description: '감성 인테리어와 외부 풍경' }, { id: 'photos', type: 'photos', name: '사진 갤러리', isLocked: false, isEnabled: true, description: '감성 인테리어와 외부 풍경' },
{ id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '위치 안내 및 대중교통 경로' }, { id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '위치 안내 및 대중교통 경로' },
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 사업장 주변 날씨' }, { id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 사업장 주변 날씨' },

View File

@ -534,6 +534,11 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = {
{id: 'intro', name: '소개', enabled: true, locked: false}, {id: 'intro', name: '소개', enabled: true, locked: false},
{id: 'rooms', name: '객실 안내', enabled: true, locked: false}, {id: 'rooms', name: '객실 안내', enabled: true, locked: false},
{id: 'info', name: '기본 정보', enabled: true, locked: true}, {id: 'info', name: '기본 정보', enabled: true, locked: true},
// ★ 서버 기본표(`site_payload._DEFAULT_THEME`)의 숙박 목록에 있는 두 섹션이
// fixture 에는 빠져 있었다. 그래서 개발 서버로는 이용 규정·예약 안내가 보이지 않아
// "발행하면 나오는데 여기서는 안 나온다" 를 확인할 수 없었다.
{id: 'rules', name: '이용 규정', enabled: true, locked: false},
{id: 'booking', name: '예약 안내', enabled: true, locked: false},
{id: 'photos', name: '사진 갤러리', enabled: true, locked: false}, {id: 'photos', name: '사진 갤러리', enabled: true, locked: false},
{id: 'weather', name: '날씨', enabled: true, locked: false}, {id: 'weather', name: '날씨', enabled: true, locked: false},
{id: 'local', name: '지역 정보', enabled: true, locked: false}, {id: 'local', name: '지역 정보', enabled: true, locked: false},

View File

@ -12,7 +12,7 @@ import {
type SitePayload, type SitePayload,
type UnitInfo, type UnitInfo,
} from '@o2o/shared'; } from '@o2o/shared';
import {UNIT_SPEC} from '@/seo/jsonld'; import {BOOKING_CHANNELS, UNIT_SPEC, unitBaseRate} from '@/seo/jsonld';
/** /**
* payload → 화면이 바로 쓰는 모양. * payload → 화면이 바로 쓰는 모양.
@ -96,12 +96,15 @@ export function unitViews(payload: SitePayload): UnitView[] {
}); });
} }
/**
* 카드에 얹는 "얼마부터".
*
* ★ 숫자를 여기서 고르지 않는다 — `unitBaseRate`(seo/jsonld.ts) 하나가 고른 값을 표기만 한다.
* 화면과 JSON-LD 가 각자 계산하면 어긋날 수 있고, 어긋나면 절대규칙 3 위반으로 발행이 막힌다.
*/
function unitPriceText(unit: UnitInfo): string | undefined { function unitPriceText(unit: UnitInfo): string | undefined {
const weekday = Number(factText(unit.facts, 'weekday_price')?.replace(/[^0-9]/g, '')); const rate = unitBaseRate(unit);
const price = Number(factText(unit.facts, 'price')?.replace(/[^0-9]/g, '')); return rate ? `${rate.price.toLocaleString('ko-KR')}원부터` : undefined;
const base = Number.isFinite(weekday) && weekday > 0 ? weekday : price;
if (!Number.isFinite(base) || base <= 0) return undefined;
return `${base.toLocaleString('ko-KR')}원부터`;
} }
/** 갤러리에 낼 이미지 — 대체 텍스트 없는 것은 뺀다(검색·낭독기 모두 못 읽는다). */ /** 갤러리에 낼 이미지 — 대체 텍스트 없는 것은 뺀다(검색·낭독기 모두 못 읽는다). */
@ -283,17 +286,14 @@ export function channelLabel(link: ChannelLink): string {
} }
/** /**
* 예약을 실제로 받는 채널. * 예약을 실제로 받는 채널 목록은 `seo/jsonld.ts` 의 `BOOKING_CHANNELS` 한 벌이다.
* *
* ★ 블로그·인스타그램은 뺀다. 눌러도 예약 화면이 안 나오는 링크를 "예약하기" 자리에 * ★ 블로그·인스타그램은 그 목록에 없다. 눌러도 예약 화면이 안 나오는 링크를 "예약하기"
* 두면 손님이 예약한 줄 알고 안 온다. 공식 사이트도 뺀다 — 지금 보고 있는 이 사이트가 * 자리에 두면 손님이 예약한 줄 알고 안 온다. 공식 사이트도 없다 — 지금 보고 있는 이
* 그 자리라, 자기 자신으로 돌려보내는 버튼이 된다. * 사이트가 그 자리라, 자기 자신으로 돌려보내는 버튼이 된다.
* ★ 화면의 예약 버튼과 JSON-LD 의 `makesOffer.url`·`potentialAction` 이 **같은 링크**를
* 가리켜야 한다. 목록을 두 곳에 적으면 그게 조용히 갈라진다.
*/ */
const BOOKING_CHANNELS: readonly number[] = [
LinkChannel.YANOLJA,
LinkChannel.GOODCHOICE,
LinkChannel.NAVER_PLACE,
];
/** /**
* 문의를 실제로 받을 수 있는 채널 — 네이버 톡톡·인스타 DM 처럼 말을 걸 수 있는 곳만. * 문의를 실제로 받을 수 있는 채널 — 네이버 톡톡·인스타 DM 처럼 말을 걸 수 있는 곳만.
@ -312,6 +312,145 @@ export function bookingLinks(payload: SitePayload): ChannelLink[] {
return confirmedLinks(payload, BOOKING_CHANNELS); return confirmedLinks(payload, BOOKING_CHANNELS);
} }
/**
* ─────────────────────────────────────────────────────────────────────────
* 숙박 예약 — 손님이 "이 방을 이 값에 이 창구로" 예약할 수 있게 하는 데이터.
* ─────────────────────────────────────────────────────────────────────────
*
* ★ 왜 숙박만 따로 만드나
* `bookingRows()` 가 읽는 `reservation_required`·`reservation_channel` 은 **숙박 스키마에
* 없는 key** 다(lodging.json 확인). 그래서 숙박으로 발행하면 서버 기본표가 "실시간 예약"
* 섹션을 켜 두는데도(`site_payload._DEFAULT_THEME`) 화면에는 전화번호 한 줄만 남았다 —
* 요금도, 인원도, 취소 규정도, 예약 창구도 없는 "예약" 섹션이었다.
* 펜션·민박은 예약이 곧 매출이고, AI 가 "얼마예요 / 몇 명까지 / 어떻게 예약해요" 에
* 답할 근거가 이 자리에 있어야 한다.
*
* ★ 우리는 예약을 **처리하지 않는다.** 빈 방 재고도 결제도 갖지 않고(PRODUCT.md 6절),
* 확정된 예약 채널과 전화로 **보낸다.** 그래서 이 구성은 "예약 폼" 이 아니라
* **"예약에 필요한 사실 + 실제로 예약이 되는 창구"** 다. 없는 기능을 화면으로 흉내내면
* 손님은 예약한 줄 알고 안 온다.
*/
/**
* 예약 전에 반드시 확인해야 하는 fact.
*
* ★ 이용 규정(`RULE_FACT_KEYS`)과 목록이 겹친다 — 일부러다. 같은 사실이라도 손님이 그것을
* 찾는 순간이 다르다(규정은 "어떤 곳인가", 여기는 "예약을 눌러도 되는가"). 두 섹션이
* 같이 켜져 있으면 값이 두 번 보이는데, 값이 같으므로 거짓이 되지 않는다.
* ★ 프런트 운영시간을 넣는다 — 전화 예약이 1순위인 업소에서 "언제 전화하면 받나" 는
* 예약 성공 여부를 가르는 값이다.
*/
const STAY_BOOKING_NOTICE_KEYS = [
'check_in_time',
'check_out_time',
'cancel_policy',
'extra_person_fee',
'reception_hours',
'cooking_allowed',
'pet_allowed',
'smoking',
] as const;
/** 예약 창구 한 줄에 필요한 객실 정보. */
export interface StayOffer {
unitId: string;
name: string;
/** "기준 2명 · 최대 4명". 확인된 값만으로 만들고, 둘 다 없으면 undefined. */
capacityText?: string;
/** 주중·주말·성수기 요금. 확인된 것만. */
rateRows: InfoRow[];
/**
* 기준 요금 표기("주중 1박 280,000원").
*
* ★ 숫자는 `unitBaseRate`(seo/jsonld.ts)가 고른 그 값이다 — JSON-LD 의
* `makesOffer.price` 와 **같은 숫자**여야 화면 ↔ 구조화 데이터 대조를 통과한다.
*/
baseRateText?: string;
/** 객실 상세(사진·전체 스펙)는 객실 섹션이 갖고 있다. 한 장 사이트라 앵커다. */
href: string;
}
function stayOffers(payload: SitePayload): StayOffer[] {
return sanitizeUnits(payload.units).map((unit) => {
const standard = factText(unit.facts, 'standard_capacity');
const max = factText(unit.facts, 'max_capacity');
const rate = unitBaseRate(unit);
return {
unitId: unit.unitId,
name: unit.name,
capacityText: [standard && `기준 ${standard}`, max && `최대 ${max}`]
.filter(Boolean)
.join(' · ') || undefined,
rateRows: (['weekday_price', 'weekend_price', 'peak_price'] as const)
.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((row): row is InfoRow => row !== null),
baseRateText: rate ? `${rate.label} ${rate.price.toLocaleString('ko-KR')}원` : undefined,
href: '#units',
};
});
}
export interface StayBookingView {
offers: StayOffer[];
notices: InfoRow[];
/** 실제로 예약이 되는 채널. 확정된 것만. */
links: ChannelLink[];
/** 말을 걸 수 있는 채널(네이버 톡톡·인스타 DM). */
contacts: ChannelLink[];
phone?: string;
}
/**
* 숙박 예약 구성에 필요한 것 전부. **근거가 하나도 없으면 null** 이다.
*
* ★ null 을 돌려주는 이유: 섹션을 그릴지 말지를 컴포넌트·상단 내비·하단 탭이 각자
* 판단하면 세 곳이 갈라진다. 눌러도 아무 일 없는 "예약" 탭은 고장으로 읽힌다.
* 판단은 이 함수 하나가 한다.
*/
export function stayBookingView(payload: SitePayload): StayBookingView | null {
if (payload.place.category !== PlaceCategory.LODGING) return null;
const view: StayBookingView = {
offers: stayOffers(payload).filter(
(offer) => offer.rateRows.length > 0 || offer.capacityText !== undefined,
),
notices: placeRowsByKeys(payload, STAY_BOOKING_NOTICE_KEYS),
links: bookingLinks(payload),
// ★ 예약 창구로 이미 나가는 채널은 문의에 다시 넣지 않는다. 네이버 플레이스는 두
// 목록에 모두 들어 있어서, 그대로 두면 같은 링크가 "예약" 과 "문의" 로 두 번 보인다 —
// 손님은 둘이 다른 곳인 줄 알고 어느 쪽을 눌러야 하는지 망설인다.
contacts: contactLinks(payload).filter(
(contact) => !bookingLinks(payload).some((link) => link.url === contact.url),
),
phone: payload.place.phone,
};
const empty =
view.offers.length === 0 &&
view.notices.length === 0 &&
view.links.length === 0 &&
view.contacts.length === 0 &&
!view.phone;
return empty ? null : view;
}
/**
* 섹션 설정에 그 섹션 자체가 있는지.
*
* ★ `isSectionEnabled()` 와 다르다 — "사장님이 껐다" 와 "payload 에 항목이 아예 없다" 는
* 다른 상태다. 항목이 없는 payload(옛 버전·손으로 만든 fixture)에서는 기본으로 내보내고,
* **명시적으로 끈 것은 존중한다.** 둘을 같이 묶으면 사장님이 끈 섹션이 되살아난다.
*/
export function hasSection(payload: SitePayload, id: string): boolean {
return payload.theme.sections.some((section) => section.id === id);
}
export function contactLinks(payload: SitePayload): ChannelLink[] { export function contactLinks(payload: SitePayload): ChannelLink[] {
return confirmedLinks(payload, CONTACT_CHANNELS); return confirmedLinks(payload, CONTACT_CHANNELS);
} }

View File

@ -1,3 +1,4 @@
import {PlaceCategory} from '@o2o/shared';
import { import {
AboutSection, AboutSection,
AnswerBlock, AnswerBlock,
@ -14,10 +15,11 @@ import {
LocationSection, LocationSection,
RulesSection, RulesSection,
SpaceSection, SpaceSection,
StayBookingSection,
UnitsSection, UnitsSection,
} from '@/sections'; } from '@/sections';
import {useSite} from '@/lib/site-context'; import {useSite} from '@/lib/site-context';
import {isSectionEnabled} from '@/lib/derive'; import {hasSection, isSectionEnabled} from '@/lib/derive';
/** /**
* 홈. * 홈.
@ -36,6 +38,7 @@ import {isSectionEnabled} from '@/lib/derive';
*/ */
export function HomePage() { export function HomePage() {
const payload = useSite(); const payload = useSite();
const isLodging = payload.place.category === PlaceCategory.LODGING;
/** /**
* 섹션 id → 발행본 컴포넌트. * 섹션 id → 발행본 컴포넌트.
@ -53,7 +56,9 @@ export function HomePage() {
menu: UnitsSection, menu: UnitsSection,
programs: UnitsSection, programs: UnitsSection,
rules: RulesSection, rules: RulesSection,
booking: BookingSection, // ★ 숙박은 예약 섹션이 다른 컴포넌트다. 같은 이름의 섹션이 업종에 따라 다른 것을
// 그리는 자리는 여기가 유일하다 — 이유는 StayBookingSection 머리주석.
booking: isLodging ? StayBookingSection : BookingSection,
space: SpaceSection, space: SpaceSection,
inquiry: InquirySection, inquiry: InquirySection,
exhibition: ExhibitionSection, exhibition: ExhibitionSection,
@ -88,6 +93,13 @@ export function HomePage() {
{/* 섹션 설정에 없더라도 오시는 길은 항상 나간다 — 위치 질의의 근거다. */} {/* 섹션 설정에 없더라도 오시는 길은 항상 나간다 — 위치 질의의 근거다. */}
{!isSectionEnabled(payload, 'map') && <LocationSection />} {!isSectionEnabled(payload, 'map') && <LocationSection />}
{/* ★ 숙박에서 예약 안내는 **항목이 없을 때만** 기본으로 낸다.
"사장님이 껐다" 와 "payload 에 항목이 아예 없다" 는 다른 상태다(`hasSection`) —
옛 payload·손으로 만든 fixture 에는 booking 항목이 없는데, 숙박에서 예약 창구가
없는 페이지는 이 업종 질의의 대부분("어떻게 예약해요")에 답을 못 한다.
끈 것을 되살리지는 않는다 — 그건 사장님 결정이다. */}
{isLodging && !hasSection(payload, 'booking') && <StayBookingSection />}
</main> </main>
); );
} }

View File

@ -1,6 +1,6 @@
import {Home, Image as ImageIcon, MapPin, Sparkles} from 'lucide-react'; import {CalendarCheck, Home, Image as ImageIcon, MapPin, Sparkles} from 'lucide-react';
import {useSite} from '@/lib/site-context'; import {useSite} from '@/lib/site-context';
import {isSectionEnabled, unitSpec} from '@/lib/derive'; import {isSectionEnabled, stayBookingView, unitSpec} from '@/lib/derive';
import {useActiveSection} from '@/lib/use-active-section'; import {useActiveSection} from '@/lib/use-active-section';
/** /**
@ -20,6 +20,9 @@ export function MobileTabBar() {
const tabs = [ const tabs = [
{id: 'top', label: '홈', icon: Home}, {id: 'top', label: '홈', icon: Home},
...(payload.units.length > 0 ? [{id: 'units', label: spec.label, icon: ImageIcon}] : []), ...(payload.units.length > 0 ? [{id: 'units', label: spec.label, icon: ImageIcon}] : []),
...(stayBookingView(payload) !== null
? [{id: 'booking', label: '예약', icon: CalendarCheck}]
: []),
...(isSectionEnabled(payload, 'local') ? [{id: 'guide', label: '주변', icon: Sparkles}] : []), ...(isSectionEnabled(payload, 'local') ? [{id: 'guide', label: '주변', icon: Sparkles}] : []),
{id: 'location', label: '위치', icon: MapPin}, {id: 'location', label: '위치', icon: MapPin},
]; ];

View File

@ -1,6 +1,6 @@
import {Phone} from 'lucide-react'; import {Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context'; import {useSite} from '@/lib/site-context';
import {isSectionEnabled, unitSpec} from '@/lib/derive'; import {isSectionEnabled, stayBookingView, unitSpec} from '@/lib/derive';
/** /**
* 상단 내비. * 상단 내비.
@ -19,6 +19,9 @@ export function SiteHeader() {
const items = [ const items = [
{label: '소개', href: '#about', show: isSectionEnabled(payload, 'intro')}, {label: '소개', href: '#about', show: isSectionEnabled(payload, 'intro')},
{label: spec.label, href: '#units', show: payload.units.length > 0}, {label: spec.label, href: '#units', show: payload.units.length > 0},
// ★ 숙박에서 예약은 이 사이트의 목적지다. 근거(요금·창구)가 하나도 없으면 링크도 없다 —
// 눌러도 아무 일 없는 메뉴는 고장으로 읽힌다. 판단은 stayBookingView() 한 곳이 한다.
{label: '예약', href: '#booking', show: stayBookingView(payload) !== null},
{label: '주변 정보', href: '#guide', show: isSectionEnabled(payload, 'local')}, {label: '주변 정보', href: '#guide', show: isSectionEnabled(payload, 'local')},
{label: '오시는 길', href: '#location', show: true}, {label: '오시는 길', href: '#location', show: true},
{label: 'FAQ', href: '#faq', show: payload.faqs.length > 0}, {label: 'FAQ', href: '#faq', show: payload.faqs.length > 0},

View File

@ -0,0 +1,205 @@
import {ArrowUpRight, BedDouble, CalendarCheck, Phone, ShieldCheck} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {channelLabel, sectionName, stayBookingView} from '@/lib/derive';
/**
* 숙박 예약 구성 — 손님이 **이 방을 · 이 값에 · 이 창구로** 예약할 수 있는 자리.
*
* ★ 왜 `BookingSection` 과 따로 있나
* 그쪽이 읽는 fact(`reservation_required`·`reservation_channel`)는 **숙박 스키마에 없다.**
* 그래서 펜션·민박으로 발행하면 서버 기본표가 켜 두는 "실시간 예약" 섹션에 전화번호
* 한 줄만 남았다 — 요금도 인원도 취소 규정도 없는 예약 섹션이었다. 숙박은 예약이
* 곧 매출이고, "얼마예요 / 몇 명까지 / 어떻게 예약해요" 가 이 업종 질의의 대부분이다.
*
* ★ 예약을 **처리하지 않는다.** 우리는 빈 방 재고도 결제도 갖지 않는다(PRODUCT.md 6절 —
* "사이트는 예약 채널로 보낸다. 거래를 품지 않는다"). 그래서 날짜 선택기·예약 폼을
* 그리지 않는다. 없는 기능을 화면으로 흉내내면 손님은 예약한 줄 알고 안 오고,
* 그 클레임은 사장님이 받는다. 대신 **예약에 필요한 사실**과 **실제로 예약이 되는 창구**를
* 한자리에 모은다.
*
* ★ 지어낸 값이 없다. 요금·인원·취소 규정은 확인된 fact 뿐이고(`stayBookingView`),
* 예약 버튼은 확정된 채널 URL 뿐이다. 근거가 하나도 없으면 섹션째 그리지 않는다 —
* 그 판단도 `stayBookingView()` 한 곳이 한다(상단 내비·하단 탭이 같은 함수를 본다).
*
* ★ 기준 요금 숫자는 `unitBaseRate`(seo/jsonld.ts)가 고른 값이다. JSON-LD `makesOffer.price`
* 와 같은 숫자여야 절대규칙 3(화면 = 구조화 데이터) 대조를 통과한다.
*/
export function StayBookingSection() {
const payload = useSite();
const view = stayBookingView(payload);
if (!view) return null;
const {offers, notices, links, contacts, phone} = view;
return (
<section
id="booking"
aria-labelledby="booking-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
style={{backgroundColor: 'var(--color-surface-alt)'}}
>
<div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
<CalendarCheck className="size-4" />
<span>Reservation</span>
</p>
<h2
id="booking-heading"
className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl"
>
{sectionName(payload, 'booking', '예약 안내')}
</h2>
{/* ★ "여기서 결제되지 않는다"를 먼저 말한다. 예약 버튼을 누른 뒤에 알게 되면
손님은 속은 것으로 느끼고, 그 인상은 업소가 가져간다. */}
<p className="mb-8 max-w-2xl text-xs leading-relaxed opacity-60 sm:text-sm">
빈 방 확인과 결제는 아래 예약 창구에서 진행됩니다. 이 페이지에서는 요금과 이용 조건만
안내합니다.
</p>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5 lg:gap-6">
{/* ── 객실별 요금 · 인원 ───────────────────────────────── */}
{offers.length > 0 && (
<div
className="overflow-hidden rounded-2xl border border-black/8 lg:col-span-3"
style={{backgroundColor: 'var(--color-surface)'}}
>
<p className="flex items-center gap-2 border-b border-black/8 px-4 py-3 text-xs font-bold sm:px-5">
<BedDouble className="size-3.5 opacity-50" />
<span>객실별 요금 · 인원</span>
</p>
<ul className="divide-y divide-black/5">
{offers.map((offer) => (
<li key={offer.unitId} className="p-4 sm:p-5">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<h3 className="serif text-base font-bold sm:text-lg">{offer.name}</h3>
{offer.baseRateText && (
<span className="text-sm font-bold" style={{color: 'var(--color-brand)'}}>
{offer.baseRateText}
</span>
)}
</div>
{offer.capacityText && (
<p className="mt-1 text-xs opacity-60">{offer.capacityText}</p>
)}
{offer.rateRows.length > 0 && (
<dl className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5">
{offer.rateRows.map((row) => (
<div key={row.label} className="flex items-baseline gap-1.5 text-xs">
<dt className="opacity-50">{row.label}</dt>
<dd className="font-semibold">{row.value}</dd>
</div>
))}
</dl>
)}
{/* 사진·전체 스펙은 객실 섹션이 갖고 있다. 한 장 사이트라 앵커다. */}
<a
href={offer.href}
className="mt-3 inline-flex items-center gap-1 text-xs font-semibold underline decoration-black/20 underline-offset-4 transition-opacity hover:opacity-70"
>
<span>{offer.name} 사진 · 상세 보기</span>
</a>
</li>
))}
</ul>
</div>
)}
{/* ── 예약 창구 ──────────────────────────────────────── */}
<div
className="flex h-fit flex-col gap-3 rounded-2xl border border-black/8 p-4 sm:p-5 lg:col-span-2"
style={{backgroundColor: 'var(--color-surface)'}}
>
<p className="text-xs font-bold">예약 창구</p>
{phone && (
<a
href={`tel:${phone}`}
className="flex items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-bold text-white transition-opacity hover:opacity-90"
style={{backgroundColor: 'var(--color-brand)'}}
>
<Phone className="size-4" />
<span>전화 예약 {phone}</span>
</a>
)}
{/* ★ 확정된 채널만 나간다 — 확정 전 URL 은 동명 업소의 예약 페이지일 수 있다.
눌렀는데 남의 숙소가 뜨면 그 예약은 영영 우리 것이 아니다. */}
{links.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between gap-2 rounded-xl border border-black/10 px-4 py-3 text-xs font-semibold transition-colors hover:bg-black/5"
style={{backgroundColor: 'var(--color-surface-alt)'}}
>
<span>{`${channelLabel(link)}에서 예약`}</span>
<ArrowUpRight className="size-3.5 shrink-0" />
</a>
))}
{contacts.length > 0 && (
<div className="mt-1 border-t border-black/8 pt-3">
<p className="mb-2 text-xs opacity-50">문의</p>
<ul className="flex flex-wrap gap-2">
{contacts.map((link) => (
<li key={link.url}>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-lg border border-black/10 px-2.5 py-1.5 text-xs transition-colors hover:bg-black/5"
>
<span>{channelLabel(link)}</span>
<ArrowUpRight className="size-3" />
</a>
</li>
))}
</ul>
</div>
)}
{links.length === 0 && (
// 채널이 하나도 확정되지 않은 업소 — 전화가 유일한 창구다. 그 사실을 말해 준다.
<p className="text-xs leading-relaxed opacity-55">
온라인 예약 채널은 등록되지 않았습니다. 예약은 전화로 문의해 주세요.
</p>
)}
</div>
</div>
{/* ── 예약 전 확인 ────────────────────────────────────── */}
{notices.length > 0 && (
<div
className="mt-4 overflow-hidden rounded-2xl border border-black/8 lg:mt-6"
style={{backgroundColor: 'var(--color-surface)'}}
>
<p className="flex items-center gap-2 border-b border-black/8 px-4 py-3 text-xs font-bold sm:px-5">
<ShieldCheck className="size-3.5 opacity-50" />
<span>예약 전 확인</span>
</p>
{/* 확인된 fact 만 줄이 된다 — "확인 중" 이라는 빈 줄을 그리면 손님은 그걸 규정으로 읽는다. */}
<dl className="grid grid-cols-1 divide-y divide-black/5 sm:grid-cols-2 sm:divide-y-0">
{notices.map((row) => (
<div
key={row.label}
className="flex flex-col gap-1 p-4 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sm:p-5"
>
<dt className="shrink-0 text-xs opacity-55">{row.label}</dt>
<dd className="text-xs font-semibold sm:max-w-[60%] sm:text-right">{row.value}</dd>
</div>
))}
</dl>
</div>
)}
</div>
</section>
);
}

View File

@ -6,6 +6,8 @@ export {EssentialInfoSection} from './EssentialInfoSection';
export {UnitsSection} from './UnitsSection'; export {UnitsSection} from './UnitsSection';
export {RulesSection} from './RulesSection'; export {RulesSection} from './RulesSection';
export {BookingSection} from './BookingSection'; export {BookingSection} from './BookingSection';
// 숙박은 예약 구성이 통째로 다르다 — 이유는 StayBookingSection 머리주석.
export {StayBookingSection} from './StayBookingSection';
export {SpaceSection} from './SpaceSection'; export {SpaceSection} from './SpaceSection';
export {InquirySection} from './InquirySection'; export {InquirySection} from './InquirySection';
export {ExhibitionSection} from './ExhibitionSection'; export {ExhibitionSection} from './ExhibitionSection';

View File

@ -0,0 +1,220 @@
/**
* 숙박 예약 구성 — 이 검사가 지키는 것.
*
* 1. 미검증 fact·확정 전 링크가 예약 화면에 새지 않는다(절대규칙 1).
* 예약은 틀리면 바로 클레임이 나는 자리다 — 확인 안 된 요금이나 남의 숙소 예약 링크가
* 한 번 나가면 손님은 헛걸음하고, 그 전화는 사장님이 받는다.
* 2. 화면의 요금과 JSON-LD 의 `makesOffer.price` 가 **같은 숫자**다(절대규칙 3).
* 갈라지면 발행 게이트가 사이트를 막는다 — 조용히 틀리는 게 아니라 발행이 멈추는 종류다.
* 3. 예약 액션이 **실제로 예약이 되는 URL** 만 가리킨다.
* 확정 전 채널을 `potentialAction` 에 넣으면 AI 가 그 주소로 손님을 보낸다.
*/
import {describe, expect, it} from 'vitest';
import {
PlaceCategory,
FactStatus,
sanitizePayloadForPublish,
type SitePayload,
} from '@o2o/shared';
import {MOONLIGHT_STAY_PAYLOAD} from '@/fixtures/moonlight-stay';
import {stayBookingView} from '@/lib/derive';
import {collectJsonLd} from '@/seo/jsonld';
import {renderLlmsTxt} from '@/seo/llms';
import {verifyJsonLd, visibleText} from '@/seo/verify';
import {render} from '@/entry-server';
import {homeMeta} from '@/seo/meta';
import {renderHead} from '@/seo/head';
const PAGE = {title: '달빛스테이 제주', description: '제주 애월 독채 펜션'};
/** 확정 전 야놀자 링크 — fixture 가 일부러 남겨 둔 값이다. */
const UNCONFIRMED_URL = 'https://www.yanolja.com/pension/0000000';
/**
* 프리렌더가 굽는 문서와 **같은 모양**으로 만든다.
*
* ★ 앱 마크업만 놓고 대조하면 안 된다 — canonical·og:image 처럼 head 로 나가는 값과
* 하이드레이션 블롭이 빠져서, 멀쩡한 값이 "화면에 없다" 로 잡힌다(실측 12건).
* 검사는 **나갈 그 HTML** 에 대고 해야 의미가 있다(verify.ts 머리주석).
* ★ 자산 경로는 이 검사와 무관하므로 고정 문자열을 쓴다.
*/
function html(input: SitePayload = MOONLIGHT_STAY_PAYLOAD): string {
const payload = sanitizePayloadForPublish(input);
const meta = homeMeta(payload);
const head = renderHead({
payload,
meta,
scriptSrc: '/assets/index.js',
cssHrefs: ['/assets/index.css'],
});
return [
'<!doctype html><html lang="ko"><head>',
head,
'</head><body>',
`<div id="root">${render(payload)}</div>`,
`<script>window.__SITE_PAYLOAD__=${JSON.stringify(payload)}</script>`,
'</body></html>',
].join('\n');
}
describe('stayBookingView — 예약에 쓸 값을 고르는 자리', () => {
it('숙박이 아니면 아무것도 돌려주지 않는다 — 다른 업종은 BookingSection 이 그린다', () => {
const cafe = {
...MOONLIGHT_STAY_PAYLOAD,
place: {...MOONLIGHT_STAY_PAYLOAD.place, category: PlaceCategory.CAFE},
};
expect(stayBookingView(cafe)).toBeNull();
});
it('확정된 예약 채널만 창구가 된다 — 확정 전 URL 은 동명 업소일 수 있다', () => {
const view = stayBookingView(MOONLIGHT_STAY_PAYLOAD)!;
expect(view.links.map((link) => link.url)).not.toContain(UNCONFIRMED_URL);
expect(view.links).toHaveLength(1); // 네이버 플레이스만 확정됨
});
it('미검증 fact 는 예약 전 확인에 오르지 않는다', () => {
const withUnverifiedPolicy = {
...MOONLIGHT_STAY_PAYLOAD,
facts: MOONLIGHT_STAY_PAYLOAD.facts.map((fact) =>
fact.key === 'cancel_policy' ? {...fact, status: FactStatus.UNVERIFIED} : fact,
),
};
const labels = stayBookingView(withUnverifiedPolicy)!.notices.map((row) => row.label);
expect(labels).not.toContain('취소·환불 규정');
// 확인된 나머지는 그대로 남는다 — 한 건이 빠졌다고 표가 사라지면 안 된다.
expect(labels).toContain('체크인 시간');
});
it('근거가 하나도 없으면 null 이다 — 눌러도 아무 일 없는 예약 메뉴를 만들지 않는다', () => {
const bare: SitePayload = {
...MOONLIGHT_STAY_PAYLOAD,
place: {...MOONLIGHT_STAY_PAYLOAD.place, phone: undefined},
facts: [],
units: [],
links: [],
};
expect(stayBookingView(bare)).toBeNull();
});
it('기준 요금 표기는 어느 요금인지 밝힌다 — 밝히지 않은 가격은 그 자체로 오해다', () => {
const offers = stayBookingView(MOONLIGHT_STAY_PAYLOAD)!.offers;
expect(offers[0].baseRateText).toBe('주중 1박 280,000원');
expect(offers[0].capacityText).toBe('기준 2명 · 최대 4명');
});
});
describe('발행 HTML — 예약 섹션이 실제로 나가는가', () => {
it('요금 · 인원 · 취소 규정 · 예약 창구가 한자리에 있다', () => {
const text = visibleText(html());
expect(text).toContain('예약 창구');
expect(text).toContain('주중 1박 280,000원');
expect(text).toContain('기준 2명 · 최대 4명');
expect(text).toContain('전화 예약 0507-1345-8821');
expect(text).toContain('네이버 플레이스에서 예약');
});
it('여기서 결제되지 않는다는 사실을 먼저 말한다 — 우리는 재고도 결제도 갖지 않는다', () => {
expect(visibleText(html())).toContain('빈 방 확인과 결제는 아래 예약 창구에서 진행됩니다');
});
it('확정 전 예약 링크는 화면에 없다', () => {
expect(html()).not.toContain(UNCONFIRMED_URL);
});
it('booking 항목이 없는 payload 에서도 예약 안내가 나간다 — 옛 payload·fixture', () => {
const withoutBooking = {
...MOONLIGHT_STAY_PAYLOAD,
theme: {
...MOONLIGHT_STAY_PAYLOAD.theme,
sections: MOONLIGHT_STAY_PAYLOAD.theme.sections.filter((s) => s.id !== 'booking'),
},
};
expect(visibleText(html(withoutBooking))).toContain('예약 창구');
});
it('사장님이 끈 예약 섹션은 되살리지 않는다 — 껐다와 항목이 없다는 다르다', () => {
const disabled = {
...MOONLIGHT_STAY_PAYLOAD,
theme: {
...MOONLIGHT_STAY_PAYLOAD.theme,
sections: MOONLIGHT_STAY_PAYLOAD.theme.sections.map((s) =>
s.id === 'booking' ? {...s, enabled: false} : s,
),
},
};
expect(visibleText(html(disabled))).not.toContain('예약 창구');
});
});
describe('구조화 데이터 — 예약 값이 화면과 어긋나지 않는가 (절대규칙 3)', () => {
it('makesOffer · potentialAction 을 포함한 전체 JSON-LD 가 화면 대조를 통과한다', () => {
const nodes = collectJsonLd(MOONLIGHT_STAY_PAYLOAD, PAGE);
expect(verifyJsonLd(html(), nodes)).toEqual([]);
});
it('객실별 요금이 makesOffer 로 나가고, 가격은 화면과 같은 숫자다', () => {
const [business] = collectJsonLd(MOONLIGHT_STAY_PAYLOAD, PAGE);
const offers = business.makesOffer as {name: string; price: number}[];
expect(offers.map((offer) => [offer.name, offer.price])).toEqual([
['Moonlight A동', 280000],
['Starlight B동', 300000],
]);
expect(visibleText(html())).toContain('280,000');
});
it('빈 방 재고를 모르므로 availability 를 주장하지 않는다', () => {
const [business] = collectJsonLd(MOONLIGHT_STAY_PAYLOAD, PAGE);
for (const offer of business.makesOffer as Record<string, unknown>[]) {
expect(offer.availability).toBeUndefined();
}
});
it('예약 액션은 확정된 채널만 가리킨다', () => {
const [business] = collectJsonLd(MOONLIGHT_STAY_PAYLOAD, PAGE);
const action = business.potentialAction as {target: {urlTemplate: string}};
expect(action.target.urlTemplate).toBe('https://m.place.naver.com/accommodation/1234567890');
});
it('예약 채널이 하나도 확정되지 않으면 예약 액션을 내보내지 않는다', () => {
const noChannel = {
...MOONLIGHT_STAY_PAYLOAD,
links: MOONLIGHT_STAY_PAYLOAD.links.map((link) => ({...link, confirmed: false})),
};
const [business] = collectJsonLd(noChannel, PAGE);
expect(business.potentialAction).toBeUndefined();
// 요금은 그대로 나간다 — 예약 창구가 전화뿐인 업소도 요금은 사실이다.
expect((business.makesOffer as unknown[]).length).toBe(2);
});
it('숙박이 아니면 예약 오퍼·액션을 붙이지 않는다', () => {
const cafe = {
...MOONLIGHT_STAY_PAYLOAD,
place: {...MOONLIGHT_STAY_PAYLOAD.place, category: PlaceCategory.CAFE},
};
const [business] = collectJsonLd(cafe, PAGE);
expect(business.makesOffer).toBeUndefined();
expect(business.potentialAction).toBeUndefined();
});
});
describe('llms.txt — AI 가 예약 경로를 읽는 자리', () => {
it('재고와 결제를 우리가 갖지 않는다고 명시한다', () => {
expect(renderLlmsTxt(MOONLIGHT_STAY_PAYLOAD)).toContain(
'이 홈페이지는 빈 방 재고와 결제를 처리하지 않습니다.',
);
});
it('예약 창구와 기준 요금이 위쪽 블록에 있다 — 아래에만 있으면 답에 안 실린다', () => {
const txt = renderLlmsTxt(MOONLIGHT_STAY_PAYLOAD);
expect(txt).toContain('- 전화 예약: 0507-1345-8821');
expect(txt).toContain('- 기준 요금 — Moonlight A동: 주중 1박 280,000원');
expect(txt.indexOf('## 예약')).toBeLessThan(txt.indexOf('## 이용 정보'));
});
it('확정 전 채널은 예약 블록에 없다', () => {
const txt = renderLlmsTxt(MOONLIGHT_STAY_PAYLOAD);
const booking = txt.slice(txt.indexOf('## 예약'), txt.indexOf('## 이용 정보'));
expect(booking).not.toContain(UNCONFIRMED_URL);
});
});

View File

@ -1,4 +1,5 @@
import { import {
LinkChannel,
PlaceCategory, PlaceCategory,
factBool, factBool,
factValue, factValue,
@ -8,6 +9,7 @@ import {
selectPublishableFaqs, selectPublishableFaqs,
type FactEntry, type FactEntry,
type SitePayload, type SitePayload,
type UnitInfo,
} from '@o2o/shared'; } from '@o2o/shared';
/** /**
@ -191,10 +193,114 @@ export function businessJsonLd(payload: SitePayload): Json {
amenityFeature: amenityFeatures(payload.facts), amenityFeature: amenityFeatures(payload.facts),
sameAs: sameAs(payload), sameAs: sameAs(payload),
[spec.type === 'MenuItem' ? 'hasMenu' : 'containsPlace']: unitNodes(payload), [spec.type === 'MenuItem' ? 'hasMenu' : 'containsPlace']: unitNodes(payload),
makesOffer: stayOffers(payload),
potentialAction: reserveAction(payload),
...categoryExtras(payload), ...categoryExtras(payload),
}); });
} }
/**
* 객실 기준 요금 — **화면과 JSON-LD 가 같은 숫자를 쓰게 하는 단일 출처.**
*
* ★ 왜 여기 있나
* 화면의 요금 표기(`derive.unitPriceText`)와 JSON-LD 의 `makesOffer.price` 가 각자
* 계산하면 둘이 갈라질 수 있고, 갈라지는 순간 절대규칙 3(화면 = 구조화 데이터) 위반이라
* 발행 게이트가 사이트를 막는다. 그래서 숫자를 고르는 함수는 하나뿐이고, 양쪽이 이걸 쓴다.
* ★ 주중 요금을 기준으로 삼는다 — 손님이 "얼마부터"로 읽는 값이고, 주말/성수기는 그보다 비싸다.
* `label` 을 같이 돌려주는 이유: 화면이 "주중 280,000원" 이라고 쓰면 구조화 데이터의
* `unitText` 도 같은 말이어야 한다. 어느 요금인지 안 밝힌 가격은 그 자체로 오해다.
*/
export function unitBaseRate(unit: UnitInfo): {price: number; label: string} | undefined {
for (const [key, label] of [
['weekday_price', '주중 1박'],
['price', '1박'],
['weekend_price', '주말 1박'],
['peak_price', '성수기 1박'],
] as const) {
const price = Number(factValue(unit.facts, key)?.replace(/[^0-9]/g, ''));
if (Number.isFinite(price) && price > 0) return {price, label};
}
return undefined;
}
/**
* makesOffer — 객실별 1박 요금. **숙박만** 낸다.
*
* ★ 왜 `containsPlace` 안이 아니라 여기인가
* `HotelRoom` 은 Accommodation 이라 `offers` 가 정식 속성이 아니다. 요금을 파는 주체는
* 사업장이므로 Organization 계열의 `makesOffer` 가 맞는 자리다. 대조기(verify.ts)도
* 이 속성을 이름·가격 쌍으로 따로 검사한다.
* ★ `availability` 는 넣지 않는다. 우리는 빈 방 재고를 모른다 — 모르는 것을 InStock 으로
* 주장하면 그게 거짓이고, 예약 채널이 마감인데 AI 가 "예약 가능" 이라고 답하게 된다.
* ★ `url` 은 확정된 예약 채널뿐이다. 없으면 넣지 않는다(자기 페이지로 돌려보내는 예약 URL 은
* 예약 경로가 아니다).
*/
function stayOffers(payload: SitePayload): Json[] {
if (payload.place.category !== PlaceCategory.LODGING) return [];
const spec = UNIT_SPEC[payload.place.category];
const reserveUrl = bookingChannelUrl(payload);
return sanitizeUnits(payload.units)
.map((unit) => {
const rate = unitBaseRate(unit);
if (!rate) return null;
const offer: Json = compact({
'@type': 'Offer',
name: unit.name,
price: rate.price,
priceCurrency: 'KRW',
priceSpecification: compact({
'@type': 'UnitPriceSpecification',
price: rate.price,
priceCurrency: 'KRW',
unitText: rate.label,
}),
itemOffered: {'@id': `${siteUrl(payload, spec.path, unit.slug)}#unit`},
url: reserveUrl,
});
return offer;
})
.filter((offer): offer is Json => offer !== null);
}
/**
* 예약을 실제로 받는 채널 URL 하나. 화면의 예약 버튼과 같은 목록에서 고른다
* (`derive.bookingLinks` — 야놀자·여기어때·네이버 플레이스, 확정된 것만).
*
* ★ 목록을 두 곳에 적지 않으려면 derive 를 부르는 쪽이 자연스럽지만, 의존 방향이
* derive → jsonld 라 반대로 부를 수 없다. 채널 코드 목록은 이 파일에 두고
* derive 가 이걸 쓴다.
*/
export const BOOKING_CHANNELS: readonly LinkChannel[] = [
LinkChannel.YANOLJA,
LinkChannel.GOODCHOICE,
LinkChannel.NAVER_PLACE,
];
function bookingChannelUrl(payload: SitePayload): string | undefined {
return payload.links.find((link) => link.confirmed && BOOKING_CHANNELS.includes(link.channel))
?.url;
}
/**
* potentialAction — "이 업소를 예약하는 방법" 을 기계가 읽는 형태로.
*
* AI 검색이 "여기 예약 어떻게 해요?" 에 답할 때 근거로 쓰는 자리다. 확정된 예약 채널이
* 없으면 내보내지 않는다 — 예약을 받지 않는 곳에 예약 액션을 붙이면 그게 거짓이다.
* ★ `actionPlatform` 은 쓰지 않는다. 값이 schema.org URL 이라 화면 대조에서 "화면에 없는
* URL" 로 잡히고, 플랫폼 구분은 이 사이트에서 아무 의미도 없다.
*/
function reserveAction(payload: SitePayload): Json | undefined {
if (payload.place.category !== PlaceCategory.LODGING) return undefined;
const url = bookingChannelUrl(payload);
if (!url) return undefined;
return {
'@type': 'ReserveAction',
target: {'@type': 'EntryPoint', urlTemplate: url, inLanguage: 'ko-KR'},
result: {'@type': 'LodgingReservation'},
};
}
/** 최저~최고 요금. 단위 fact 의 숫자만 모은다 — 확인 안 된 요금은 애초에 안 들어온다. */ /** 최저~최고 요금. 단위 fact 의 숫자만 모은다 — 확인 안 된 요금은 애초에 안 들어온다. */
function priceRange(payload: SitePayload): string | undefined { function priceRange(payload: SitePayload): string | undefined {
const prices = sanitizeUnits(payload.units) const prices = sanitizeUnits(payload.units)

View File

@ -1,4 +1,5 @@
import { import {
PlaceCategory,
factText, factText,
joinUrl, joinUrl,
sanitizeUnits, sanitizeUnits,
@ -6,7 +7,7 @@ import {
selectPublishableFaqs, selectPublishableFaqs,
type SitePayload, type SitePayload,
} from '@o2o/shared'; } from '@o2o/shared';
import {SCHEMA_TYPE, UNIT_SPEC} from './jsonld'; import {BOOKING_CHANNELS, SCHEMA_TYPE, UNIT_SPEC, unitBaseRate} from './jsonld';
/** /**
* llms.txt — LLM 이 이 가게를 설명할 때 쓸 사실 목록. * llms.txt — LLM 이 이 가게를 설명할 때 쓸 사실 목록.
@ -54,6 +55,9 @@ export function renderLlmsTxt(payload: SitePayload): string {
} }
lines.push(''); lines.push('');
// ── 예약 (숙박) ──────────────────────────────────────
pushStayBooking(lines, payload);
// ── 확인된 이용 정보 ────────────────────────────────── // ── 확인된 이용 정보 ──────────────────────────────────
if (facts.length > 0) { if (facts.length > 0) {
lines.push('## 이용 정보'); lines.push('## 이용 정보');
@ -135,6 +139,43 @@ export function renderLlmsTxt(payload: SitePayload): string {
return lines.join('\n'); return lines.join('\n');
} }
/**
* 예약 — 숙박에서 가장 많이 묻는 질의("어떻게 예약해요 / 얼마예요")의 답을 한 블록에 모은다.
*
* ★ 이용 정보·객실 절에 흩어져 있는 값을 한 번 더 쓰는 것이지만, LLM 은 이 파일을 위에서부터
* 읽고 답을 만든다. 예약 경로가 "공식 채널" 절 맨 아래에만 있으면 답에 안 실린다.
* ★ **재고와 결제를 우리가 갖지 않는다는 사실을 명시한다.** 이 문장이 없으면 LLM 이
* "공식 홈페이지에서 바로 예약할 수 있다"고 답한다 — 그건 거짓이고, 손님은 헛걸음한다.
* ★ 예약 채널은 확정된 것만이다. 확정 전 URL 은 동명 업소의 예약 페이지일 수 있다.
*/
function pushStayBooking(lines: string[], payload: SitePayload) {
if (payload.place.category !== PlaceCategory.LODGING) return;
const links = payload.links.filter(
(link) => link.confirmed && BOOKING_CHANNELS.includes(link.channel),
);
const rates = sanitizeUnits(payload.units)
.map((unit) => {
const rate = unitBaseRate(unit);
return rate ? `${unit.name}: ${rate.label} ${rate.price.toLocaleString('ko-KR')}원` : null;
})
.filter((line): line is string => line !== null);
if (!payload.place.phone && links.length === 0 && rates.length === 0) return;
lines.push('## 예약');
lines.push('');
lines.push(
'이 홈페이지는 빈 방 재고와 결제를 처리하지 않습니다. ' +
'예약 가능 여부와 결제는 아래 창구에서 확인해야 합니다.',
);
lines.push('');
if (payload.place.phone) lines.push(`- 전화 예약: ${payload.place.phone}`);
for (const link of links) lines.push(`- ${link.title ?? '예약 채널'}: ${link.url}`);
for (const rate of rates) lines.push(`- 기준 요금 — ${rate}`);
lines.push('');
}
function pushFact(lines: string[], label: string, value: string | null | undefined) { function pushFact(lines: string[], label: string, value: string | null | undefined) {
lines.push(`- ${label}: ${value?.trim() ? value : '정보 없음'}`); lines.push(`- ${label}: ${value?.trim() ? value : '정보 없음'}`);
} }

View File

@ -52,6 +52,23 @@ describe('visibleText', () => {
}); });
}); });
describe('verifyJsonLd — 표기 차이로 사실을 막지 않는다', () => {
it('속성의 & 이스케이프를 표기 차이로 흡수한다 — 화면에 있는 이미지였다', () => {
// 실측(2026-09-07): 쿼리스트링 있는 이미지 URL 을 쓰는 사이트가 전부 발행 불가였다.
// HTML 속성에서는 & 가 &amp; 로 나가는데 JSON-LD 는 원본 & 를 갖고 있다.
const url = 'https://cdn.example.com/a.jpg?auto=format&fit=crop';
const body = `<img src="https://cdn.example.com/a.jpg?auto=format&amp;fit=crop" alt="객실">`;
expect(verifyJsonLd(page(body), [{'@type': 'LodgingBusiness', image: [url]}])).toEqual([]);
});
it('그래도 없는 이미지는 잡는다 — 이스케이프를 되돌려도 못 찾으면 실패다', () => {
const problems = verifyJsonLd(page(BODY), [
{'@type': 'LodgingBusiness', image: ['https://cdn.example.com/none.jpg?a=1&b=2']},
]);
expect(problems).toHaveLength(1);
});
});
describe('verifyJsonLd — 거짓을 잡는다', () => { describe('verifyJsonLd — 거짓을 잡는다', () => {
it('화면에 없는 전화번호를 주장하면 잡는다', () => { it('화면에 없는 전화번호를 주장하면 잡는다', () => {
const problems = verifyJsonLd(page(BODY), [ const problems = verifyJsonLd(page(BODY), [

View File

@ -22,6 +22,10 @@ const STRUCTURAL = new Set([
'@type', '@type',
'@id', '@id',
'priceCurrency', 'priceCurrency',
// 단위 코드(UN/CEFACT). 면적 76㎡ 를 화면은 '㎡' 로 쓰고 구조화 데이터는 'MTK' 로 쓴다 —
// 한국어 페이지에 'MTK' 가 찍힐 일은 없다. priceCurrency('KRW')와 같은 종류의 메타값이다.
// ★ 사람이 읽는 단위 표기(`unitText`)는 여기 넣지 않는다 — 그건 화면에 있어야 하는 말이다.
'unitCode',
'addressCountry', 'addressCountry',
'inLanguage', 'inLanguage',
// 좌표는 지도 핀용 메타지 본문에 쓸 값이 아니다. 대신 payload 와 직접 대조한다(verifyGeo). // 좌표는 지도 핀용 메타지 본문에 쓸 값이 아니다. 대신 payload 와 직접 대조한다(verifyGeo).
@ -134,6 +138,18 @@ function asShown(value: Scalar): string[] {
*/ */
export function verifyJsonLd(html: string, nodes: Json[]): string[] { export function verifyJsonLd(html: string, nodes: Json[]): string[] {
const text = visibleText(html); const text = visibleText(html);
/**
* URL 대조용 사본 — 엔티티를 되돌린 HTML.
*
* ★ 왜 필요한가 (실측 2026-09-07, 데모 payload)
* `<img src="…?auto=format&fit=crop">` 는 HTML 로 나갈 때 `&` 가 `&amp;` 로 이스케이프된다.
* JSON-LD 의 `image` 는 원본 `&` 를 갖고 있으므로 원본 HTML 문자열에서는 절대 안 찾아진다 —
* **화면에 실제로 있는 이미지가 "화면에 없다"로 잡혀** 발행이 막혔다. 쿼리스트링 있는
* 이미지 URL 을 쓰는 사이트는 전부 이 오탐에 걸린다.
* 숫자 표기 차이를 `asShown()` 으로 흡수하는 것과 같은 이유다 — **표기 차이는 거짓이 아니다.**
* ★ 반대로 느슨해지지는 않는다: 되돌린 사본에서도 못 찾으면 그대로 실패다.
*/
const unescaped = unescapeHtml(html);
const problems: string[] = []; const problems: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@ -156,7 +172,9 @@ export function verifyJsonLd(html: string, nodes: Json[]): string[] {
const token = String(value); const token = String(value);
// URL·이미지는 본문 텍스트가 아니라 요소 속성(src/href)에 있다. // URL·이미지는 본문 텍스트가 아니라 요소 속성(src/href)에 있다.
if (token.startsWith('http://') || token.startsWith('https://')) { if (token.startsWith('http://') || token.startsWith('https://')) {
if (!html.includes(token)) report(`${prop}: '${token}' 이 화면에 없다`); if (!html.includes(token) && !unescaped.includes(token)) {
report(`${prop}: '${token}' 이 화면에 없다`);
}
continue; continue;
} }
if (asShown(value).some((shown) => text.includes(shown))) continue; if (asShown(value).some((shown) => text.includes(shown))) continue;