o2o-site-AEO/solution/frontend/src/features/publish/publishGate.ts
Mina Choi c85c577349 이름: solution/front → solution/frontend
`backend` 옆에 `front` 가 있을 이유가 없었다. negosium 의 negodata/front 를 그대로
베꼈고 그게 왜 front 인지는 따져보지 않았다 — 근거 없이 들여온 이름이라 바로잡는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:27:16 +09:00

159 lines
7.1 KiB
TypeScript

import type {InfoField, PhotoItem, SectionItem} from '@o2o/shared';
import {PublishRejectReason} from '@o2o/shared';
/**
* 발행 검수 게이트 — 클라이언트 쪽 사전 점검.
*
* ★ 진짜 게이트는 백엔드에 있다(`publish_logs.reject_reason`). 여기서 막는 건
* "발행 버튼을 눌렀다가 서버에 거절당하는" 왕복을 없애기 위한 것이고,
* 판정 기준은 백엔드의 PublishRejectReason 과 1:1 로 맞춘다.
* 기준이 갈리면 화면은 통과라는데 서버는 막는 상황이 생긴다 — 그게 제일 나쁘다.
*/
/** 이보다 짧으면 강한 경고를 내지만 발행은 막지 않는다. */
const MIN_UNIQUE_CHARS = 40;
/** 막지는 않지만, 이 아래면 인용 경쟁에서 밀린다. */
const HEALTHY_UNIQUE_CHARS = 120;
export type GateSeverity = 'block' | 'warn';
export interface GateFinding {
id: string;
severity: GateSeverity;
/** 백엔드 거절 사유와 대응되는 것만 채운다. SEO 권고 항목은 비어 있다. */
reason?: (typeof PublishRejectReason)[keyof typeof PublishRejectReason];
title: string;
detail: string;
/** 사장님이 어디를 고쳐야 하는지. */
fix?: string;
}
export interface GateInput {
storeName: string;
location: string;
infoFields: InfoField[];
photos: PhotoItem[];
sections: SectionItem[];
/** 고유 콘텐츠(소개문 등) 문단. 업종 시드의 introText 가 기본값. */
uniqueContent: string[];
}
export interface GateResult {
findings: GateFinding[];
blockers: GateFinding[];
warnings: GateFinding[];
canPublish: boolean;
}
/** 발행을 막는 항목이 하나라도 있으면 발행 불가. */
export function runPublishGate(input: GateInput): GateResult {
const findings: GateFinding[] = [];
// ── 확인 전 fact ────────────────────────────────────────
// ★ 발행을 막지 않고, 화면에 알리지도 않는다.
// 확인 전 값은 그냥 **사이트에 안 나갈 뿐**이다 — 사장님이 발행 전에 고쳐야 할 것이 아니다.
// "발행을 막는 항목"으로 띄우면 실제로 막히지도 않는 일로 발행을 겁먹게 만든다.
// (미검증 값이 새어 나가는 것은 백엔드가 payload 를 쓰기 전에 이미 걸러낸다.)
// ── 업종 스키마 required 누락 ───────────────────────────
// ★ 막지 않는다(2026-08-27 백엔드 결정과 같은 기준).
// 막아야 할 것은 "틀린 정보가 나가는 것"이지 "정보가 덜 찬 것"이 아니다.
// 영업시간이 비어 있어도 주소·전화가 있는 페이지는 그 자체로 쓸모가 있고,
// 사장님은 발행 뒤에 언제든 채울 수 있다(채우면 재빌드된다).
const emptyRequired = input.infoFields.filter((f) => !f.value?.trim());
if (emptyRequired.length > 0) {
findings.push({
id: 'required-missing',
severity: 'warn',
title: `아직 비어 있는 항목 ${emptyRequired.length}건`,
detail: emptyRequired.map((f) => f.label).join(' · '),
fix: '오른쪽 [정보] 탭에서 채우면 검색·AI 답변에 함께 쓰입니다.',
});
}
// ── 고유 콘텐츠 ─────────────────────────────────────────
// OTA 에 있는 문장만 있는 페이지는 AI 검색이 "출처가 여기일 이유"를 못 찾는다.
//
// 콘텐츠 품질은 권고 사항이다. 소개가 짧아도 사장님이 원하면 먼저 발행할 수 있어야 한다.
const uniqueChars = input.uniqueContent.join('').replace(/\s/g, '').length;
if (uniqueChars < MIN_UNIQUE_CHARS) {
findings.push({
id: 'no-unique-content',
severity: 'warn',
title: '이 가게만의 문장이 없습니다',
detail: `고유 소개 문장이 ${uniqueChars}자입니다. 다른 곳에도 있는 문장만으로는 AI 검색이 이 홈페이지를 근거로 삼을 이유가 없습니다.`,
fix: '소개 섹션에 이 가게에만 해당하는 문장을 채워주세요.',
});
} else if (uniqueChars < HEALTHY_UNIQUE_CHARS) {
findings.push({
id: 'thin-unique-content',
severity: 'warn',
title: '이 가게만의 문장이 짧습니다',
detail: `고유 소개 문장이 ${uniqueChars}자입니다. 인용할 문장이 적으면 AI 답변에서 OTA 설명이 우선 인용됩니다.`,
fix: `소개 섹션을 ${HEALTHY_UNIQUE_CHARS}자 이상으로 늘리면 인용될 확률이 올라갑니다.`,
});
}
// ── 이하 권고(경고) — 막지는 않지만 노출에 직접 영향 ────
if (!input.storeName.trim()) {
findings.push({
id: 'no-store-name',
severity: 'block',
title: '상호명이 비어 있습니다',
detail: '상호명은 제목·구조화 데이터·주소 슬러그의 기준값입니다.',
fix: '2단계에서 상호명을 입력해 주세요.',
});
}
const primaryPhoto = input.photos.find((p) => p.isPrimary) ?? input.photos[0];
if (!primaryPhoto) {
findings.push({
id: 'no-primary-photo',
severity: 'warn',
title: '대표 사진이 없습니다',
detail: '대표 사진은 공유 카드(og:image)와 구조화 데이터의 대표 이미지로 쓰입니다.',
fix: '오른쪽 [사진] 탭에서 대표로 쓸 사진을 클릭해 주세요.',
});
}
const photosWithoutAlt = input.photos.filter((p) => !p.title?.trim());
if (photosWithoutAlt.length > 0) {
findings.push({
id: 'photo-alt-missing',
severity: 'warn',
title: `대체 텍스트가 없는 사진 ${photosWithoutAlt.length}장`,
detail: '대체 텍스트가 없는 이미지는 검색·AI가 내용을 알 수 없고, 화면 낭독기도 건너뜁니다.',
fix: '사진 설명을 채우거나, 비워둘 사진은 발행에서 빼주세요.',
});
}
const faqSection = input.sections.find((s) => s.id === 'faq');
if (faqSection && !faqSection.isEnabled) {
findings.push({
id: 'faq-disabled',
severity: 'warn',
title: 'FAQ 섹션이 꺼져 있습니다',
detail:
'FAQ 는 AI 검색이 가장 잘 인용하는 형식입니다(FAQPage 구조화 데이터). 켜두면 "체크인 몇 시예요?" 같은 질문에 이 홈페이지가 답으로 잡힙니다.',
fix: '왼쪽 섹션 목록에서 FAQ 를 켜주세요.',
});
}
const localSection = input.sections.find((s) => s.id === 'local');
if (localSection && !localSection.isEnabled) {
findings.push({
id: 'local-disabled',
severity: 'warn',
title: '지역 정보 섹션이 꺼져 있습니다',
detail:
'"○○ 근처 숙소" 같은 지역 질의는 주변 정보가 있는 페이지가 먼저 잡힙니다.',
fix: '왼쪽 섹션 목록에서 지역 정보를 켜주세요.',
});
}
const blockers = findings.filter((f) => f.severity === 'block');
const warnings = findings.filter((f) => f.severity === 'warn');
return {findings, blockers, warnings, canPublish: blockers.length === 0};
}