From d498d36ccff1039045f3cfc11fb928aeb16007a3 Mon Sep 17 00:00:00 2001 From: hbyang Date: Mon, 7 Sep 2026 15:27:29 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/site,frontend,backend:=20?= =?UTF-8?q?=EC=88=99=EB=B0=95=20=EC=98=88=EC=95=BD=20=EA=B5=AC=EC=84=B1=20?= =?UTF-8?q?=E2=80=94=20=EC=9A=94=EA=B8=88=C2=B7=EC=9D=B8=EC=9B=90=C2=B7?= =?UTF-8?q?=EC=B0=BD=EA=B5=AC=EB=A5=BC=20=ED=95=9C=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=EC=97=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 숙박으로 발행하면 서버 기본표가 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 는 같은 방식으로 손대조했다. --- docs/DEVLOG.md | 51 ++++ solution/backend/services/site_payload.py | 2 +- solution/frontend/src/data/industryData.ts | 2 +- solution/site/src/fixtures/moonlight-stay.ts | 5 + solution/site/src/lib/derive.ts | 169 ++++++++++++-- solution/site/src/pages/HomePage.tsx | 16 +- solution/site/src/sections/MobileTabBar.tsx | 7 +- solution/site/src/sections/SiteHeader.tsx | 5 +- .../site/src/sections/StayBookingSection.tsx | 205 ++++++++++++++++ solution/site/src/sections/index.ts | 2 + .../site/src/sections/stay-booking.test.tsx | 220 ++++++++++++++++++ solution/site/src/seo/jsonld.ts | 106 +++++++++ solution/site/src/seo/llms.ts | 43 +++- solution/site/src/seo/verify.test.ts | 17 ++ solution/site/src/seo/verify.ts | 20 +- 15 files changed, 846 insertions(+), 24 deletions(-) create mode 100644 solution/site/src/sections/StayBookingSection.tsx create mode 100644 solution/site/src/sections/stay-booking.test.tsx diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index bdfbe3b..bfb7dba 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다 **무슨 일** diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index 1281900..cd5790b 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -86,7 +86,7 @@ _DEFAULT_THEME = { "card": "#fafafa", "text": "#09090b", "accent": "#2563eb"}, "sections": [ ("hero", "히어로", True), ("intro", "소개", False), ("rooms", "객실 안내", False), - ("info", "기본 정보", True), ("rules", "이용 규정", False), ("booking", "실시간 예약", False), + ("info", "기본 정보", True), ("rules", "이용 규정", False), ("booking", "예약 안내", False), ("photos", "사진 갤러리", False), ("map", "오시는 길", True), ("weather", "날씨", False), ("local", "지역 정보", False), ("faq", "자주 묻는 질문", False), ], diff --git a/solution/frontend/src/data/industryData.ts b/solution/frontend/src/data/industryData.ts index 9f5fddd..f686845 100644 --- a/solution/frontend/src/data/industryData.ts +++ b/solution/frontend/src/data/industryData.ts @@ -126,7 +126,7 @@ export const INDUSTRY_CONFIGS: Record = { { id: 'rooms', type: 'rooms', name: '객실 안내', isLocked: false, isEnabled: true, description: '객실 타입, 구조, 비치 물품' }, { id: 'info', type: 'info', name: '기본 정보', isLocked: true, 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: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '위치 안내 및 대중교통 경로' }, { id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 사업장 주변 날씨' }, diff --git a/solution/site/src/fixtures/moonlight-stay.ts b/solution/site/src/fixtures/moonlight-stay.ts index ca32309..42225e2 100644 --- a/solution/site/src/fixtures/moonlight-stay.ts +++ b/solution/site/src/fixtures/moonlight-stay.ts @@ -534,6 +534,11 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = { {id: 'intro', name: '소개', enabled: true, locked: false}, {id: 'rooms', name: '객실 안내', enabled: true, locked: false}, {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: 'weather', name: '날씨', enabled: true, locked: false}, {id: 'local', name: '지역 정보', enabled: true, locked: false}, diff --git a/solution/site/src/lib/derive.ts b/solution/site/src/lib/derive.ts index dc0274b..07822e9 100644 --- a/solution/site/src/lib/derive.ts +++ b/solution/site/src/lib/derive.ts @@ -12,7 +12,7 @@ import { type SitePayload, type UnitInfo, } from '@o2o/shared'; -import {UNIT_SPEC} from '@/seo/jsonld'; +import {BOOKING_CHANNELS, UNIT_SPEC, unitBaseRate} from '@/seo/jsonld'; /** * payload → 화면이 바로 쓰는 모양. @@ -96,12 +96,15 @@ export function unitViews(payload: SitePayload): UnitView[] { }); } +/** + * 카드에 얹는 "얼마부터". + * + * ★ 숫자를 여기서 고르지 않는다 — `unitBaseRate`(seo/jsonld.ts) 하나가 고른 값을 표기만 한다. + * 화면과 JSON-LD 가 각자 계산하면 어긋날 수 있고, 어긋나면 절대규칙 3 위반으로 발행이 막힌다. + */ 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')}원부터`; + const rate = unitBaseRate(unit); + return rate ? `${rate.price.toLocaleString('ko-KR')}원부터` : undefined; } /** 갤러리에 낼 이미지 — 대체 텍스트 없는 것은 뺀다(검색·낭독기 모두 못 읽는다). */ @@ -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 처럼 말을 걸 수 있는 곳만. @@ -312,6 +312,145 @@ export function bookingLinks(payload: SitePayload): ChannelLink[] { 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[] { return confirmedLinks(payload, CONTACT_CHANNELS); } diff --git a/solution/site/src/pages/HomePage.tsx b/solution/site/src/pages/HomePage.tsx index cbc3635..bc617ac 100644 --- a/solution/site/src/pages/HomePage.tsx +++ b/solution/site/src/pages/HomePage.tsx @@ -1,3 +1,4 @@ +import {PlaceCategory} from '@o2o/shared'; import { AboutSection, AnswerBlock, @@ -14,10 +15,11 @@ import { LocationSection, RulesSection, SpaceSection, + StayBookingSection, UnitsSection, } from '@/sections'; 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() { const payload = useSite(); + const isLodging = payload.place.category === PlaceCategory.LODGING; /** * 섹션 id → 발행본 컴포넌트. @@ -53,7 +56,9 @@ export function HomePage() { menu: UnitsSection, programs: UnitsSection, rules: RulesSection, - booking: BookingSection, + // ★ 숙박은 예약 섹션이 다른 컴포넌트다. 같은 이름의 섹션이 업종에 따라 다른 것을 + // 그리는 자리는 여기가 유일하다 — 이유는 StayBookingSection 머리주석. + booking: isLodging ? StayBookingSection : BookingSection, space: SpaceSection, inquiry: InquirySection, exhibition: ExhibitionSection, @@ -88,6 +93,13 @@ export function HomePage() { {/* 섹션 설정에 없더라도 오시는 길은 항상 나간다 — 위치 질의의 근거다. */} {!isSectionEnabled(payload, 'map') && } + + {/* ★ 숙박에서 예약 안내는 **항목이 없을 때만** 기본으로 낸다. + "사장님이 껐다" 와 "payload 에 항목이 아예 없다" 는 다른 상태다(`hasSection`) — + 옛 payload·손으로 만든 fixture 에는 booking 항목이 없는데, 숙박에서 예약 창구가 + 없는 페이지는 이 업종 질의의 대부분("어떻게 예약해요")에 답을 못 한다. + 끈 것을 되살리지는 않는다 — 그건 사장님 결정이다. */} + {isLodging && !hasSection(payload, 'booking') && } ); } diff --git a/solution/site/src/sections/MobileTabBar.tsx b/solution/site/src/sections/MobileTabBar.tsx index accfe1c..06e57ea 100644 --- a/solution/site/src/sections/MobileTabBar.tsx +++ b/solution/site/src/sections/MobileTabBar.tsx @@ -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 {isSectionEnabled, unitSpec} from '@/lib/derive'; +import {isSectionEnabled, stayBookingView, unitSpec} from '@/lib/derive'; import {useActiveSection} from '@/lib/use-active-section'; /** @@ -20,6 +20,9 @@ export function MobileTabBar() { const tabs = [ {id: 'top', label: '홈', icon: Home}, ...(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}] : []), {id: 'location', label: '위치', icon: MapPin}, ]; diff --git a/solution/site/src/sections/SiteHeader.tsx b/solution/site/src/sections/SiteHeader.tsx index 0f805f1..21684a5 100644 --- a/solution/site/src/sections/SiteHeader.tsx +++ b/solution/site/src/sections/SiteHeader.tsx @@ -1,6 +1,6 @@ import {Phone} from 'lucide-react'; 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 = [ {label: '소개', href: '#about', show: isSectionEnabled(payload, 'intro')}, {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: '#location', show: true}, {label: 'FAQ', href: '#faq', show: payload.faqs.length > 0}, diff --git a/solution/site/src/sections/StayBookingSection.tsx b/solution/site/src/sections/StayBookingSection.tsx new file mode 100644 index 0000000..8f93591 --- /dev/null +++ b/solution/site/src/sections/StayBookingSection.tsx @@ -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 ( +
+
+

+ + Reservation +

+ +

+ {sectionName(payload, 'booking', '예약 안내')} +

+ {/* ★ "여기서 결제되지 않는다"를 먼저 말한다. 예약 버튼을 누른 뒤에 알게 되면 + 손님은 속은 것으로 느끼고, 그 인상은 업소가 가져간다. */} +

+ 빈 방 확인과 결제는 아래 예약 창구에서 진행됩니다. 이 페이지에서는 요금과 이용 조건만 + 안내합니다. +

+ +
+ {/* ── 객실별 요금 · 인원 ───────────────────────────────── */} + {offers.length > 0 && ( +
+

+ + 객실별 요금 · 인원 +

+ +
    + {offers.map((offer) => ( +
  • +
    +

    {offer.name}

    + {offer.baseRateText && ( + + {offer.baseRateText} + + )} +
    + + {offer.capacityText && ( +

    {offer.capacityText}

    + )} + + {offer.rateRows.length > 0 && ( +
    + {offer.rateRows.map((row) => ( +
    +
    {row.label}
    +
    {row.value}
    +
    + ))} +
    + )} + + {/* 사진·전체 스펙은 객실 섹션이 갖고 있다. 한 장 사이트라 앵커다. */} + + {offer.name} 사진 · 상세 보기 + +
  • + ))} +
+
+ )} + + {/* ── 예약 창구 ──────────────────────────────────────── */} +
+

예약 창구

+ + {phone && ( + + + 전화 예약 {phone} + + )} + + {/* ★ 확정된 채널만 나간다 — 확정 전 URL 은 동명 업소의 예약 페이지일 수 있다. + 눌렀는데 남의 숙소가 뜨면 그 예약은 영영 우리 것이 아니다. */} + {links.map((link) => ( + + {`${channelLabel(link)}에서 예약`} + + + ))} + + {contacts.length > 0 && ( +
+

문의

+ +
+ )} + + {links.length === 0 && ( + // 채널이 하나도 확정되지 않은 업소 — 전화가 유일한 창구다. 그 사실을 말해 준다. +

+ 온라인 예약 채널은 등록되지 않았습니다. 예약은 전화로 문의해 주세요. +

+ )} +
+
+ + {/* ── 예약 전 확인 ────────────────────────────────────── */} + {notices.length > 0 && ( +
+

+ + 예약 전 확인 +

+ + {/* 확인된 fact 만 줄이 된다 — "확인 중" 이라는 빈 줄을 그리면 손님은 그걸 규정으로 읽는다. */} +
+ {notices.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/solution/site/src/sections/index.ts b/solution/site/src/sections/index.ts index a6ec6ed..ae041b9 100644 --- a/solution/site/src/sections/index.ts +++ b/solution/site/src/sections/index.ts @@ -6,6 +6,8 @@ export {EssentialInfoSection} from './EssentialInfoSection'; export {UnitsSection} from './UnitsSection'; export {RulesSection} from './RulesSection'; export {BookingSection} from './BookingSection'; +// 숙박은 예약 구성이 통째로 다르다 — 이유는 StayBookingSection 머리주석. +export {StayBookingSection} from './StayBookingSection'; export {SpaceSection} from './SpaceSection'; export {InquirySection} from './InquirySection'; export {ExhibitionSection} from './ExhibitionSection'; diff --git a/solution/site/src/sections/stay-booking.test.tsx b/solution/site/src/sections/stay-booking.test.tsx new file mode 100644 index 0000000..f729d6b --- /dev/null +++ b/solution/site/src/sections/stay-booking.test.tsx @@ -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 [ + '', + head, + '', + `
${render(payload)}
`, + ``, + '', + ].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[]) { + 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); + }); +}); diff --git a/solution/site/src/seo/jsonld.ts b/solution/site/src/seo/jsonld.ts index 7d0d463..0174843 100644 --- a/solution/site/src/seo/jsonld.ts +++ b/solution/site/src/seo/jsonld.ts @@ -1,4 +1,5 @@ import { + LinkChannel, PlaceCategory, factBool, factValue, @@ -8,6 +9,7 @@ import { selectPublishableFaqs, type FactEntry, type SitePayload, + type UnitInfo, } from '@o2o/shared'; /** @@ -191,10 +193,114 @@ export function businessJsonLd(payload: SitePayload): Json { amenityFeature: amenityFeatures(payload.facts), sameAs: sameAs(payload), [spec.type === 'MenuItem' ? 'hasMenu' : 'containsPlace']: unitNodes(payload), + makesOffer: stayOffers(payload), + potentialAction: reserveAction(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 의 숫자만 모은다 — 확인 안 된 요금은 애초에 안 들어온다. */ function priceRange(payload: SitePayload): string | undefined { const prices = sanitizeUnits(payload.units) diff --git a/solution/site/src/seo/llms.ts b/solution/site/src/seo/llms.ts index 800984f..a3aebb9 100644 --- a/solution/site/src/seo/llms.ts +++ b/solution/site/src/seo/llms.ts @@ -1,4 +1,5 @@ import { + PlaceCategory, factText, joinUrl, sanitizeUnits, @@ -6,7 +7,7 @@ import { selectPublishableFaqs, type SitePayload, } from '@o2o/shared'; -import {SCHEMA_TYPE, UNIT_SPEC} from './jsonld'; +import {BOOKING_CHANNELS, SCHEMA_TYPE, UNIT_SPEC, unitBaseRate} from './jsonld'; /** * llms.txt — LLM 이 이 가게를 설명할 때 쓸 사실 목록. @@ -54,6 +55,9 @@ export function renderLlmsTxt(payload: SitePayload): string { } lines.push(''); + // ── 예약 (숙박) ────────────────────────────────────── + pushStayBooking(lines, payload); + // ── 확인된 이용 정보 ────────────────────────────────── if (facts.length > 0) { lines.push('## 이용 정보'); @@ -135,6 +139,43 @@ export function renderLlmsTxt(payload: SitePayload): string { 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) { lines.push(`- ${label}: ${value?.trim() ? value : '정보 없음'}`); } diff --git a/solution/site/src/seo/verify.test.ts b/solution/site/src/seo/verify.test.ts index da7a660..8152f7a 100644 --- a/solution/site/src/seo/verify.test.ts +++ b/solution/site/src/seo/verify.test.ts @@ -52,6 +52,23 @@ describe('visibleText', () => { }); }); +describe('verifyJsonLd — 표기 차이로 사실을 막지 않는다', () => { + it('속성의 & 이스케이프를 표기 차이로 흡수한다 — 화면에 있는 이미지였다', () => { + // 실측(2026-09-07): 쿼리스트링 있는 이미지 URL 을 쓰는 사이트가 전부 발행 불가였다. + // HTML 속성에서는 & 가 & 로 나가는데 JSON-LD 는 원본 & 를 갖고 있다. + const url = 'https://cdn.example.com/a.jpg?auto=format&fit=crop'; + const body = `객실`; + 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 — 거짓을 잡는다', () => { it('화면에 없는 전화번호를 주장하면 잡는다', () => { const problems = verifyJsonLd(page(BODY), [ diff --git a/solution/site/src/seo/verify.ts b/solution/site/src/seo/verify.ts index 9a34007..977b6c8 100644 --- a/solution/site/src/seo/verify.ts +++ b/solution/site/src/seo/verify.ts @@ -22,6 +22,10 @@ const STRUCTURAL = new Set([ '@type', '@id', 'priceCurrency', + // 단위 코드(UN/CEFACT). 면적 76㎡ 를 화면은 '㎡' 로 쓰고 구조화 데이터는 'MTK' 로 쓴다 — + // 한국어 페이지에 'MTK' 가 찍힐 일은 없다. priceCurrency('KRW')와 같은 종류의 메타값이다. + // ★ 사람이 읽는 단위 표기(`unitText`)는 여기 넣지 않는다 — 그건 화면에 있어야 하는 말이다. + 'unitCode', 'addressCountry', 'inLanguage', // 좌표는 지도 핀용 메타지 본문에 쓸 값이 아니다. 대신 payload 와 직접 대조한다(verifyGeo). @@ -134,6 +138,18 @@ function asShown(value: Scalar): string[] { */ export function verifyJsonLd(html: string, nodes: Json[]): string[] { const text = visibleText(html); + /** + * URL 대조용 사본 — 엔티티를 되돌린 HTML. + * + * ★ 왜 필요한가 (실측 2026-09-07, 데모 payload) + * `` 는 HTML 로 나갈 때 `&` 가 `&` 로 이스케이프된다. + * JSON-LD 의 `image` 는 원본 `&` 를 갖고 있으므로 원본 HTML 문자열에서는 절대 안 찾아진다 — + * **화면에 실제로 있는 이미지가 "화면에 없다"로 잡혀** 발행이 막혔다. 쿼리스트링 있는 + * 이미지 URL 을 쓰는 사이트는 전부 이 오탐에 걸린다. + * 숫자 표기 차이를 `asShown()` 으로 흡수하는 것과 같은 이유다 — **표기 차이는 거짓이 아니다.** + * ★ 반대로 느슨해지지는 않는다: 되돌린 사본에서도 못 찾으면 그대로 실패다. + */ + const unescaped = unescapeHtml(html); const problems: string[] = []; const seen = new Set(); @@ -156,7 +172,9 @@ export function verifyJsonLd(html: string, nodes: Json[]): string[] { const token = String(value); // URL·이미지는 본문 텍스트가 아니라 요소 속성(src/href)에 있다. 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; } if (asShown(value).some((shown) => text.includes(shown))) continue;