[feat] solution/backend,site: 예약 버튼을 네이버 예약 화면으로 — 검색 화면이 뜨던 것
발행본의 예약 버튼이 네이버 플레이스 링크를 그대로 열었다. 잘해야 가게 홈이라 예약을 한 번 더 눌러야 하고, 자동 발견이 물어온 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). 어댑터는 실제 네이버 응답으로 확인.
This commit is contained in:
parent
55ee968f9d
commit
66f81f3631
@ -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` 한 다음 발행을 걸어 봤다.
|
||||
|
||||
@ -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(직접)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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 를 뽑는다.
|
||||
|
||||
|
||||
@ -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: '여기어때'};
|
||||
|
||||
@ -26,6 +26,7 @@ export const CHANNEL_LABEL: Record<number, string> = {
|
||||
[LinkChannel.YANOLJA]: '야놀자',
|
||||
[LinkChannel.GOODCHOICE]: '여기어때',
|
||||
[LinkChannel.NAVER_PLACE]: '네이버 플레이스',
|
||||
[LinkChannel.NAVER_BOOKING]: '네이버 예약',
|
||||
[LinkChannel.INSTAGRAM]: '인스타그램',
|
||||
[LinkChannel.OFFICIAL_SITE]: '공식 홈페이지',
|
||||
[LinkChannel.BLOG]: '블로그',
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -272,6 +272,7 @@ export function exhibitionRows(payload: SitePayload): InfoRow[] {
|
||||
|
||||
/** 채널 코드 → 사람이 읽는 이름. link.title 이 있으면 그쪽이 우선이다. */
|
||||
export const CHANNEL_LABEL: Record<number, string> = {
|
||||
[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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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)'}}
|
||||
>
|
||||
<span>{channelLabel(link)}</span>
|
||||
<span>{bookingCtaLabel(link)}</span>
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
</a>
|
||||
))}
|
||||
|
||||
@ -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)'}}
|
||||
>
|
||||
<span>{`${channelLabel(link)}에서 예약`}</span>
|
||||
<span>{bookingCtaLabel(link)}</span>
|
||||
<ArrowUpRight className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
))}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user