Files
AriWeb-Suite/modules/webmaker/lib/brief.js
T

237 lines
28 KiB
JavaScript
Raw Normal View History

'use strict';
// Site Brief: 기획 확정 결과(JSON). 생성 에이전트의 유일한 입력.
// 업종/지역/디자인 카탈로그, 업종 기본값, 규제 제약, 정합성 검사, 결과 검증(결정적 규칙)을 담당한다.
const fs = require('fs');
const path = require('path');
// ---------- 목표 → 기능 ----------
const GOALS = [
{ id: 'inquiry', label: '문의 유도', features: ['contactForm', 'map'] },
{ id: 'booking', label: '예약 접수', features: ['booking', 'map'] },
{ id: 'intro', label: '회사/매장 소개', features: [] },
{ id: 'portfolio', label: '포트폴리오/작업 사례', features: ['gallery'] },
{ id: 'news', label: '소식/공지', features: ['board'] },
{ id: 'sales', label: '상품/서비스 판매 안내', features: ['faq'] },
];
const FEATURES = [
{ id: 'contactForm', label: '문의 폼', section: 'contact' },
{ id: 'survey', label: '설문/참가 신청', section: 'survey' },
{ id: 'members', label: '회원 로그인', section: 'members' },
{ id: 'booking', label: '예약', section: 'booking' },
{ id: 'board', label: '게시판/소식', section: 'news' },
{ id: 'gallery', label: '갤러리', section: 'gallery' },
{ id: 'map', label: '지도/오시는 길', section: 'map' },
{ id: 'reviews', label: '고객 후기', section: 'reviews' },
{ id: 'faq', label: 'FAQ', section: 'faq' },
{ id: 'blog', label: '블로그', section: 'blog' }, // v1.4.0
{ id: 'library', label: '자료실', section: 'library' }, // v1.4.0
{ id: 'calendar', label: '일정관리(캘린더)', section: 'calendar' }, // v1.4.18
];
const SECTIONS = {
hero: { label: '히어로(대표 문구+CTA)' }, about: { label: '소개' }, services: { label: '서비스/진료/과목' }, menu: { label: '메뉴' },
products: { label: '상품/서비스 목록' }, portfolio: { label: '작업 사례' }, gallery: { label: '갤러리', feature: 'gallery' }, team: { label: '팀/구성원' },
reviews: { label: '고객 후기', feature: 'reviews' }, faq: { label: 'FAQ', feature: 'faq' }, survey: { label: '설문/신청 양식', feature: 'survey' }, members: { label: '회원 로그인', feature: 'members' }, pricing: { label: '가격표' }, hours: { label: '영업시간' },
map: { label: '오시는 길/지도', feature: 'map' }, contact: { label: '문의 폼', feature: 'contactForm' }, booking: { label: '예약', feature: 'booking' },
news: { label: '소식/공지', feature: 'board' }, blog: { label: '블로그', feature: 'blog' }, library: { label: '자료실(내려받기)', feature: 'library' }, calendar: { label: '일정표(캘린더)', feature: 'calendar' }, cta: { label: '행동 유도(CTA) 배너' }, credentials: { label: '자격/인증/수상' }, process: { label: '진행 절차' },
};
// ---------- 규제 업종 ----------
const REGULATIONS = {
medical: { label: '의료(의료광고법)', disableFeatures: ['reviews'], constraints: ['치료 효과 보장·완치·부작용 없음 등 단정 표현 금지', '환자 치료 경험담(후기) 게시 금지', '치료 전후 비교 사진 금지', '최고·유일·1위 등 최상급 표현 금지', '가격은 사실정보에 입력된 값만 표시'], patterns: ['완치', '부작용\\s*(이|가)?\\s*없', '100\\s*%', '효과\\s*보장', '치료\\s*보장', '전후\\s*사진', '최고의\\s*(병원|의원|의료진)', '유일한', '1\\s*위', '(환자|고객)\\s*후기'] },
legal: { label: '법률/세무(변호사법 등)', disableFeatures: [], constraints: ['승소·결과 보장 표현 금지', '전문 분야 표기는 등록된 전문분야에 한함', '수임료는 사실정보에 입력된 값만 표시', '타 사무소 비방·비교 금지'], patterns: ['승소\\s*보장', '결과\\s*보장', '100\\s*%', '무조건', '최고의\\s*(변호사|법무|세무)', '1\\s*위'] },
finance: { label: '금융/대부', disableFeatures: [], constraints: ['수익률·원금 보장 표현 금지', '이자율·수수료는 사실정보 값만 표시'], patterns: ['원금\\s*보장', '수익\\s*보장', '확정\\s*수익', '무조건'] },
};
// ---------- 업종 카탈로그 (대분류 → 소분류) + 기본값 ----------
function ind(major, minors, d) { return { major, minors, defaults: d }; }
const INDUSTRIES = [
ind('음식점·카페', ['한식', '중식', '일식', '양식', '카페·디저트', '술집·바', '분식·패스트푸드', '베이커리'], { goals: ['intro', 'inquiry'], features: ['map', 'reviews'], customers: ['주변 직장인', '가족 단위', '데이트 커플', '단체 모임', '관광객'], pages: [['home', ['hero', 'about', 'menu', 'reviews', 'hours', 'map']], ['menu', ['menu', 'pricing']], ['contact', ['hours', 'map', 'contact']]], tone: 'warm', voice: 'friendly' }),
ind('병원·의원', ['내과', '치과', '피부과', '한의원', '정형외과', '소아청소년과', '안과', '성형외과', '동물병원'], { regulated: 'medical', goals: ['booking', 'intro'], features: ['booking', 'map', 'faq'], customers: ['지역 주민', '직장인', '시니어', '학생·청년', '보호자'], pages: [['home', ['hero', 'about', 'services', 'team', 'hours', 'map']], ['services', ['services', 'process', 'faq']], ['booking', ['booking', 'hours', 'map']]], tone: 'minimal', voice: 'expert' }),
ind('법률·세무·행정', ['변호사', '법무사', '세무사', '회계사', '노무사', '행정사'], { regulated: 'legal', goals: ['inquiry', 'intro'], features: ['contactForm', 'map', 'faq'], customers: ['개인 의뢰인', '중소기업', '스타트업', '자영업자'], pages: [['home', ['hero', 'about', 'services', 'credentials', 'contact']], ['services', ['services', 'process', 'faq']], ['contact', ['contact', 'map']]], tone: 'luxury', voice: 'formal' }),
ind('뷰티·미용', ['헤어샵', '네일', '피부관리', '메이크업', '왁싱', '이발소'], { goals: ['booking', 'portfolio'], features: ['booking', 'gallery', 'map', 'reviews'], customers: ['20~30대 여성', '직장인', '학생', '남성 고객'], pages: [['home', ['hero', 'services', 'gallery', 'reviews', 'booking', 'map']], ['pricing', ['pricing', 'faq']]], tone: 'modern', voice: 'friendly' }),
ind('교육·학원', ['입시학원', '어학원', '코딩·IT', '예체능', '유아교육', '성인교육', '과외·스터디'], { goals: ['inquiry', 'intro'], features: ['contactForm', 'map', 'faq', 'board'], customers: ['학부모', '초·중·고 학생', '대학생·취준생', '직장인'], pages: [['home', ['hero', 'about', 'services', 'team', 'faq', 'contact']], ['courses', ['products', 'pricing', 'process']], ['news', ['news']]], tone: 'vivid', voice: 'friendly' }),
ind('부동산·건설·인테리어', ['공인중개사', '인테리어', '건축·시공', '리모델링', '청소·이사'], { goals: ['inquiry', 'portfolio'], features: ['contactForm', 'gallery', 'map'], customers: ['신혼·1인 가구', '자영업자(상가)', '건물주', '기업'], pages: [['home', ['hero', 'about', 'portfolio', 'process', 'contact']], ['portfolio', ['gallery']], ['contact', ['contact', 'map']]], tone: 'minimal', voice: 'expert' }),
ind('IT·소프트웨어·에이전시', ['SaaS·앱', 'IT 서비스·SI', '디자인 에이전시', '마케팅 에이전시', 'AI·데이터'], { goals: ['inquiry', 'sales'], features: ['contactForm', 'faq', 'board'], customers: ['스타트업', '중소기업', '대기업 담당자', '개발자'], pages: [['home', ['hero', 'services', 'products', 'process', 'faq', 'cta']], ['product', ['products', 'pricing', 'faq']], ['contact', ['contact']]], tone: 'modern', voice: 'expert' }),
ind('제조·유통·도소매', ['식품 제조', '기계·부품', '화장품·생활용품', '도매·유통', '농수산물'], { goals: ['intro', 'inquiry', 'sales'], features: ['contactForm', 'gallery'], customers: ['B2B 구매 담당자', '소매점', '일반 소비자', '해외 바이어'], pages: [['home', ['hero', 'about', 'products', 'credentials', 'contact']], ['products', ['products', 'gallery']], ['company', ['about', 'team', 'map']]], tone: 'minimal', voice: 'formal' }),
ind('쇼핑·판매', ['의류·패션', '식품·건강', '가구·인테리어 소품', '취미·수공예', '전자·IT 기기'], { goals: ['sales', 'intro'], features: ['gallery', 'faq', 'reviews'], customers: ['20~30대', '30~40대 주부', '선물 구매자', '마니아층'], pages: [['home', ['hero', 'products', 'reviews', 'faq', 'cta']], ['products', ['products', 'gallery']], ['about', ['about', 'contact']]], tone: 'vivid', voice: 'friendly' }),
ind('프리랜서·포트폴리오', ['디자이너', '개발자', '사진작가', '작가·강사', '영상·크리에이터'], { goals: ['portfolio', 'inquiry'], features: ['gallery', 'contactForm'], customers: ['기업 담당자', '개인 의뢰인', '에이전시'], pages: [['home', ['hero', 'about', 'portfolio', 'process', 'contact']], ['works', ['gallery']]], tone: 'minimal', voice: 'friendly' }),
ind('숙박·여행·레저', ['펜션·민박', '호텔·게스트하우스', '여행사', '캠핑', '체험·액티비티'], { goals: ['booking', 'intro'], features: ['booking', 'gallery', 'map', 'reviews', 'faq'], customers: ['가족 여행객', '커플', '단체·워크숍', '외국인 관광객'], pages: [['home', ['hero', 'gallery', 'about', 'reviews', 'booking', 'map']], ['rooms', ['products', 'pricing', 'faq']]], tone: 'warm', voice: 'friendly' }),
ind('스포츠·피트니스·건강', ['헬스장·PT', '필라테스·요가', '골프', '수영·구기', '재활·운동센터'], { goals: ['booking', 'inquiry'], features: ['booking', 'map', 'reviews', 'faq'], customers: ['직장인', '주부', '시니어', '학생'], pages: [['home', ['hero', 'services', 'team', 'pricing', 'reviews', 'map']], ['programs', ['products', 'faq', 'booking']]], tone: 'vivid', voice: 'friendly' }),
ind('기타', ['비영리·단체', '종교', '개인 블로그', '이벤트·행사', '기타'], { goals: ['intro', 'news'], features: ['board', 'contactForm'], customers: ['회원', '일반 방문자', '후원자'], pages: [['home', ['hero', 'about', 'news', 'contact']]], tone: 'minimal', voice: 'friendly' }),
];
const PAGE_TITLES = { home: '홈', menu: '메뉴', contact: '문의·오시는 길', services: '서비스', booking: '예약', pricing: '가격 안내', courses: '과정 안내', news: '소식', portfolio: '포트폴리오', product: '제품', products: '상품', company: '회사 소개', about: '소개', works: '작업', rooms: '객실', programs: '프로그램' };
const AGES = ['10대', '20대', '30대', '40대', '50대', '60대 이상'];
// ---------- 지역 (시·도 → 시·군·구) ----------
const REGIONS = {
'서울특별시': ['종로구', '중구', '용산구', '성동구', '광진구', '동대문구', '중랑구', '성북구', '강북구', '도봉구', '노원구', '은평구', '서대문구', '마포구', '양천구', '강서구', '구로구', '금천구', '영등포구', '동작구', '관악구', '서초구', '강남구', '송파구', '강동구'],
'부산광역시': ['중구', '서구', '동구', '영도구', '부산진구', '동래구', '남구', '북구', '해운대구', '사하구', '금정구', '강서구', '연제구', '수영구', '사상구', '기장군'],
'대구광역시': ['중구', '동구', '서구', '남구', '북구', '수성구', '달서구', '달성군', '군위군'],
'인천광역시': ['중구', '동구', '미추홀구', '연수구', '남동구', '부평구', '계양구', '서구', '강화군', '옹진군'],
'광주광역시': ['동구', '서구', '남구', '북구', '광산구'],
'대전광역시': ['동구', '중구', '서구', '유성구', '대덕구'],
'울산광역시': ['중구', '남구', '동구', '북구', '울주군'],
'세종특별자치시': ['세종시'],
'경기도': ['수원시', '성남시', '의정부시', '안양시', '부천시', '광명시', '평택시', '동두천시', '안산시', '고양시', '과천시', '구리시', '남양주시', '오산시', '시흥시', '군포시', '의왕시', '하남시', '용인시', '파주시', '이천시', '안성시', '김포시', '화성시', '광주시', '양주시', '포천시', '여주시', '연천군', '가평군', '양평군'],
'강원특별자치도': ['춘천시', '원주시', '강릉시', '동해시', '태백시', '속초시', '삼척시', '홍천군', '횡성군', '영월군', '평창군', '정선군', '철원군', '화천군', '양구군', '인제군', '고성군', '양양군'],
'충청북도': ['청주시', '충주시', '제천시', '보은군', '옥천군', '영동군', '증평군', '진천군', '괴산군', '음성군', '단양군'],
'충청남도': ['천안시', '공주시', '보령시', '아산시', '서산시', '논산시', '계룡시', '당진시', '금산군', '부여군', '서천군', '청양군', '홍성군', '예산군', '태안군'],
'전북특별자치도': ['전주시', '군산시', '익산시', '정읍시', '남원시', '김제시', '완주군', '진안군', '무주군', '장수군', '임실군', '순창군', '고창군', '부안군'],
'전라남도': ['목포시', '여수시', '순천시', '나주시', '광양시', '담양군', '곡성군', '구례군', '고흥군', '보성군', '화순군', '장흥군', '강진군', '해남군', '영암군', '무안군', '함평군', '영광군', '장성군', '완도군', '진도군', '신안군'],
'경상북도': ['포항시', '경주시', '김천시', '안동시', '구미시', '영주시', '영천시', '상주시', '문경시', '경산시', '의성군', '청송군', '영양군', '영덕군', '청도군', '고령군', '성주군', '칠곡군', '예천군', '봉화군', '울진군', '울릉군'],
'경상남도': ['창원시', '진주시', '통영시', '사천시', '김해시', '밀양시', '거제시', '양산시', '의령군', '함안군', '창녕군', '고성군', '남해군', '하동군', '산청군', '함양군', '거창군', '합천군'],
'제주특별자치도': ['제주시', '서귀포시'],
};
// ---------- 디자인 카탈로그 ----------
const TONES = [
{ id: 'minimal', label: '미니멀', desc: '여백 많고 정돈된 구성, 절제된 색', palettes: [{ name: '화이트·그레이', colors: ['#111827', '#6b7280', '#f9fafb', '#2563eb'] }, { name: '아이보리·차콜', colors: ['#1f2937', '#9ca3af', '#f5f5f0', '#0f766e'] }, { name: '모노 블루', colors: ['#0f172a', '#334155', '#f8fafc', '#1d4ed8'] }] },
{ id: 'modern', label: '모던', desc: '선명한 대비, 볼드한 타이포, 카드형 레이아웃', palettes: [{ name: '네이비·스카이', colors: ['#0b1f3a', '#1e3a8a', '#ffffff', '#38bdf8'] }, { name: '블랙·라임', colors: ['#0a0a0a', '#262626', '#fafafa', '#a3e635'] }, { name: '인디고·코랄', colors: ['#1e1b4b', '#4338ca', '#ffffff', '#fb7185'] }] },
{ id: 'warm', label: '따뜻한', desc: '베이지·브라운·오렌지 계열, 부드러운 곡선', palettes: [{ name: '베이지·브라운', colors: ['#3f2e22', '#8b5e3c', '#f7f1e8', '#d97706'] }, { name: '테라코타', colors: ['#3b1f1a', '#b45309', '#fff7ed', '#c2410c'] }, { name: '올리브·크림', colors: ['#2f3a1f', '#6b7c3a', '#fbfaf3', '#ca8a04'] }] },
{ id: 'luxury', label: '고급스러운', desc: '다크 톤과 골드·세리프 서체, 넓은 여백', palettes: [{ name: '블랙·골드', colors: ['#0f0f0f', '#3a3a3a', '#f5f3ef', '#c9a227'] }, { name: '딥그린·골드', colors: ['#0b2e2a', '#14532d', '#f6f5f0', '#b08d57'] }, { name: '버건디·아이보리', colors: ['#3b0a14', '#7f1d1d', '#faf7f2', '#a16207'] }] },
{ id: 'vivid', label: '활기찬', desc: '밝고 강한 포인트 컬러, 큼직한 요소', palettes: [{ name: '옐로·블루', colors: ['#111827', '#1d4ed8', '#ffffff', '#facc15'] }, { name: '핑크·퍼플', colors: ['#2e1065', '#7c3aed', '#fdf4ff', '#ec4899'] }, { name: '그린·오렌지', colors: ['#052e16', '#15803d', '#f0fdf4', '#f97316'] }] },
];
const FONT_SETS = [
{ id: 'sans', label: '산세리프 (Noto Sans KR)', heading: 'Noto Sans KR', body: 'Noto Sans KR', google: 'Noto+Sans+KR:wght@400;500;700' },
{ id: 'serif-mix', label: '세리프 제목 + 산세리프 본문', heading: 'Noto Serif KR', body: 'Noto Sans KR', google: 'Noto+Serif+KR:wght@500;700&family=Noto+Sans+KR:wght@400;500' },
{ id: 'rounded', label: '둥근 고딕 (Gowun Dodum)', heading: 'Gowun Dodum', body: 'Gowun Dodum', google: 'Gowun+Dodum' },
];
const DENSITY = [{ id: 'wide', label: '넓게' }, { id: 'standard', label: '표준' }, { id: 'dense', label: '조밀' }];
const IMAGERY = [{ id: 'photo', label: '사진' }, { id: 'illustration', label: '일러스트' }];
const VOICE = [{ id: 'formal', label: '격식' }, { id: 'friendly', label: '친근' }, { id: 'expert', label: '전문' }];
const LENGTH = [{ id: 'short', label: '짧게' }, { id: 'normal', label: '보통' }];
const LANGUAGE = [{ id: 'ko', label: '한국어' }, { id: 'en', label: '영어' }, { id: 'multi', label: '다국어(한/영)' }];
const FACT_FIELDS = [
{ id: 'phone', label: '전화번호' }, { id: 'address', label: '주소' }, { id: 'hours', label: '영업시간' },
{ id: 'email', label: '문의 수신 이메일' }, { id: 'prices', label: '가격표' }, { id: 'credentials', label: '자격·인증·경력' }, { id: 'reviews', label: '고객 후기(실제)' },
];
function catalog() {
return { GOALS, FEATURES, SECTIONS, REGULATIONS: Object.fromEntries(Object.entries(REGULATIONS).map(([k, v]) => [k, { label: v.label, disableFeatures: v.disableFeatures, constraints: v.constraints }])), INDUSTRIES, PAGE_TITLES, AGES, REGIONS, TONES, FONT_SETS, DENSITY, IMAGERY, VOICE, LENGTH, LANGUAGE, FACT_FIELDS };
}
// ---------- Brief 기본 구조 ----------
function emptyFact() { return { value: '', source: null, status: 'removed' }; } // v1.0.3: 값이 없는 사실정보는 홈페이지에서 항목 자체를 뺀다
function defaultBrief(mode) {
return {
version: 1, mode,
business: { name: '', industry: { major: '', minor: '' }, region: { sido: '', sigungu: '' }, description: '', existingUrl: '' },
goals: [], features: Object.fromEntries(FEATURES.map((f) => [f.id, false])),
regulated: { flag: false, category: null, constraints: [] },
customers: { types: [], ages: [] },
pages: [],
design: { tone: '', palette: null, fonts: null, density: 'standard', imagery: 'photo', recommendedTone: '' },
copy: { voice: 'friendly', length: 'normal', language: 'ko' },
facts: Object.fromEntries(FACT_FIELDS.map((f) => [f.id, emptyFact()])),
assets: { logo: '', photos: [], useStock: false },
references: [], notes: '', origin: {},
};
}
function findIndustry(major) { return INDUSTRIES.find((i) => i.major === major); }
function pagesFromDefaults(list) { return list.map(([id, secs]) => ({ id, title: PAGE_TITLES[id] || id, sections: secs.map((s) => ({ id: s, title: SECTIONS[s] ? SECTIONS[s].label : s, feature: SECTIONS[s] && SECTIONS[s].feature || null })) })); }
/** 업종 기본값을 비어 있는 항목에만 채운다 */
function applyIndustryDefaults(brief) {
const ind = findIndustry(brief.business.industry.major); if (!ind) return brief;
const d = ind.defaults;
if (!brief.goals.length) brief.goals = [...d.goals];
if (!Object.values(brief.features).some(Boolean)) { for (const f of featuresForGoals(brief.goals)) brief.features[f] = true; for (const f of d.features) brief.features[f] = true; }
if (!brief.customers.types.length) brief.customers.types = d.customers.slice(0, 2);
if (!brief.pages.length) brief.pages = pagesFromDefaults(d.pages);
if (!brief.design.tone) { brief.design.tone = d.tone; brief.design.recommendedTone = d.tone; }
if (!brief.copy.voice) brief.copy.voice = d.voice;
if (d.regulated) { brief.regulated = { flag: true, category: d.regulated, constraints: REGULATIONS[d.regulated].constraints }; }
applyRegulation(brief); reconcileFeatures(brief);
return brief;
}
function customerTypesFor(major) { const i = findIndustry(major); return i ? i.defaults.customers : ['일반 고객', '기업 고객']; }
function featuresForGoals(goals) { const out = new Set(); for (const g of goals) { const G = GOALS.find((x) => x.id === g); if (G) G.features.forEach((f) => out.add(f)); } return [...out]; }
function applyRegulation(brief) {
if (!brief.regulated.flag || !brief.regulated.category) return brief;
const reg = REGULATIONS[brief.regulated.category]; if (!reg) return brief;
brief.regulated.constraints = reg.constraints;
for (const f of reg.disableFeatures) brief.features[f] = false;
for (const p of brief.pages) p.sections = p.sections.filter((s) => !reg.disableFeatures.includes(s.feature));
return brief;
}
/** 켜진 기능에 섹션이 없으면 알맞은 페이지에 섹션을 추가한다. 반환: 추가된 섹션 라벨 목록 */
function reconcileFeatures(brief) {
const added = [];
const has = new Set(); for (const p of brief.pages) for (const s of p.sections) if (s.feature) has.add(s.feature);
for (const f of FEATURES) {
if (!brief.features[f.id] || has.has(f.id) || !brief.pages.length) continue;
const secId = f.section; const prefer = { contactForm: ['contact', 'reservation', 'booking'], map: ['contact', 'reservation', 'booking', 'home'], booking: ['booking', 'reservation', 'contact'], faq: ['services', 'programs', 'product', 'products'], gallery: ['portfolio', 'works', 'about'], board: ['news', 'notice'], reviews: ['home'], calendar: ['news', 'notice', 'programs', 'courses', 'home'] }[f.id] || [];
let page = brief.pages.find((p) => prefer.includes(p.id)) || brief.pages[brief.pages.length - 1];
page.sections.push({ id: secId, title: SECTIONS[secId].label, feature: f.id });
added.push(`${SECTIONS[secId].label} → ${page.title}`);
}
return added;
}
/** 기능↔페이지 정합성, 사실정보 부재 경고 */
function checkConsistency(brief) {
const warnings = [];
const sectionFeatures = new Set(); const sectionIds = new Set();
for (const p of brief.pages) for (const s of p.sections) { sectionIds.add(s.id); if (s.feature) sectionFeatures.add(s.feature); }
for (const f of FEATURES) {
if (brief.features[f.id] && !sectionFeatures.has(f.id)) warnings.push({ level: 'error', code: 'feature-no-page', text: `기능 "${f.label}" 이 켜져 있지만 어떤 페이지 섹션에도 배치되지 않았습니다.` });
if (!brief.features[f.id] && sectionFeatures.has(f.id)) warnings.push({ level: 'warn', code: 'section-no-feature', text: `"${f.label}" 섹션이 있지만 기능이 꺼져 있습니다.` });
}
if (!brief.pages.length) warnings.push({ level: 'error', code: 'no-pages', text: '페이지가 하나도 없습니다.' });
if (brief.pages.length && brief.pages[0].id !== 'home') warnings.push({ level: 'warn', code: 'no-home', text: '첫 페이지가 홈(home)이 아닙니다. 첫 페이지가 index.html 이 됩니다.' });
const facts = brief.facts || {};
const need = (sec, fact, label) => { if (sectionIds.has(sec) && facts[fact] && facts[fact].status === 'removed') return; if (sectionIds.has(sec) && (!facts[fact] || facts[fact].status !== 'provided')) warnings.push({ level: 'warn', code: 'fact-missing', text: `"${SECTIONS[sec].label}" 섹션에 필요한 ${label} 가 없어 "[입력 필요]" 로 표시됩니다.` }); };
need('map', 'address', '주소'); need('contact', 'phone', '전화번호'); need('hours', 'hours', '영업시간'); need('pricing', 'prices', '가격표'); need('credentials', 'credentials', '자격 정보');
if (sectionIds.has('reviews') && (!facts.reviews || (facts.reviews.status !== 'provided' && facts.reviews.status !== 'removed'))) warnings.push({ level: 'warn', code: 'fact-missing', text: '후기 섹션이 있지만 실제 후기가 입력되지 않았습니다. 가짜 후기는 만들지 않으며 "[입력 필요]" 로 표시됩니다.' });
if (brief.regulated.flag) warnings.push({ level: 'info', code: 'regulated', text: `규제 업종(${REGULATIONS[brief.regulated.category].label}) 제약이 적용됩니다: ${brief.regulated.constraints.join(' / ')}` });
return warnings;
}
/** 업종 정보가 없을 때 쓰는 기본 페이지 구성 */
function genericPages() { return pagesFromDefaults([['home', ['hero', 'about', 'services', 'contact']], ['about', ['about', 'credentials', 'map']]]); }
/** 페이지 파일명 규칙: 첫 페이지 index.html, 나머지 <id>.html */
function pageFile(brief, i) { return i === 0 ? 'index.html' : `${brief.pages[i].id}.html`; }
// ---------- 결과 검증 (결정적 규칙) ----------
const PHONE_RE = /(?:0\d{1,2}[-.\s]?\d{3,4}[-.\s]?\d{4}|1[5-9]\d{2}[-.\s]?\d{4})/g;
const PRICE_RE = /\d{1,3}(?:,\d{3})+\s*원|\d+\s*만\s*원/g;
const ADDR_RE = /(?:[가-힣]+(?:특별시|광역시|특별자치시|특별자치도|도)\s+)?[가-힣]+(?:시|군|구)\s+[가-힣0-9]+(?:로|길|동)\s*\d+/g;
function stripHtml(s) { return s.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' '); }
function listHtml(dir, base = '') {
let out = []; let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return out; }
for (const e of ents) { if (e.name.startsWith('.') || e.name === 'uploads') continue; const rel = base ? `${base}/${e.name}` : e.name; if (e.isDirectory()) out = out.concat(listHtml(path.join(dir, e.name), rel)); else if (/\.html?$/i.test(e.name)) out.push(rel); }
return out.sort();
}
/**
* 생성 결과 규칙 검사. prefix: 다국어 사이트의 기본 언어 폴더('ko/') — 그 폴더의 페이지만 검사하고 파일 이름은 폴더 기준으로 비교한다
* (다른 언어 폴더는 서비스가 번역해 만든 사본이라 검사하지 않는다).
*/
function verifyFiles(siteDir, brief, prefix = '') {
const files = prefix ? listHtml(path.join(siteDir, prefix)).map((f) => prefix + f) : listHtml(siteDir);
const report = { pages: [], placeholders: [], extraneous: [], regulated: [], mailto: [], widget: { present: false, pages: [] }, summary: {} };
const allowedPhones = new Set(); const allowedPrices = new Set(); let allowedAddr = '';
const f = brief.facts || {};
if (f.phone && f.phone.status === 'provided') (String(f.phone.value).match(PHONE_RE) || []).forEach((m) => allowedPhones.add(m.replace(/[-.\s]/g, '')));
if (f.prices && f.prices.status === 'provided') (String(f.prices.value).match(PRICE_RE) || []).forEach((m) => allowedPrices.add(m.replace(/\s/g, '')));
if (f.address && f.address.status === 'provided') allowedAddr = String(f.address.value).replace(/\s/g, '');
const regPatterns = brief.regulated.flag && REGULATIONS[brief.regulated.category] ? REGULATIONS[brief.regulated.category].patterns.map((p) => new RegExp(p, 'g')) : [];
for (let i = 0; i < (brief.pages || []).length; i++) { const pf = pageFile(brief, i); report.pages.push({ page: brief.pages[i].title, file: prefix + pf, exists: files.includes(prefix + pf) }); }
for (const rel of files) {
const raw = fs.readFileSync(path.join(siteDir, rel), 'utf8'); const text = stripHtml(raw);
const ph = (raw.match(/\[입력 필요\]/g) || []).length; if (ph) report.placeholders.push({ file: rel, count: ph });
const mt = (raw.match(/mailto:/gi) || []).length; if (mt) report.mailto.push({ file: rel, count: mt });
if (/data-wm-contact/.test(raw)) { report.widget.present = true; report.widget.pages.push(rel); }
for (const m of text.match(PHONE_RE) || []) if (!allowedPhones.has(m.replace(/[-.\s]/g, ''))) report.extraneous.push({ file: rel, type: '전화번호', value: m });
for (const m of text.match(PRICE_RE) || []) if (!allowedPrices.has(m.replace(/\s/g, ''))) report.extraneous.push({ file: rel, type: '가격', value: m });
for (const m of text.match(ADDR_RE) || []) { const nm = m.replace(/\s/g, ''); if (!allowedAddr || !(allowedAddr.includes(nm) || nm.includes(allowedAddr))) report.extraneous.push({ file: rel, type: '주소', value: m }); }
for (const re of regPatterns) for (const m of text.match(re) || []) report.regulated.push({ file: rel, value: m });
}
report.summary = { files: files.length, pagesMissing: report.pages.filter((p) => !p.exists).length, placeholders: report.placeholders.reduce((a, b) => a + b.count, 0), extraneous: report.extraneous.length, regulated: report.regulated.length, mailto: report.mailto.reduce((a, b) => a + b.count, 0), widget: report.widget.present };
return report;
}
module.exports = { INDUSTRIES, catalog, defaultBrief, applyIndustryDefaults, applyRegulation, reconcileFeatures, checkConsistency, genericPages, customerTypesFor, featuresForGoals, findIndustry, pagesFromDefaults, pageFile, verifyFiles, GOALS, FEATURES, SECTIONS, REGULATIONS, TONES, FONT_SETS, PAGE_TITLES };