o2o-site-AEO/solution/site/src/seo/jsonld.ts
민헌 8a09af6599 [feat] solution,postgres-init: FAQ 를 20개까지 채운다 — 펜션 공통 질문 30개 + 문의 안내
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
2026-09-14 17:05:43 +09:00

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