152 lines
7.1 KiB
TypeScript
152 lines
7.1 KiB
TypeScript
import {
|
|
isPublishableFact,
|
|
SourceType,
|
|
type FactEntry,
|
|
type FaqEntry,
|
|
type SitePayload,
|
|
type UnitInfo,
|
|
} from '../types';
|
|
|
|
/**
|
|
* ★ 절대규칙 1 의 실행부.
|
|
*
|
|
* 발행 게이트는 백엔드에 있지만, 렌더러도 같은 필터를 한 번 더 건다.
|
|
* 이유: 게이트를 통과한 payload 라도 캐시된 옛 버전이거나 손으로 만든 fixture 일 수 있다.
|
|
* 미검증 값이 화면 아무 데도, JSON-LD 에도 안 나가는 것이 이 함수 하나에 걸려 있다.
|
|
*/
|
|
export function selectPublishable(facts: FactEntry[]): FactEntry[] {
|
|
return facts.filter((f) => isPublishableFact(f.status) && f.value != null && f.value !== '');
|
|
}
|
|
|
|
/** key → fact 로 조회. 노출 가능한 것만 담긴다. */
|
|
export function factMap(facts: FactEntry[]): Map<string, FactEntry> {
|
|
const map = new Map<string, FactEntry>();
|
|
for (const fact of selectPublishable(facts)) map.set(fact.key, fact);
|
|
return map;
|
|
}
|
|
|
|
/** 값 문자열. 노출 불가면 undefined — 호출부가 "빈 값"과 "가려진 값"을 구분하지 않아도 되게 한다. */
|
|
export function factValue(facts: FactEntry[], key: string): string | undefined {
|
|
const fact = factMap(facts).get(key);
|
|
return fact?.value ?? undefined;
|
|
}
|
|
|
|
/**
|
|
* 값 + 단위("4대", "76㎡", "280,000원").
|
|
*
|
|
* number 타입은 천 단위 구분을 넣는다 — "280000원"은 사람이 자릿수를 세야 하고,
|
|
* LLM 이 인용할 때도 그대로 읽혀서 답변 품질이 떨어진다.
|
|
*/
|
|
export function factText(facts: FactEntry[], key: string): string | undefined {
|
|
const fact = factMap(facts).get(key);
|
|
if (!fact?.value) return undefined;
|
|
const value = fact.type === 'number' ? formatNumeric(fact.value) : fact.value;
|
|
return fact.unit ? `${value}${fact.unit}` : value;
|
|
}
|
|
|
|
function formatNumeric(raw: string): string {
|
|
const num = Number(raw.replace(/,/g, ''));
|
|
return Number.isFinite(num) ? num.toLocaleString('ko-KR') : raw;
|
|
}
|
|
|
|
/** bool 타입 fact. 미검증이면 undefined — false 로 떨어뜨리지 않는다(가려진 것과 "아니오"는 다르다). */
|
|
/**
|
|
* 표에 쓸 fact 이름표.
|
|
*
|
|
* ★ "주차 가능 | 가능" 을 없앤다 (2026-09-04, 사장님 지적)
|
|
* 이름표가 이미 '가능' 으로 끝나는데 값도 '가능' 이라 같은 말이 두 번 섰다.
|
|
* 꼬리를 떼면 "주차 | 가능" 이 된다 — 표는 원래 그렇게 읽는다.
|
|
* ★ bool 일 때만 뗀다. '바비큐 이용료' 처럼 값이 숫자인 칸은 이름이 곧 뜻이다.
|
|
* ★ **shared 에 두는 이유**: 화면(derive)과 구조화 데이터(seo/jsonld)가 같은 이름표를 써야 한다.
|
|
* 갈리면 발행 게이트가 "구조화 데이터가 화면 값과 다르다"로 막는다 — 실제로 막혔다.
|
|
* 렌더러 쪽 한 곳에 두면 jsonld → derive → jsonld 순환 참조가 된다.
|
|
*/
|
|
export function displayFactLabel(fact: FactEntry): string {
|
|
if (fact.type !== 'bool') return fact.label;
|
|
return fact.label.replace(/\s*(가능|여부)$/, '') || fact.label;
|
|
}
|
|
|
|
export function factBool(facts: FactEntry[], key: string): boolean | undefined {
|
|
const value = factValue(facts, key);
|
|
if (value == null) return undefined;
|
|
return value === 'true' || value === '1' || value === 'Y';
|
|
}
|
|
|
|
/** 발행을 막아야 하는 항목 — critical 인데 노출 불가 상태인 fact. */
|
|
export function blockingFacts(facts: FactEntry[]): FactEntry[] {
|
|
return facts.filter((f) => f.critical && !isPublishableFact(f.status));
|
|
}
|
|
|
|
/** 업종 스키마의 required 인데 값이 없는 fact. */
|
|
export function missingRequiredFacts(facts: FactEntry[]): FactEntry[] {
|
|
return facts.filter(
|
|
(f) => f.required && (!isPublishableFact(f.status) || f.value == null || f.value === ''),
|
|
);
|
|
}
|
|
|
|
/** 노출 가능한 FAQ 만 — 화면에 그리는 목록. 문의 안내(TEMPLATE)도 들어간다. */
|
|
export function selectPublishableFaqs(faqs: FaqEntry[]): FaqEntry[] {
|
|
return faqs
|
|
.filter((f) => isPublishableFact(f.status) && f.question && f.answer)
|
|
.sort((a, b) => a.sortOrder - b.sortOrder);
|
|
}
|
|
|
|
/**
|
|
* 실제로 답하는 FAQ 만 — FAQPage JSON-LD · llms.txt 의 입력.
|
|
*
|
|
* ★ 문의 안내(TEMPLATE)를 뺀다. FAQ 를 20개로 채우려고 붙인 공통 질문이라 답이
|
|
* "숙소로 문의 부탁드립니다" 뿐이다. 구조화 데이터로 내보내면 AI 검색이 인용할 답이 없는
|
|
* 문항이 섞이고, 같은 문구가 모든 펜션 사이트에 반복된다.
|
|
*/
|
|
export function selectAnsweredFaqs(faqs: FaqEntry[]): FaqEntry[] {
|
|
return selectPublishableFaqs(faqs).filter((f) => f.sourceType !== SourceType.TEMPLATE);
|
|
}
|
|
|
|
/** 단위(객실·메뉴) 안의 fact 도 같은 규칙으로 거른 사본을 준다. */
|
|
export function sanitizeUnits(units: UnitInfo[]): UnitInfo[] {
|
|
return units
|
|
.map((unit) => ({ ...unit, facts: selectPublishable(unit.facts) }))
|
|
.sort((a, b) => a.sortOrder - b.sortOrder);
|
|
}
|
|
|
|
/**
|
|
* 발행 산출물에 실을 수 있는 형태로 payload 를 깎는다.
|
|
*
|
|
* ★ 왜 필요한가 — 정적 HTML 은 하이드레이션을 위해 payload 를 통째로 심는다.
|
|
* 화면과 JSON-LD 를 아무리 잘 걸러도, 그 블롭에 미검증 fact 가 남아 있으면
|
|
* 원본 HTML 을 읽는 AI 크롤러는 그걸 그대로 읽는다. 실제로 한 번 그렇게 새어서
|
|
* 이 함수가 생겼다 — 필터를 화면에만 걸면 반쪽이다.
|
|
*
|
|
* 프리렌더는 이 결과를 서버 렌더와 임베드 **양쪽에** 쓴다.
|
|
* 같은 객체를 쓰므로 하이드레이션 불일치도 생기지 않는다.
|
|
*/
|
|
export function sanitizePayloadForPublish(payload: SitePayload): SitePayload {
|
|
return {
|
|
...payload,
|
|
facts: selectPublishable(payload.facts),
|
|
units: sanitizeUnits(payload.units),
|
|
faqs: selectPublishableFaqs(payload.faqs),
|
|
// 확정 전 채널 URL 은 동명 업소일 수 있다. 발행물에 남기지 않는다.
|
|
links: payload.links.filter((link) => link.confirmed),
|
|
// 대체 텍스트 없는 이미지는 어차피 렌더하지 않는다 — 목록에서도 뺀다.
|
|
media: payload.media.filter((item) => item.alt?.trim()),
|
|
// 재생 주소가 없는 곡은 버튼만 있고 소리가 없다. 목록에서 뺀다.
|
|
// 생성 결과를 고유 콘텐츠로 세면 우리 출력으로 발행 게이트를 우회하게 된다.
|
|
// 명시적 허용 필드만 복사해 승인 토큰이 하이드레이션 블롭에 끼어들지 못하게 한다.
|
|
socialPosts: (payload.socialPosts ?? []).filter((post) => post.body?.trim() && Number.isFinite(Date.parse(post.postedAt))).slice(0, 3).map((post) => ({
|
|
postId: post.postId, provider: post.provider, body: post.body, postedAt: post.postedAt,
|
|
permalink: safeSocialPermalink(post.permalink),
|
|
})),
|
|
songs: (payload.songs ?? []).filter((song) => song.audioUrl?.trim()),
|
|
};
|
|
}
|
|
|
|
function safeSocialPermalink(value?: string | null): string | undefined {
|
|
if (!value) return undefined;
|
|
try {
|
|
const url = new URL(value);
|
|
if (url.protocol === 'https:' && ['threads.net', 'www.threads.net', 'threads.com', 'www.threads.com'].includes(url.hostname) && !url.username && !url.password) return url.href;
|
|
} catch { /* 손으로 만든 payload도 공개 링크 경계를 지켜야 한다. */ }
|
|
return undefined;
|
|
}
|