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