o2o-site-AEO/solution/shared/src/lib/facts.ts

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