`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
159 lines
7.1 KiB
TypeScript
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};
|
|
}
|