import { LinkChannel, PlaceCategory, displayFactLabel, factBool, factValue, joinUrl, sanitizeUnits, selectAnsweredFaqs, selectPublishable, type ChannelLink, type FactEntry, type SitePayload, type UnitInfo, } from '@o2o/shared'; /** * 구조화 데이터(JSON-LD). * * 이 프로젝트의 목표 문장 그대로 — "AI 검색이 이 가게를 공식 홈페이지 기준으로 설명하게 만드는 것". * 그 설명의 재료가 여기서 나간다. * * ★ 두 가지를 절대 어기지 않는다. * 1) 화면에 없는 값을 JSON-LD 에 넣지 않는다. 어긋나면 구조화 데이터 스팸으로 취급되고, * 백엔드 발행 게이트도 JSONLD_MISMATCH 로 막는다. * 2) 확인되지 않은 fact 는 넣지 않는다. 전부 selectPublishable() 을 통과한 값만 쓴다. * "값이 없다"와 "확인 전이라 가렸다"를 구분하지 않고, 둘 다 그냥 내보내지 않는다. */ /** 업종 → Schema.org 최상위 타입. 업종이 늘면 여기 한 줄만 는다. */ export const SCHEMA_TYPE: Record = { [PlaceCategory.LODGING]: 'LodgingBusiness', [PlaceCategory.CAFE]: 'CafeOrCoffeeShop', [PlaceCategory.RESTAURANT]: 'Restaurant', [PlaceCategory.CLINIC]: 'MedicalClinic', }; /** 업종 → 하위 단위의 Schema.org 타입과 URL 경로. */ export const UNIT_SPEC: Record = { [PlaceCategory.LODGING]: {type: 'HotelRoom', path: 'rooms', label: '객실'}, [PlaceCategory.CAFE]: {type: 'MenuItem', path: 'menu', label: '메뉴'}, [PlaceCategory.RESTAURANT]: {type: 'MenuItem', path: 'menu', label: '메뉴'}, [PlaceCategory.CLINIC]: {type: 'Product', path: 'programs', label: '시술'}, }; export type Json = Record; /** null·undefined·빈 문자열·빈 배열 키를 통째로 뺀다. 빈 값이 실린 JSON-LD 는 감점이다. */ function compact(input: T): T { const out: Json = {}; for (const [key, value] of Object.entries(input)) { if (value == null || value === '') continue; if (Array.isArray(value) && value.length === 0) continue; out[key] = value; } return out as T; } function siteUrl(payload: SitePayload, ...parts: string[]): string { return joinUrl(payload.site.origin, payload.site.basePath, ...parts); } /** * amenityFeature — bool 타입 fact 를 시설 목록으로 옮긴다. * 확인된 것만 들어가고, false 도 그대로 실린다("반려동물 불가"는 중요한 답이다). */ function amenityFeatures(facts: FactEntry[]): Json[] { return selectPublishable(facts) .filter((fact) => fact.type === 'bool') .map((fact) => compact({ '@type': 'LocationFeatureSpecification', // ★ 화면과 **같은** 이름표를 쓴다. 화면은 "주차 | 가능" 인데 여기만 "주차 가능" 이면 // 발행 게이트가 "구조화 데이터가 화면 값과 다르다"로 막는다 — 실제로 막혔다. name: displayFactLabel(fact), value: factBool(facts, fact.key) ?? undefined, }), ); } function postalAddress(payload: SitePayload): Json | undefined { const {place} = payload; const street = place.roadAddress ?? place.address; if (!street) return undefined; return compact({ '@type': 'PostalAddress', streetAddress: street, addressLocality: place.addressLocality, addressRegion: place.addressRegion, postalCode: place.postalCode, addressCountry: 'KR', }); } function geoCoordinates(payload: SitePayload): Json | undefined { const {latitude, longitude} = payload.place; if (latitude == null || longitude == null) return undefined; return {'@type': 'GeoCoordinates', latitude, longitude}; } /** * 대표 이미지 우선, 대체 텍스트가 있는 것만. alt 없는 이미지는 애초에 렌더도 안 한다. * * ★ 우리 자산으로 미러한 사진은 payload 에 **루트 절대경로**로 들어 있다 * (`/assets/mirror/…`, scripts/mirror-payload-images.mjs). 그대로 내보내면 * 구조화 데이터를 읽는 쪽이 어느 호스트인지 모른다 — Schema.org 의 `image` 는 * 가져갈 수 있는 주소여야 하므로 오리진을 붙인다. */ function imageUrls(payload: SitePayload, limit = 6): string[] { return payload.media .filter((m) => m.alt?.trim()) .sort((a, b) => Number(Boolean(b.isPrimary)) - Number(Boolean(a.isPrimary))) .slice(0, limit) .map((m) => (m.url.startsWith('/') ? joinUrl(payload.site.origin, m.url) : m.url)); } /** * 손님이 눌러서 열 수 있는 확정 채널 — **화면과 `sameAs` 가 같은 목록을 쓴다.** * * ★ `http(s)` 가 아닌 것을 뺀다 (실측 2026-09-10, 그래비티 조선) * 수집이 TourAPI 콘텐츠를 `tour://32/2819450` 이라는 **내부 식별자**로 채널에 적어 둔다 * (`collect_service.py`). 이건 주소가 아니라 "이 업장은 관광공사 콘텐츠 2819450 이다"라는 * 메모라, 브라우저가 열 수 없고 schema.org `sameAs`(공개 프로필 주소)에도 해당하지 않는다. * 그런데 확정 링크를 통째로 실어서 JSON-LD 에는 나가고 화면에는 못 그려졌다 — * **화면과 구조화 데이터가 다르다**로 발행 게이트가 막혔다(절대규칙 3). * 게이트를 느슨하게 푸는 게 아니라 **애초에 안 내보내는 것**이 맞다. 게이트는 옳았다. * ★ 이 판단을 한 곳에 둔다. 화면(SiteFooter)과 sameAs(seo/jsonld)가 각자 거르면 * 한쪽만 고쳤을 때 같은 종류로 또 막힌다. */ export function publicLinks(payload: SitePayload): ChannelLink[] { return payload.links.filter((link) => link.confirmed && /^https?:\/\//i.test(link.url ?? '')); } /** * 확정된 채널만 sameAs 로 나간다 — 확정 전 URL 은 동명 업소일 수 있다. * ★ 목록은 화면(SiteFooter)과 **같은 함수**에서 온다(`publicLinks`) — 갈리면 게이트가 막는다. */ function sameAs(payload: SitePayload): string[] { return publicLinks(payload).map((link) => link.url); } /** 숙박의 체크인·체크아웃은 Schema.org 에 전용 속성이 있다. 있으면 반드시 채운다. */ function lodgingExtras(payload: SitePayload): Json { const {facts} = payload; return { checkinTime: factValue(facts, 'check_in_time'), checkoutTime: factValue(facts, 'check_out_time'), petsAllowed: factBool(facts, 'pet_allowed'), numberOfRooms: payload.units.length || undefined, smokingAllowed: factBool(facts, 'smoking'), }; } function categoryExtras(payload: SitePayload): Json { if (payload.place.category === PlaceCategory.LODGING) return lodgingExtras(payload); const hours = factValue(payload.facts, 'business_hours') ?? factValue(payload.facts, 'open_hours'); return { openingHours: hours, servesCuisine: factValue(payload.facts, 'cuisine'), acceptsReservations: factBool(payload.facts, 'reservation'), }; } /** 하위 단위(객실·메뉴·프로그램). 확인된 fact 만 스펙으로 붙는다. */ function unitNodes(payload: SitePayload): Json[] { const spec = UNIT_SPEC[payload.place.category]; const mediaById = new Map(payload.media.map((m) => [m.mediaId, m])); return sanitizeUnits(payload.units).map((unit) => compact({ '@type': spec.type, '@id': `${siteUrl(payload, spec.path, unit.slug)}#unit`, name: unit.name, url: siteUrl(payload, spec.path, unit.slug), description: factValue(unit.facts, 'room_intro') ?? factValue(unit.facts, 'description'), occupancy: occupancy(unit.facts), bed: factValue(unit.facts, 'bed_type'), floorSize: floorSize(unit.facts), amenityFeature: amenityFeatures(unit.facts), image: unit.mediaIds .map((id) => mediaById.get(id)) .filter((m) => m?.alt?.trim()) .map((m) => m!.url), }), ); } function occupancy(facts: FactEntry[]): Json | undefined { const max = factValue(facts, 'max_capacity'); if (!max) return undefined; return compact({ '@type': 'QuantitativeValue', value: Number(max) || undefined, minValue: Number(factValue(facts, 'standard_capacity')) || undefined, unitText: '명', }); } function floorSize(facts: FactEntry[]): Json | undefined { const size = factValue(facts, 'room_size'); if (!size) return undefined; return {'@type': 'QuantitativeValue', value: Number(size) || size, unitCode: 'MTK'}; } /** 사업장 본체. 모든 페이지에 같은 @id 로 실린다 — AI 가 페이지들을 한 업소로 묶는 열쇠다. */ export function businessJsonLd(payload: SitePayload): Json { const {place, narrative} = payload; const spec = UNIT_SPEC[place.category]; return compact({ '@context': 'https://schema.org', // ★ 특화 타입 하나만 쓰면 `LodgingBusiness` 를 LocalBusiness 로 못 펴는 파서가 있다. // 상위 타입을 같이 적어야 "이 페이지에 사업장 엔티티가 있다"가 문자열 대조로도 잡힌다. // (schema.org 상 중복이지 모순이 아니다 — LodgingBusiness ⊂ LocalBusiness ⊂ Organization) '@type': [SCHEMA_TYPE[place.category] ?? 'LocalBusiness', 'LocalBusiness', 'Organization'], '@id': `${siteUrl(payload)}#business`, name: place.name, alternateName: place.englishName, url: siteUrl(payload), description: narrative.summary ?? narrative.about[0], telephone: place.phone, email: place.email, address: postalAddress(payload), geo: geoCoordinates(payload), image: imageUrls(payload), priceRange: priceRange(payload), 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[] = [ // ★ 순서가 곧 우선순위다. 예약 화면으로 **바로 가는** 채널이 앞이다. // 네이버 예약(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 { for (const channel of BOOKING_CHANNELS) { const hit = payload.links.find((link) => link.confirmed && link.channel === channel); if (hit) return hit.url; } return undefined; } /** * 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) .flatMap((unit) => ['weekday_price', 'weekend_price', 'peak_price', 'price'].map((key) => Number(factValue(unit.facts, key)), ), ) .filter((n) => Number.isFinite(n) && n > 0); if (prices.length === 0) return undefined; const min = Math.min(...prices); const max = Math.max(...prices); const fmt = (n: number) => n.toLocaleString('ko-KR'); return min === max ? `${fmt(min)}원` : `${fmt(min)}원 ~ ${fmt(max)}원`; } /** * FAQPage — AEO 에서 가장 크게 먹히는 마크업. * "체크인 몇 시예요?" 같은 질문에 이 홈페이지가 답으로 잡히는 자리다. * 확인된 FAQ 가 하나도 없으면 아예 내보내지 않는다(빈 FAQPage 는 감점). * ★ 문의 안내(TEMPLATE)는 싣지 않는다 — 답이 없는 문답이다(shared selectAnsweredFaqs). */ export function faqJsonLd(payload: SitePayload): Json | null { const faqs = selectAnsweredFaqs(payload.faqs); if (faqs.length === 0) return null; return { '@context': 'https://schema.org', '@type': 'FAQPage', '@id': `${siteUrl(payload, 'faq')}#faq`, mainEntity: faqs.map((faq) => ({ '@type': 'Question', name: faq.question, acceptedAnswer: {'@type': 'Answer', text: faq.answer}, })), }; } export function websiteJsonLd(payload: SitePayload): Json { return compact({ '@context': 'https://schema.org', '@type': 'WebSite', '@id': `${siteUrl(payload)}#website`, name: payload.place.name, url: siteUrl(payload), inLanguage: 'ko-KR', publisher: {'@id': `${siteUrl(payload)}#business`}, }); } /** 이 페이지가 언제 기준인지. AI 검색은 신선도를 본다 — 없으면 오래된 페이지로 취급된다. */ export function webPageJsonLd( payload: SitePayload, page: {title: string; description: string}, ): Json { return compact({ '@context': 'https://schema.org', '@type': 'WebPage', '@id': `${siteUrl(payload)}#webpage`, url: siteUrl(payload), name: page.title, description: page.description, inLanguage: 'ko-KR', isPartOf: {'@id': `${siteUrl(payload)}#website`}, about: {'@id': `${siteUrl(payload)}#business`}, // ★ "누가 썼나" 가 비어 있으면 E-E-A-T 채점에서 통째로 0점이다. 이 페이지의 모든 문장은 // 사업자가 확인한 값이므로 저자·발행자는 사업장 자신이다 — 사람 이름을 지어내지 않는다. author: {'@id': `${siteUrl(payload)}#business`}, publisher: {'@id': `${siteUrl(payload)}#business`}, datePublished: payload.site.publishedAt, dateModified: payload.site.updatedAt, // 음성 답변이 읽어 갈 자리. 상호가 든 h1 과 "예약 전 확인" 단정문 문단이다. speakable: { '@type': 'SpeakableSpecification', cssSelector: ['h1', '#summary p'], }, }); } /** * 빵부스러기. * * ★ 예전에는 "한 장짜리 사이트라 자기 자신뿐"이라며 뺐다. 하지만 이 사이트는 오리진 루트 * 아래 `/s/` 에 있어서 **상위가 실제로 존재한다** — 홈 → 이 업소. 두 칸짜리라도 * 크롤러에게 "이 URL 이 이 호스트의 어디에 속하는지"를 알려 주는 값이 있다. */ export function breadcrumbJsonLd(payload: SitePayload): Json { const origin = payload.site.origin.replace(/\/+$/, ''); return { '@context': 'https://schema.org', '@type': 'BreadcrumbList', '@id': `${siteUrl(payload)}#breadcrumb`, itemListElement: [ {'@type': 'ListItem', position: 1, name: '홈', item: `${origin}/`}, {'@type': 'ListItem', position: 2, name: payload.place.name, item: siteUrl(payload)}, ], }; } /** 한 페이지에 실릴 JSON-LD 전부. null 은 걸러진다. */ /** * 이 사이트가 내보내는 구조화 데이터 전부. * * ★ FAQPage 는 조건 없이 싣는다. 예전에는 페이지마다 실을지 골랐는데, 이제 한 장이라 * FAQ 가 있으면 그 한 장에 있는 것이다. */ export function collectJsonLd( payload: SitePayload, page: {title: string; description: string}, ): Json[] { return [ businessJsonLd(payload), websiteJsonLd(payload), webPageJsonLd(payload, page), breadcrumbJsonLd(payload), payload.faqs.length > 0 ? faqJsonLd(payload) : null, ].filter((node): node is Json => node !== null); }