From 66f81f36316c8e50f024477e30d0190864f54b58 Mon Sep 17 00:00:00 2001 From: hbyang Date: Tue, 8 Sep 2026 10:52:36 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/backend,site:=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20=EB=B2=84=ED=8A=BC=EC=9D=84=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B2=84=20=EC=98=88=EC=95=BD=20=ED=99=94=EB=A9=B4=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=E2=80=94=20=EA=B2=80=EC=83=89=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=9D=B4=20=EB=9C=A8=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발행본의 예약 버튼이 네이버 플레이스 링크를 그대로 열었다. 잘해야 가게 홈이라 예약을 한 번 더 눌러야 하고, 자동 발견이 물어온 URL 이 map.naver.com/p/search/… 인 사장님은 예약하려고 눌렀는데 검색 화면을 봤다. 예약하러 온 손님은 거기서 끝난다. 주소를 지어낼 필요가 없다 — 플레이스 응답의 __APOLLO_STATE__ 가 예약 주소를 직접 준다 (실측 2026-09-08, place 1273971279): naverBooking.naverBookingUrl = https://m.booking.naver.com/booking/6/bizes/1067685 ★ bookingBusinessId + businessTypeId 로 조립하지 않는다. 조립하면 예약을 안 받는 업소에도 주소가 생기고, 빈 화면을 본 손님은 그 가게가 예약을 안 받는 줄로 읽는다. - LinkChannel.NAVER_BOOKING=7 (백엔드·shared·init.sql 주석) - collector/base: RawSource.booking_url — 채널이 스스로 알려준 예약 주소 - naver_place_adapter._booking_url: ROOT_QUERY.placeDetail(...).naverBooking 에서 읽는다 - collect_service._store_booking_link: 예약 채널로 등록·자동 확정(근거는 discover_naver_place 와 같다 — 이미 확정된 플레이스가 내놓은 자기 예약 주소다) - site/seo/jsonld BOOKING_CHANNELS: 순서가 우선순위(예약→야놀자→여기어때→플레이스). 화면 버튼과 makesOffer.url·potentialAction 이 같은 함수를 쓴다 - site/lib/derive: 같은 순서로 정렬 + 검색 결과 주소 배제. 문구는 bookingCtaLabel 이 "네이버 예약으로 바로 예약하기"로 낸다("네이버 예약에서 예약"이 되지 않게) - 빌더도 채널을 안다(useCollectFlow 라벨 · ChannelUrlInput 호스트 판정) tsc·eslint 통과, vitest 47 passed(신규 4). 어댑터는 실제 네이버 응답으로 확인. --- docs/DEVLOG.md | 44 +++++++++++++++ postgres-init/init-data/init.sql | 2 +- solution/backend/common/enums.py | 5 ++ solution/backend/services/collect_service.py | 36 ++++++++++++ solution/backend/services/collector/base.py | 4 ++ .../services/collector/naver_place_adapter.py | 33 ++++++++++- .../features/onboarding/ChannelUrlInput.tsx | 3 + .../src/features/onboarding/useCollectFlow.ts | 1 + solution/shared/src/types/domain.ts | 2 + solution/site/src/lib/derive.ts | 32 ++++++++++- solution/site/src/sections/BookingSection.tsx | 4 +- .../site/src/sections/StayBookingSection.tsx | 4 +- .../site/src/sections/stay-booking.test.tsx | 55 +++++++++++++++++++ solution/site/src/seo/jsonld.ts | 19 ++++++- 14 files changed, 235 insertions(+), 9 deletions(-) diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 6be445e..66cce80 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,50 @@ --- +## 2026-09-08 — "예약" 을 누르면 검색 화면이 떴다 — 네이버 예약 주소를 수집해서 쓴다 + +**무슨 일** +발행본의 예약 버튼이 네이버 **플레이스** 링크를 그대로 열었다. 잘해야 가게 홈이라 예약을 한 번 +더 눌러야 하고, 자동 발견이 물어온 URL 이 `map.naver.com/p/search/…`(검색 결과 주소)인 사장님은 +**예약하려고 눌렀는데 검색 화면**을 봤다. 예약하러 온 손님은 거기서 끝난다. + +**근거 — 주소를 지어내지 않아도 된다** +플레이스 모바일 응답(`__APOLLO_STATE__`)의 `ROOT_QUERY.placeDetail(...).naverBooking` 에 +네이버가 예약 주소를 직접 준다(실측 2026-09-08, place 1273971279): + + naverBookingUrl : "https://m.booking.naver.com/booking/6/bizes/1067685" + tabs : [home, feed, menu, booking(예약), review, …] + +★ `bookingBusinessId`(1067685)와 `businessTypeId`(6)로 주소를 **조립하지 않는다.** 조립하면 +예약을 받지 않는 업소에도 그럴듯한 주소가 생기고, 눌러서 빈 화면을 본 손님은 그 가게가 예약을 +안 받는 줄로 읽는다. 응답이 `naverBookingUrl` 을 줄 때만 준 그대로 쓴다(미사용 업소는 null). + +**바꾼 것** +- `LinkChannel.NAVER_BOOKING = 7` (백엔드 enum · shared enum · init.sql 주석). 플레이스와 가른 + 이유는 성격이 다르기 때문이다 — 이건 **예약 화면 그 자체**다 +- `collector/base.py`: `RawSource.booking_url` — 채널이 스스로 알려준 예약 주소를 싣는 자리 +- `naver_place_adapter._booking_url()`: 위 노드에서 읽는다. 키에 질의 인자가 통째로 박혀 있어 + (`placeDetail({"input":…})`) 이름으로 못 찾으므로 접두사로 찾는다 +- `collect_service._store_booking_link()`: 예약 채널 링크로 등록하고 **자동 확정**한다. + 근거는 `discover_naver_place` 와 같다 — 이미 확정된 플레이스가 자기 예약 주소로 내놓은 + 값이라 남의 가게가 섞일 경로가 없다. 여기서 클릭을 한 번 더 받으면 그 사이 예약 버튼은 + 계속 검색 화면으로 간다 +- `site/seo/jsonld.ts` `BOOKING_CHANNELS`: **순서가 우선순위**가 됐다(네이버 예약 → 야놀자 → + 여기어때 → 플레이스). `bookingChannelUrl` 이 이 순서로 고르므로 화면 버튼과 + `makesOffer.url`·`potentialAction` 이 같은 곳을 가리킨다 +- `site/lib/derive.ts`: 예약 버튼을 같은 순서로 정렬하고, **검색 결과 주소는 뺀다** — + 예약하러 온 사람에게 검색 화면을 주는 건 링크가 없는 것보다 나쁘다. 링크가 하나도 없으면 + "온라인 예약 채널은 등록되지 않았습니다" 로 전화만 남는다는 것을 말해 준다 +- `bookingCtaLabel()`: `${채널}에서 예약` 을 일괄로 쓰면 "네이버 예약에서 예약" 이 된다. + 그리고 이 채널만 누르는 즉시 예약 화면이므로 버튼이 그 차이를 말해야 한다 — + "네이버 예약으로 바로 예약하기" +- 빌더도 이 채널을 안다(`useCollectFlow` 라벨, `ChannelUrlInput` 의 호스트 판정) + +**검증** — 실제 네이버 응답으로 어댑터 확인: `RawSource.booking_url = +https://m.booking.naver.com/booking/6/bizes/1067685` · 예약 노드가 없는 응답에서는 None. +`tsc·eslint` 통과, `vitest` 47 passed(신규 4건: 채널 우선순위 · 버튼 문구 · JSON-LD 대상 · +검색 URL 배제). + ## 2026-09-07 — `.env.example` 그대로 쓰면 로컬 발행이 안 됐다 — 함정 둘 클론 직후 문서대로 `cp .env.example .env` 하고 `docker compose up -d` 한 다음 발행을 걸어 봤다. diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 98b42e5..d6e8e00 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -122,7 +122,7 @@ CREATE TABLE IF NOT EXISTS place.place_aliases ( CREATE TABLE IF NOT EXISTS place.place_links ( link_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 링크 식별자(PK) place_id uuid NOT NULL, -- 사업장(place.places.place_id) - channel SMALLINT NOT NULL, -- 채널(LinkChannel): 1=야놀자 2=여기어때 3=네이버플레이스 4=인스타 5=공식홈 6=블로그 99=기타 + channel SMALLINT NOT NULL, -- 채널(LinkChannel): 1=야놀자 2=여기어때 3=네이버플레이스 4=인스타 5=공식홈 6=블로그 7=네이버예약 99=기타 url VARCHAR(1000) NOT NULL, -- 발견된 URL title VARCHAR(300) NULL, -- 제목/스니펫 discovered_by SMALLINT NOT NULL, -- 발견 주체(SourceType): 2=api(Perplexity) 1=owner(직접) diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py index 0c96a2d..d0b1cb1 100644 --- a/solution/backend/common/enums.py +++ b/solution/backend/common/enums.py @@ -267,6 +267,11 @@ class LinkChannel(CodeEnum): INSTAGRAM = 4 OFFICIAL_SITE = 5 # 사장님 자체 홈페이지 BLOG = 6 + # ★ 플레이스와 가른 이유: 이건 **예약 화면 그 자체**다. + # 플레이스 홈은 예약 버튼을 한 번 더 눌러야 하고, 자동 발견이 검색 URL 을 물어온 + # 경우에는 아예 검색 결과가 뜬다 — 발행본의 "예약" 버튼이 그리로 가면 손님은 + # 예약을 포기한다. 주소는 지어내지 않는다: 플레이스 응답의 naverBookingUrl 그대로다. + NAVER_BOOKING = 7 # 네이버 예약(m.booking.naver.com) ETC = 99 diff --git a/solution/backend/services/collect_service.py b/solution/backend/services/collect_service.py index 8234d26..d429516 100644 --- a/solution/backend/services/collect_service.py +++ b/solution/backend/services/collect_service.py @@ -518,6 +518,8 @@ async def run_collect(job: dict) -> dict: ), ) + await _store_booking_link(place, place_id, source) + unit_map = await ensure_units(place_id, [source]) unit_total = max(unit_total, len(unit_map)) f = await store_facts(actor, place_id, [source], unit_map) @@ -545,6 +547,40 @@ async def run_collect(job: dict) -> dict: return result +async def _store_booking_link(place, place_id: str, source) -> bool: + """수집 중 채널이 알려준 예약 주소를 **예약 채널 링크**로 남긴다. + + ★ 왜 필요한가 (실측 2026-09-08) + 발행본의 "예약" 버튼이 네이버 플레이스 링크를 그대로 열었다. 그 링크는 잘해야 플레이스 + 홈이라 예약까지 한 번 더 눌러야 하고, 자동 발견이 검색 URL(`map.naver.com/p/search/…`)을 + 물어온 경우에는 **검색 결과 화면**이 뜬다. 예약하려고 누른 손님이 검색 결과를 만나면 + 거기서 끝난다. + + ★ 주소를 만들지 않는다. 플레이스 응답의 `naverBookingUrl` 을 그대로 쓴다 + (naver_place_adapter._booking_url 머리주석). 예약을 받지 않는 업소에는 이 값이 없고, + 없으면 링크도 없다 — 없는 예약 창구를 만들어내지 않는다. + + ★ 자동 확정한다. 근거는 `discover_naver_place` 와 같다 — 이 URL 은 **이미 확정된** + 플레이스 페이지가 자기 예약 주소로 내놓은 값이라, 남의 가게가 섞일 경로가 없다. + 여기서 클릭을 한 번 더 받으면 사장님이 확정을 안 한 사이트는 예약 버튼이 계속 + 검색 화면으로 간다. + """ + url = (getattr(source, "booking_url", None) or "").strip() + if not url: + return False + + added = await _add_link( + place_id, LinkChannel.NAVER_BOOKING, url, + f"{place.name} 네이버 예약", SourceType.CRAWL, + ) + await DB_SESSION_MNG.execute_lambda_claim( + place_links.DBType(), + lambda s: _place_crud.confirm_link_by_url(s, uuid.UUID(place_id), url, place.verified_by, GTime.UTC()), + ) + LOG.i(f"[collect] 네이버 예약 링크 {'등록·확정' if added else '확정'} — {url}") + return added + + async def _finish(place_id: str, company_id: str, status: PlaceStatus): """수집이 끝나면 사업장을 검수 대기로 돌린다 — 수집값은 전부 후보라 사람이 봐야 한다.""" await DB_SESSION_MNG.execute_lambda_claim( diff --git a/solution/backend/services/collector/base.py b/solution/backend/services/collector/base.py index 800bd47..825d844 100644 --- a/solution/backend/services/collector/base.py +++ b/solution/backend/services/collector/base.py @@ -99,6 +99,10 @@ class RawSource: text: Optional[str] = None # 태그 걷어낸 본문 facts: list[CollectedFact] = field(default_factory=list) media: list[CollectedMedia] = field(default_factory=list) + # ★ 수집 중 **그 채널이 스스로 알려준** 예약 주소. 우리가 만든 주소가 아니다. + # 네이버 플레이스 응답의 naverBookingUrl 이 여기 실린다 — 발행본의 "예약" 버튼이 + # 플레이스 홈(한 번 더 눌러야 한다)이나 검색 결과가 아니라 예약 화면으로 바로 가게 하는 값. + booking_url: Optional[str] = None def __post_init__(self): if not (self.url or "").strip(): diff --git a/solution/backend/services/collector/naver_place_adapter.py b/solution/backend/services/collector/naver_place_adapter.py index 3898d07..3bccef9 100644 --- a/solution/backend/services/collector/naver_place_adapter.py +++ b/solution/backend/services/collector/naver_place_adapter.py @@ -145,8 +145,12 @@ class NaverPlaceAdapter: facts = self._to_facts(base, state) media = self._to_media(state) + booking_url = self._booking_url(state) - LOG.i(f"[naver_place] {base.get('name')} — fact {len(facts)}건 · 사진 {len(media)}장 (id={place_id})") + LOG.i( + f"[naver_place] {base.get('name')} — fact {len(facts)}건 · 사진 {len(media)}장" + f"{' · 예약 주소 있음' if booking_url else ''} (id={place_id})" + ) return RawSource( url=url, adapter_id=self.id, @@ -161,9 +165,36 @@ class NaverPlaceAdapter: ), facts=facts, media=media, + booking_url=booking_url, ) # ---- 내부 ---------------------------------------------------------- + + @staticmethod + def _booking_url(state: dict) -> Optional[str]: + """네이버 예약 화면 주소. **네이버가 준 값 그대로**다 — 조립하지 않는다. + + ★ 왜 조립하지 않나 + 응답에는 `bookingBusinessId`(1067685)와 `businessTypeId`(6)가 같이 있어서 + `m.booking.naver.com/booking/{type}/bizes/{id}` 를 만들 수 있을 것처럼 보인다. + 그러면 예약을 받지 않는 업소에도 그럴듯한 주소가 생기고, 눌렀는데 빈 화면이 + 나오면 손님은 그 가게가 예약을 안 받는 줄로 읽는다. 응답이 `naverBookingUrl` 을 + 줄 때만, 준 그대로 쓴다. 없으면 없는 것이다(실측: 예약 미사용 업소는 null). + + ★ 값은 ROOT_QUERY 의 placeDetail 응답 안에 있다. 키에 질의 인자가 통째로 박혀 있어 + (`placeDetail({"input":{...}})`) 이름으로 못 찾는다 — 접두사로 찾는다. + """ + root = state.get("ROOT_QUERY") + if not isinstance(root, dict): + return None + detail = next( + (v for k, v in root.items() if k.startswith("placeDetail") and isinstance(v, dict)), None + ) + booking = (detail or {}).get("naverBooking") + if not isinstance(booking, dict): + return None + url = str(booking.get("naverBookingUrl") or "").strip() + return url or None async def _resolve_place_id(self, url: str) -> str: """URL 에서 place id 를 뽑는다. diff --git a/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx b/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx index 53a38e1..6f19365 100644 --- a/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx +++ b/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx @@ -32,6 +32,9 @@ function guessChannel(url: string): {channel: LinkChannelCode; label: string} | if (u.includes('blog.naver') || u.includes('cafe.naver')) return null; return {channel: LinkChannel.NAVER_PLACE, label: '네이버 플레이스'}; } + // 네이버 예약은 플레이스보다 먼저 본다 — 호스트가 booking.naver.com 이라 플레이스 판정에 안 걸리지만, + // 순서를 명시해 두지 않으면 나중에 판정 조건이 넓어질 때 조용히 플레이스로 흡수된다. + if (u.includes('booking.naver.com')) return {channel: LinkChannel.NAVER_BOOKING, label: '네이버 예약'}; if (u.includes('yanolja.com')) return {channel: LinkChannel.YANOLJA, label: '야놀자'}; if (u.includes('goodchoice.kr') || u.includes('yeogi.com')) { return {channel: LinkChannel.GOODCHOICE, label: '여기어때'}; diff --git a/solution/frontend/src/features/onboarding/useCollectFlow.ts b/solution/frontend/src/features/onboarding/useCollectFlow.ts index 08f7f99..7ca926b 100644 --- a/solution/frontend/src/features/onboarding/useCollectFlow.ts +++ b/solution/frontend/src/features/onboarding/useCollectFlow.ts @@ -26,6 +26,7 @@ export const CHANNEL_LABEL: Record = { [LinkChannel.YANOLJA]: '야놀자', [LinkChannel.GOODCHOICE]: '여기어때', [LinkChannel.NAVER_PLACE]: '네이버 플레이스', + [LinkChannel.NAVER_BOOKING]: '네이버 예약', [LinkChannel.INSTAGRAM]: '인스타그램', [LinkChannel.OFFICIAL_SITE]: '공식 홈페이지', [LinkChannel.BLOG]: '블로그', diff --git a/solution/shared/src/types/domain.ts b/solution/shared/src/types/domain.ts index 78b194e..a612cdb 100644 --- a/solution/shared/src/types/domain.ts +++ b/solution/shared/src/types/domain.ts @@ -77,6 +77,8 @@ export const LinkChannel = { INSTAGRAM: 4, OFFICIAL_SITE: 5, BLOG: 6, + /** 네이버 예약 화면 그 자체(m.booking.naver.com). 플레이스 홈과 가른다 — 발행본의 예약 버튼이 쓴다. */ + NAVER_BOOKING: 7, ETC: 99, } as const; export type LinkChannel = (typeof LinkChannel)[keyof typeof LinkChannel]; diff --git a/solution/site/src/lib/derive.ts b/solution/site/src/lib/derive.ts index 07822e9..e5d0fdc 100644 --- a/solution/site/src/lib/derive.ts +++ b/solution/site/src/lib/derive.ts @@ -272,6 +272,7 @@ export function exhibitionRows(payload: SitePayload): InfoRow[] { /** 채널 코드 → 사람이 읽는 이름. link.title 이 있으면 그쪽이 우선이다. */ export const CHANNEL_LABEL: Record = { + [LinkChannel.NAVER_BOOKING]: '네이버 예약', [LinkChannel.YANOLJA]: '야놀자', [LinkChannel.GOODCHOICE]: '여기어때', [LinkChannel.NAVER_PLACE]: '네이버 플레이스', @@ -285,6 +286,18 @@ export function channelLabel(link: ChannelLink): string { return link.title ?? CHANNEL_LABEL[link.channel] ?? '채널'; } +/** + * 예약 버튼에 찍을 말. + * + * ★ `${channelLabel}에서 예약` 로 일괄 처리하면 네이버 예약이 "네이버 예약에서 예약" 이 된다. + * 그리고 이 채널은 다른 채널과 성격이 다르다 — 누르면 **예약 화면 그 자체**가 뜬다. + * 그 차이를 버튼이 말해 줘야 손님이 한 번 더 눌러야 하는지 아닌지를 안다. + */ +export function bookingCtaLabel(link: ChannelLink): string { + if (link.channel === LinkChannel.NAVER_BOOKING) return '네이버 예약으로 바로 예약하기'; + return `${channelLabel(link)}에서 예약`; +} + /** * 예약을 실제로 받는 채널 목록은 `seo/jsonld.ts` 의 `BOOKING_CHANNELS` 한 벌이다. * @@ -308,8 +321,25 @@ function confirmedLinks(payload: SitePayload, channels: readonly number[]): Chan return payload.links.filter((link) => link.confirmed && channels.includes(link.channel)); } +/** + * 예약 버튼에 낼 채널. **BOOKING_CHANNELS 순서대로** 정렬한다 — 예약 화면으로 바로 가는 + * 채널이 맨 위 버튼이어야 한다. + * + * ★ 검색 결과 주소는 뺀다. 자동 발견이 `map.naver.com/p/search/…` 를 물어오는 경우가 있고 + * (실측 2026-09-08), 그걸 "예약" 버튼에 걸면 손님이 검색 화면을 만난다 — 예약하러 온 + * 사람에게 검색 결과를 주는 건 링크가 없는 것보다 나쁘다. 가게를 특정하지 못하는 주소라 + * 애초에 예약 창구가 아니다. + */ export function bookingLinks(payload: SitePayload): ChannelLink[] { - return confirmedLinks(payload, BOOKING_CHANNELS); + const order = new Map(BOOKING_CHANNELS.map((channel, index) => [channel, index])); + return confirmedLinks(payload, BOOKING_CHANNELS) + .filter((link) => !isSearchUrl(link.url)) + .sort((a, b) => (order.get(a.channel) ?? 99) - (order.get(b.channel) ?? 99)); +} + +/** 가게가 아니라 **검색 결과**를 가리키는 주소인지. */ +function isSearchUrl(url: string): boolean { + return /\/p\/search\/|[?&]query=/.test(url); } /** diff --git a/solution/site/src/sections/BookingSection.tsx b/solution/site/src/sections/BookingSection.tsx index d0e82b0..ca8e84e 100644 --- a/solution/site/src/sections/BookingSection.tsx +++ b/solution/site/src/sections/BookingSection.tsx @@ -1,6 +1,6 @@ import {ArrowUpRight, CalendarCheck, Phone} from 'lucide-react'; import {useSite} from '@/lib/site-context'; -import {bookingLinks, bookingRows, channelLabel, sectionName} from '@/lib/derive'; +import {bookingCtaLabel, bookingLinks, bookingRows, sectionName} from '@/lib/derive'; /** * 예약 안내. @@ -84,7 +84,7 @@ export function BookingSection() { className="inline-flex items-center gap-1.5 rounded-xl border border-black/10 px-4 py-2.5 text-xs font-semibold transition-colors hover:bg-black/5" style={{backgroundColor: 'var(--color-surface-alt)'}} > - {channelLabel(link)} + {bookingCtaLabel(link)} ))} diff --git a/solution/site/src/sections/StayBookingSection.tsx b/solution/site/src/sections/StayBookingSection.tsx index 8f93591..f498430 100644 --- a/solution/site/src/sections/StayBookingSection.tsx +++ b/solution/site/src/sections/StayBookingSection.tsx @@ -1,6 +1,6 @@ import {ArrowUpRight, BedDouble, CalendarCheck, Phone, ShieldCheck} from 'lucide-react'; import {useSite} from '@/lib/site-context'; -import {channelLabel, sectionName, stayBookingView} from '@/lib/derive'; +import {bookingCtaLabel, channelLabel, sectionName, stayBookingView} from '@/lib/derive'; /** * 숙박 예약 구성 — 손님이 **이 방을 · 이 값에 · 이 창구로** 예약할 수 있는 자리. @@ -139,7 +139,7 @@ export function StayBookingSection() { 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)'}} > - {`${channelLabel(link)}에서 예약`} + {bookingCtaLabel(link)} ))} diff --git a/solution/site/src/sections/stay-booking.test.tsx b/solution/site/src/sections/stay-booking.test.tsx index f729d6b..b19fa32 100644 --- a/solution/site/src/sections/stay-booking.test.tsx +++ b/solution/site/src/sections/stay-booking.test.tsx @@ -12,6 +12,7 @@ import {describe, expect, it} from 'vitest'; import { + LinkChannel, PlaceCategory, FactStatus, sanitizePayloadForPublish, @@ -198,6 +199,60 @@ describe('구조화 데이터 — 예약 값이 화면과 어긋나지 않는가 }); }); +describe('예약 버튼은 예약 화면으로 바로 간다', () => { + /** 네이버 예약 링크가 붙은 payload — 수집이 플레이스 응답에서 받아온 주소. */ + const withBooking: SitePayload = { + ...MOONLIGHT_STAY_PAYLOAD, + links: [ + ...MOONLIGHT_STAY_PAYLOAD.links, + { + channel: LinkChannel.NAVER_BOOKING, + url: 'https://m.booking.naver.com/booking/6/bizes/1067685', + title: undefined, + confirmed: true, + }, + ], + }; + + it('네이버 예약이 플레이스보다 먼저 나온다 — 플레이스는 한 번 더 눌러야 한다', () => { + const links = stayBookingView(withBooking)!.links; + expect(links[0].channel).toBe(LinkChannel.NAVER_BOOKING); + expect(links.map((l) => l.channel)).toContain(LinkChannel.NAVER_PLACE); + }); + + it('버튼 문구가 "네이버 예약에서 예약" 이 되지 않는다', () => { + const text = visibleText(html(withBooking)); + expect(text).toContain('네이버 예약으로 바로 예약하기'); + expect(text).not.toContain('네이버 예약에서 예약'); + }); + + it('예약 액션과 오퍼 URL 이 예약 화면을 가리킨다 — 화면 버튼과 같은 곳이다', () => { + const [business] = collectJsonLd(withBooking, PAGE); + const action = business.potentialAction as {target: {urlTemplate: string}}; + expect(action.target.urlTemplate).toBe('https://m.booking.naver.com/booking/6/bizes/1067685'); + for (const offer of business.makesOffer as {url?: string}[]) { + expect(offer.url).toBe('https://m.booking.naver.com/booking/6/bizes/1067685'); + } + }); + + it('검색 결과 주소는 예약 버튼이 되지 않는다 — 눌러도 검색 화면이다', () => { + const searchOnly: SitePayload = { + ...MOONLIGHT_STAY_PAYLOAD, + links: [ + { + channel: LinkChannel.NAVER_PLACE, + url: 'https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4?c=15.00,0,0,0,dh', + title: '네이버 플레이스', + confirmed: true, + }, + ], + }; + expect(stayBookingView(searchOnly)!.links).toHaveLength(0); + // 창구가 전화뿐이라는 사실을 말해 준다 — 버튼만 조용히 사라지면 안 된다. + expect(visibleText(html(searchOnly))).toContain('온라인 예약 채널은 등록되지 않았습니다'); + }); +}); + describe('llms.txt — AI 가 예약 경로를 읽는 자리', () => { it('재고와 결제를 우리가 갖지 않는다고 명시한다', () => { expect(renderLlmsTxt(MOONLIGHT_STAY_PAYLOAD)).toContain( diff --git a/solution/site/src/seo/jsonld.ts b/solution/site/src/seo/jsonld.ts index 0174843..e75f2cc 100644 --- a/solution/site/src/seo/jsonld.ts +++ b/solution/site/src/seo/jsonld.ts @@ -272,14 +272,29 @@ function stayOffers(payload: SitePayload): Json[] { * derive 가 이걸 쓴다. */ export const BOOKING_CHANNELS: readonly LinkChannel[] = [ + // ★ 순서가 곧 우선순위다. 예약 화면으로 **바로 가는** 채널이 앞이다. + // 네이버 예약(m.booking.naver.com)은 눌렀을 때 예약 화면 그 자체가 뜨고, + // 네이버 플레이스는 잘해야 가게 홈이라 예약을 한 번 더 눌러야 한다. + // 실측(2026-09-08): 자동 발견이 물어온 플레이스 URL 이 검색 결과 주소였던 사장님은 + // "예약" 을 눌렀는데 검색 화면을 봤다. 예약하러 온 손님은 거기서 끝난다. + LinkChannel.NAVER_BOOKING, LinkChannel.YANOLJA, LinkChannel.GOODCHOICE, LinkChannel.NAVER_PLACE, ]; +/** + * 예약 화면으로 보낼 URL 하나. **가장 앞선 채널**을 고른다(BOOKING_CHANNELS 순서). + * + * 화면의 예약 버튼과 `makesOffer.url`·`potentialAction` 이 같은 함수를 쓰므로, + * 구조화 데이터가 가리키는 곳과 손님이 눌러서 가는 곳이 어긋날 수 없다. + */ function bookingChannelUrl(payload: SitePayload): string | undefined { - return payload.links.find((link) => link.confirmed && BOOKING_CHANNELS.includes(link.channel)) - ?.url; + for (const channel of BOOKING_CHANNELS) { + const hit = payload.links.find((link) => link.confirmed && link.channel === channel); + if (hit) return hit.url; + } + return undefined; } /**