COPY 잡은 확인된 fact 로만 FAQ 를 써서 4~8개에서 끝났다(실측 로컬: 스테이머뭄 fact 8건, 산하연 풀빌라 fact 4건 · FAQ 4건). fact 가 0건이면 start_copy 가 FAQ_UNGROUNDED 로 잡을 만들지 않아 0개였다. 생성 상한을 20으로 올리고, 모자라면 펜션 카탈로그에서 겹치지 않는 질문을 **문의 안내** 답으로 채운다. 공통 답에 값·가능 여부를 적으면 업종 시드 FAQ 가 가공의 가격을 사이트에 내보낸 사고와 같다 — 답은 "…은 전화(…)로 문의해 주시면 안내해 드립니다" 뿐이고, 그래서 화면에만 나간다. - common/faq_catalog(신규): 로더 + resources/pension.json 30문항. fact_keys 가 업종 스키마에 없으면 로드 시 예외 - services/faq_fill.py(신규): 고르기 규칙 — fact 로 답할 수 있는 질문 · 기존 FAQ 와 근거 key 또는 질문 키워드가 겹치는 질문은 건너뛴다(LLM 은 "주차 및 와이파이" 처럼 묶어 쓰고, 사장님 입력은 근거 key 가 없다) - copy_service: max_faqs=20, 생성 뒤 _fill_faqs. 근거가 없거나 키가 없으면 LLM 없이 채우기만 - place_service.start_copy: 카탈로그가 있으면 fact 0건이어도 잡 생성(FAQ_UNGROUNDED 는 카탈로그 없는 업종만) - SourceType.TEMPLATE=5(백엔드·shared·orval 모델). fact_service 규칙 4 로 fact 에는 못 쓴다 - faq_crud.expire_generated: TEMPLATE 도 재생성 때 내린다 — 새 fact 로 답이 생긴 주제에 옛 문의 안내가 남지 않게 - prompts/copy: fact 로 답할 수 있는 카탈로그 질문을 싣고 "한 문항 한 주제" 규칙(생성 FAQ 4건 중 3건이 묶여 있었다) - shared selectAnsweredFaqs · jsonld · llms · prerender(↔ conftest) · seo_audit: 문의 안내는 FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수 · FAQ 점수에서 뺀다 — 모든 펜션에 같은 문구라 세면 빈 사이트가 게이트를 통과한다 - site FaqSection: 문의 안내가 섞이면 "모두 사업자가 확인한 내용" 문구를 달지 않는다 - frontend FaqPanel "노출 N건 (문의 안내 M)" · notifyCopy 가 faq_fill 을 본다 - postgres-init: 컬럼 변경 없음(CHECK 없는 SMALLINT). 0012 + init.sql 에 generated_by·source_fact_ids COMMENT ON, 0012 는 컬럼이 있을 때만(DO $$ IF EXISTS). init.sql 의 "비면 발행 게이트가 반려" 주석은 사실이 아니어서 고쳤다 - docs/DECISIONS.md 8절 · DATA_MODEL.md · DEVLOG.md 백엔드 664 passed(신규 test_faq_fill 10건 · test_copy_api 3건). 실패 2건은 이 변경 전 HEAD 에서도 같다: test_rate_limit_closes_the_tap · test_사이트_디렉터리_밖의_thumbs_에_올린다 site·frontend·admin tsc 통과 · site vitest 63 passed · FaqPanel·collectNotify eslint 통과 로컬 실사업장(하늘물빛정원, fact 4건): 생성 4건 + 문의 안내 16건 = 20건, 질문 중복 0 0012: 새 DB(init.sql → migrate 규칙)와 로컬 DB 사본 양쪽에서 두 번씩 적용 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011yLDuinzgyCxmqAutE1tse
471 lines
20 KiB
TypeScript
471 lines
20 KiB
TypeScript
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, string> = {
|
|
[PlaceCategory.LODGING]: 'LodgingBusiness',
|
|
[PlaceCategory.CAFE]: 'CafeOrCoffeeShop',
|
|
[PlaceCategory.RESTAURANT]: 'Restaurant',
|
|
[PlaceCategory.CLINIC]: 'MedicalClinic',
|
|
};
|
|
|
|
/** 업종 → 하위 단위의 Schema.org 타입과 URL 경로. */
|
|
export const UNIT_SPEC: Record<PlaceCategory, {type: string; path: string; label: string}> = {
|
|
[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<string, unknown>;
|
|
|
|
/** null·undefined·빈 문자열·빈 배열 키를 통째로 뺀다. 빈 값이 실린 JSON-LD 는 감점이다. */
|
|
function compact<T extends Json>(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/<slug>` 에 있어서 **상위가 실제로 존재한다** — 홈 → 이 업소. 두 칸짜리라도
|
|
* 크롤러에게 "이 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);
|
|
}
|