[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:
hbyang 2026-09-08 10:52:36 +09:00
parent 55ee968f9d
commit 66f81f3631
14 changed files with 235 additions and 9 deletions

View File

@ -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` 한 다음 발행을 걸어 봤다.

View File

@ -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(직접)

View File

@ -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

View File

@ -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(

View File

@ -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():

View File

@ -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 를 뽑는다.

View File

@ -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: '여기어때'};

View File

@ -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]: '블로그',

View File

@ -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];

View File

@ -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);
}
/**

View File

@ -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>
))}

View File

@ -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>
))}

View File

@ -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(

View File

@ -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;
}
/**