'use strict';
// ============================================================================
// 검색·AI 최적화(SEO · AEO · GEO)와 검색사이트 등록 (v1.3.5)
//
// 1) 생성 옵션 → AI 초안 → 사용자 검토 → 반영
// - 생성 탭에서 "SEO AI 생성 / AEO 생성 / GEO 생성" 을 고르면(site.optimize.wanted) 사이트 생성·검증이 끝난 뒤
// AI 가 사이트 파일을 읽어 초안(site.optimize.draft)을 만든다. 초안은 바로 사이트에 넣지 않는다.
// - 사용자가 관리 탭에서 값을 검토·수정하고 "반영" 하면(site.optimize.final) materialize() 가 HTML 과 파일에 넣는다.
// - SEO : 페이지별
·meta description·키워드·Open Graph·트위터 카드·canonical, sitemap.xml·robots.txt
// - AEO : (Answer Engine Optimization) 질문형 FAQ — FAQPage 구조화 데이터, 사이트에 없는 질문은 FAQ 영역으로 추가
// - GEO : (Generative Engine Optimization) 사업체 정보 구조화 데이터(Organization/LocalBusiness 등),
// AI 가 인용하기 좋은 사실 문장, llms.txt (AI 크롤러용 사이트 요약)
//
// 2) 검색사이트 등록(구글 Search Console, 네이버 서치어드바이저) — 배포가 끝난 사이트만
// - 소유 확인 코드(meta 태그) 입력 → 태그를 넣고 재배포 → 검색사이트에서 "확인" → 사이트맵 제출.
// - 사람이 해야 하는 단계(코드 받기, DNS TXT 등록, 검색사이트에서 확인·제출)는 관리자 콘솔 "검색 등록" 탭에
// 작업 목록으로 보이고, 관리자가 진행·완료 표시할 수 있다. IndexNow(빙·네이버 등 공유)는 자동 색인 요청.
//
// 3) 다국어 사이트(v1.3.10, site.langs) — 언어 폴더(/ko/ /en/ …)마다 따로
// - 기본 언어 초안은 AI 가 기본 언어 폴더를 읽어 만들고, 다른 언어 초안은 그 초안을 바탕으로 "그 언어 사용자가 실제로 검색하는 표현"으로
// 현지화해 따로 만든다(site.optimize.langDrafts[언어]). 사용자는 언어 탭마다 검토하고, 반영 값은 site.optimize.langFinals[언어].
// - 각 페이지에 그 언어의 title·description·og:locale, 모든 언어 버전의 hreflang 대체 링크(+x-default)를 넣고,
// sitemap.xml 에는 언어별 주소와 xhtml:link 대체 주소를 함께 적는다. 검색사이트 확인 태그는 사이트 맨 위 언어 선택 페이지에 넣는다.
//
// materialize() 는 같은 입력이면 같은 결과를 내도록(멱등) 표시 주석으로 관리 구역을 나눠 덮어쓴다.
// 안: … , 본문 FAQ: …
// 파일(sitemap.xml·robots.txt·llms.txt·IndexNow 키): 우리가 쓴 내용의 해시를 기억해, 사용자가 직접 만든 파일은 덮어쓰지 않는다.
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const dns = require('dns').promises;
const multilang = require('./multilang'); // v1.3.10 언어별 SEO·AEO·GEO
const sitemsg = require('./sitemsg'); // v1.3.16 사이트에 넣는 시스템 문구 (FAQ 제목 등)
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const clip = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
const sha = (t) => crypto.createHash('sha1').update(t).digest('hex').slice(0, 16);
// ---------- 옵션 ----------
/** 생성 옵션 값 정리: {seo, aeo, geo} (문자열 JSON·체크박스 값 모두 받음) */
function wantedFrom(input) {
let o = input; if (typeof o === 'string') { try { o = JSON.parse(o); } catch (_) { o = {}; } }
o = o || {}; const on = (v) => v === true || v === 'true' || v === 'on' || v === 1 || v === '1';
return { seo: on(o.seo), aeo: on(o.aeo), geo: on(o.geo) };
}
const anyWanted = (w) => !!(w && (w.seo || w.aeo || w.geo));
/** 사이트의 HTML 파일 목록 (하위 폴더 포함, 최대 400개, 웹메일 진입 파일·업로드 폴더 제외) */
function htmlFiles(siteDir) {
const out = [];
const walk = (dir, rel) => { let ents = []; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; } for (const e of ents) { if (e.name.startsWith('.') || ['uploads', 'webmail', 'node_modules'].includes(e.name)) continue; const r = rel ? `${rel}/${e.name}` : e.name; if (e.isDirectory()) { if (out.length < 400) walk(path.join(dir, e.name), r); } else if (/\.html?$/i.test(e.name) && out.length < 400) out.push(r); } };
walk(siteDir, ''); return out.sort((a, b) => (a === 'index.html' ? -1 : b === 'index.html' ? 1 : a.localeCompare(b)));
}
/**
* 최적화 값을 매기는 "페이지 파일" 목록 (v1.3.10).
* 단일 언어 사이트: 사이트 전체 HTML(최대 60개). 다국어 사이트: 기본 언어 폴더 기준 경로(index.html, about.html …) — 언어마다 같은 구조라 같은 목록을 쓴다.
*/
function pageFiles(site, siteDir) {
if (multilang.isMulti(site)) return multilang.htmlUnder(path.join(siteDir, site.langs.default)).sort((a, b) => (a === 'index.html' ? -1 : b === 'index.html' ? 1 : a.localeCompare(b))).slice(0, 60);
return htmlFiles(siteDir).slice(0, 60);
}
// ---------- AI 초안 ----------
/**
* 초안 프롬프트. AI 는 사이트 폴더를 Read 로 읽고 JSON 만 답한다.
* 각 항목의 작성 기준(글자 수, 질문형, 사실만 등)을 명시한다 — 사용자 검토 화면에서도 같은 기준을 보여 준다.
* @param {object} site
* @param {{seo,aeo,geo}} wanted
* @param {string[]} files HTML 파일 목록
*/
function draftPrompt(site, wanted, files) {
const ml = multilang.isMulti(site) ? site.langs.default : null; // 다국어: 기본 언어 폴더를 읽는다
const b = (site.plan && site.plan.brief) || {}; const facts = b.facts || {};
const factLines = Object.entries(facts).filter(([, v]) => v && v.value && v.status !== 'removed').map(([k, v]) => `${k}: ${v.value}`).join('\n');
const lang = site.uiLang && site.uiLang !== 'ko' ? site.uiLang : 'ko';
const parts = [];
if (wanted.seo) parts.push(`"seo": { "siteName": "사이트(사업체) 이름", "locale": "ko_KR 같은 언어_국가", "keywords": ["사이트 전체 핵심 검색어 5~10개 (업종+지역+서비스 조합)"],
"pages": [ { "file": "index.html", "title": "검색 결과 제목 — 페이지마다 다르게, 핵심어를 앞쪽에, 한글 기준 25~35자(최대 60자)", "description": "검색 결과 설명 — 페이지 내용을 요약하고 행동 유도, 한글 기준 70~110자(최대 155자)", "keywords": ["이 페이지 검색어 3~6개"], "ogTitle": "공유 미리보기 제목", "ogDescription": "공유 미리보기 설명 (60~100자)", "h1": "이 페이지 h1 문구 (없거나 부적절하면 제안, 적절하면 현재 값)" } ] }`);
if (wanted.aeo) parts.push(`"aeo": { "summary": "질문형 검색(음성·AI 답변)에 그대로 읽힐 사이트 핵심 답 2~3문장 — 누가, 무엇을, 어디서",
"faqs": [ { "q": "실제 고객이 검색할 질문형 문장 (…인가요? …어떻게 하나요?)", "a": "첫 문장에 바로 답, 2~4문장 이내, 사이트 내용과 일치", "page": "질문이 속할 페이지 파일", "onPage": true } ] } ← 5~8개, 사이트에 이미 있는 Q&A 는 onPage:true 로 그대로 옮기고, 없는 질문은 onPage:false 로 제안(사이트 내용으로 답할 수 있는 것만)`);
if (wanted.geo) parts.push(`"geo": { "entity": { "type": "Organization|LocalBusiness|Restaurant|MedicalClinic|EducationalOrganization|Store|ProfessionalService 중 가장 맞는 것", "name": "", "description": "한두 문장 소개", "telephone": "", "email": "", "address": { "streetAddress": "", "addressLocality": "", "addressRegion": "", "postalCode": "", "addressCountry": "KR" }, "openingHours": ["Mo-Fr 09:00-18:00 형식"], "priceRange": "", "sameAs": ["공식 SNS·지도 주소"] },
"facts": ["AI 답변이 그대로 인용할 수 있는 짧은 사실 문장 5~10개 (숫자·고유명사 포함, 사이트에 있는 사실만)"],
"llmsTxt": "llms.txt 형식(# 사이트명 / > 한 줄 요약 / 본문 소개 / ## 주요 페이지 - [이름](파일): 설명) 의 마크다운" }`);
return `현재 디렉터리는 완성된 웹사이트입니다. 아래 HTML 파일을 Read 로 읽고(큰 파일은 앞부분과 주요 섹션 위주), 검색엔진·AI 답변 엔진에 맞는 최적화 값을 만들어 JSON 으로만 답하세요.
작성 언어: 사이트 본문 언어 (화면 언어 ${lang}).${ml ? `\n다국어 사이트입니다: 기본 언어(${ml}) 폴더의 페이지만 읽고, "file"·"page" 값은 아래 목록의 경로 그대로 씁니다. 이 초안은 기본 언어용이며 locale 은 ${multilang.LOCALES[ml].og} 입니다.` : ''}
규칙:
- 사이트·아래 사실 정보에 있는 내용만 씁니다. 전화번호·주소·가격·수치를 지어내지 말고, 모르면 빈 문자열로 둡니다.
- 과장·낚시성 문구, 키워드 반복 나열을 하지 않습니다.
- 파일 이름은 아래 목록에 있는 것만 씁니다.
[HTML 파일] ${(ml ? files.map((f) => `${ml}/${f}`) : files).join(', ')}
[사실 정보]
${factLines || '(없음 — 사이트 본문에서만 찾기)'}
[업종] ${(b.business && b.business.industry && [b.business.industry.major, b.business.industry.minor].filter(Boolean).join(' > ')) || '-'}
출력 JSON:
{ ${parts.join(',\n ')} }`;
}
/**
* 다국어 사이트의 다른 언어 초안 프롬프트 (v1.3.10). 기본 언어 초안 JSON 을 그 언어용으로 "현지화" 한다(파일 읽기 없음).
* 직역이 아니라 그 언어 사용자가 실제로 검색하는 표현·길이 기준으로 다시 쓰고, 파일 이름·숫자·연락처·URL 은 그대로 둔다.
*/
function localizePrompt(site, baseDraft, lang) {
const L = multilang.LOCALES[lang]; const D = multilang.LOCALES[site.langs.default];
return `아래는 웹사이트의 ${D.label}(${site.langs.default}) 검색·AI 최적화 값(JSON)입니다. 같은 사이트의 ${L.label}(${lang}) 버전 페이지(/${lang}/ 폴더)에 쓸 값으로 바꿔, 같은 구조의 JSON 만 답하세요.
규칙:
- 직역하지 말고 ${L.label} 사용자가 실제로 검색하는 표현으로 씁니다(키워드는 그 언어권 검색 습관에 맞게 현지화).
- 길이 기준은 그 언어 기준: 검색 제목 약 50~60자(영문 기준, CJK 는 25~35자), 설명 약 120~155자(CJK 70~110자).
- "file", "page", "use", "onPage", 전화번호·이메일·URL·숫자·영업시간 형식(Mo-Fr 09:00-18:00)은 그대로 둡니다. 상호는 원래 표기 또는 그 언어권 통용 표기.
- seo.locale 은 "${L.og}" 로 씁니다. 원문에 없는 사실을 지어내지 않습니다. llmsTxt 는 ${L.label}로 쓰고 링크는 그대로 둡니다.
${JSON.stringify(baseDraft)}`;
}
/**
* AI 초안 정리: 형식·길이 검증, 없는 파일 제거. 사용자 검토 화면과 materialize 가 이 형태만 믿는다.
*/
function normalize(data, files, wanted, forceLocale, dirPrefix, keepFlags) { // keepFlags: 사용자 검토 결과(최종 반영)면 "반영 안 함" 체크를 그대로 둔다 (v1.3.17 B04) // forceLocale: 다국어 사이트에서 언어 폴더의 og:locale 로 고정, dirPrefix: 'ko/' (AI 가 쓴 폴더 경로를 떼어 냄)
const out = {}; const fileSet = new Set(files);
if (dirPrefix) { const cut = (f) => (typeof f === 'string' && f.startsWith(dirPrefix) ? f.slice(dirPrefix.length) : f); for (const p of (data.seo && Array.isArray(data.seo.pages) ? data.seo.pages : [])) if (p) p.file = cut(p.file); for (const f of (data.aeo && Array.isArray(data.aeo.faqs) ? data.aeo.faqs : [])) if (f) f.page = cut(f.page); }
if (wanted.seo && data.seo) {
const s = data.seo;
out.seo = { siteName: clip(s.siteName, 80), locale: forceLocale || (/^[a-z]{2}_[A-Z]{2}$/.test(s.locale || '') ? s.locale : 'ko_KR'), keywords: (Array.isArray(s.keywords) ? s.keywords : []).map((k) => clip(k, 40)).filter(Boolean).slice(0, 12),
pages: (Array.isArray(s.pages) ? s.pages : []).filter((p) => p && fileSet.has(p.file)).map((p) => ({ file: p.file, title: clip(p.title, 70), description: clip(p.description, 170), keywords: (Array.isArray(p.keywords) ? p.keywords : []).map((k) => clip(k, 40)).filter(Boolean).slice(0, 8), ogTitle: clip(p.ogTitle || p.title, 90), ogDescription: clip(p.ogDescription || p.description, 200), h1: clip(p.h1, 100), use: keepFlags ? p.use !== false : true })) };
const seen = new Set(out.seo.pages.map((p) => p.file)); for (const f of files) if (!seen.has(f) && out.seo.pages.length < 60) out.seo.pages.push({ file: f, title: '', description: '', keywords: [], ogTitle: '', ogDescription: '', h1: '', use: false });
}
if (wanted.aeo && data.aeo) {
const a = data.aeo;
out.aeo = { summary: clip(a.summary, 400), faqs: (Array.isArray(a.faqs) ? a.faqs : []).map((f) => ({ q: clip(f && f.q, 200), a: clip(f && f.a, 800), page: fileSet.has(f && f.page) ? f.page : (files[0] || 'index.html'), onPage: !!(f && f.onPage), use: keepFlags ? !!f && f.use !== false : true })).filter((f) => f.q && f.a).slice(0, 12) };
}
if (wanted.geo && data.geo) {
const g = data.geo; const e = g.entity || {}; const ad = e.address || {};
out.geo = { entity: { type: /^[A-Za-z]{3,40}$/.test(e.type || '') ? e.type : 'Organization', name: clip(e.name, 120), description: clip(e.description, 400), telephone: clip(e.telephone, 40), email: clip(e.email, 120), address: { streetAddress: clip(ad.streetAddress, 160), addressLocality: clip(ad.addressLocality, 80), addressRegion: clip(ad.addressRegion, 80), postalCode: clip(ad.postalCode, 20), addressCountry: clip(ad.addressCountry || 'KR', 2) }, openingHours: (Array.isArray(e.openingHours) ? e.openingHours : []).map((h) => clip(h, 60)).filter(Boolean).slice(0, 7), priceRange: clip(e.priceRange, 40), sameAs: (Array.isArray(e.sameAs) ? e.sameAs : []).filter((u) => /^https?:\/\//.test(u || '')).map((u) => clip(u, 300)).slice(0, 8) },
facts: (Array.isArray(g.facts) ? g.facts : []).map((f) => clip(f, 300)).filter(Boolean).slice(0, 12), llmsTxt: String(g.llmsTxt || '').slice(0, 8000), useEntity: keepFlags ? g.useEntity !== false : true, useLlms: keepFlags ? g.useLlms !== false : true };
}
return out;
}
/**
* 사용자 검토 결과(화면에서 고친 값) 검증 → site.optimize.final 에 저장할 형태.
* 화면은 draft 와 같은 모양을 돌려보내며, 각 항목의 use(반영 여부)를 바꿀 수 있다.
*/
function normalizeFinal(input, files, forceLocale) {
const wanted = { seo: !!input.seo, aeo: !!input.aeo, geo: !!input.geo };
return normalize(input, files, wanted, forceLocale, undefined, true); // v1.3.17 (B04): 체크를 끈 항목은 끈 채로 반영
}
// ---------- 사이트에 넣기 ----------
// 관리 구역은 "표시 주석…표시 주석\n" 을 정확히 넣고 정확히 지운다 → 원래 파일의 공백을 건드리지 않아 몇 번을 다시 써도 결과가 같다(멱등)
const SEO_RE = /[\s\S]*?\n/;
const FAQ_RE = /[\s\S]*?\n/;
/** 페이지 주소: 기준 주소 + 파일 (index.html 은 폴더 주소로) */
const pageUrl = (base, file) => base ? base.replace(/\/?$/, '/') + file.replace(/(^|\/)index\.html$/, '$1') : '';
/** JSON-LD 를 끊김 방지) */
const ld = (obj) => ``;
/** 빈 값 제거 (구조화 데이터 검사기 경고 방지) */
function prune(o) { if (Array.isArray(o)) { const a = o.map(prune).filter((x) => x !== undefined); return a.length ? a : undefined; } if (o && typeof o === 'object') { const r = {}; for (const [k, v] of Object.entries(o)) { const pv = prune(v); if (pv !== undefined && pv !== '') r[k] = pv; } return Object.keys(r).length ? r : undefined; } return o === '' || o == null ? undefined : o; }
/**
* FAQ 영역 제목: v1.3.16 부터 코드에 언어별 글자를 두지 않고 사이트 시스템 문구 카탈로그(lib/sitemsg 'faq.title')에서 가져온다.
* @param {string|null} lang 로케일 코드 (다국어 사이트의 언어 폴더)
* @param {string} [locale] 단일 언어 사이트의 og:locale(ko_KR 등) — 언어를 모를 때 여기서 추정
*/
const faqTitleFor = (lang, locale) => sitemsg.tr(lang || multilang.codeOf(String(locale || 'ko').split('_')[0]), 'faq.title');
/**
* 한 폴더의 페이지들에 관리 구역( 의 wm-seo, 본문 wm-aeo-faq)을 다시 쓴다.
* @param {string} dir 페이지 폴더 (단일 언어: 사이트 폴더, 다국어: 언어 폴더)
* @param {string} prefix 바뀐 파일 이름 앞에 붙일 경로 ('' 또는 'en/')
* @param {string[]} files dir 기준 HTML 목록
* @param {object|null} fin 이 언어의 반영 값 (없으면 관리 구역을 걷어 내기만 — 대체 링크·확인 태그는 넣는다)
* @param {string|null} baseUrl 이 폴더의 운영 주소 (https://도메인/ 또는 https://도메인/en/)
* @param {object} ctx { verify: 검색사이트 확인 태그(searchReg), ogLocale, altLocales: [], alternates: [{hreflang, base}], faqTitle, homeUrl }
*/
function writePages(dir, prefix, files, fin, baseUrl, ctx) {
fin = fin || {}; const changed = [];
const seoPages = new Map(((fin.seo && fin.seo.pages) || []).filter((p) => p.use).map((p) => [p.file, p]));
const faqs = ((fin.aeo && fin.aeo.faqs) || []).filter((f) => f.use);
const geo = fin.geo || null; const reg = ctx.verify || null;
for (const file of files) {
const fp = path.join(dir, file); let html; try { html = fs.readFileSync(fp, 'utf8'); } catch (_) { continue; }
const orig = html; html = html.replace(SEO_RE, '').replace(FAQ_RE, ''); // 지난번에 넣은 관리 구역부터 걷어 내고 원래 페이지를 기준으로 판단
const p = seoPages.get(file); const isHome = file === 'index.html'; const url = pageUrl(baseUrl, file);
const lines = [];
if (p) {
if (p.title) { if (/]*>[\s\S]*?<\/title>/i.test(html)) html = html.replace(/]*>[\s\S]*?<\/title>/i, `${esc(p.title)}`); else lines.push(`${esc(p.title)}`); }
if (p.description) { html = html.replace(/]*>/gi, ''); lines.push(``); }
const kw = [...new Set([...(p.keywords || []), ...((fin.seo && fin.seo.keywords) || []).slice(0, 4)])]; if (kw.length) lines.push(``);
lines.push('');
if (url) lines.push(``);
lines.push(``, ``, ``);
if (fin.seo.siteName) lines.push(``); lines.push(``);
for (const a of ctx.altLocales || []) lines.push(``);
if (url) lines.push(``);
lines.push('');
} else if (ctx.always) {
// v1.3.16 다국어: 이 언어의 검색 값을 아직 반영하지 않았어도 로케일 정보(canonical·og:locale·og:url)는 언어마다 분명히 넣는다
if (url) lines.push(``);
lines.push(``); for (const a of ctx.altLocales || []) lines.push(``);
if (url) lines.push(``);
}
// 다국어: 같은 페이지의 모든 언어 버전 (검색엔진이 언어별 주소를 알아보게) — 절대 주소가 필요해 운영 주소가 있을 때만
for (const a of ctx.alternates || []) lines.push(``);
// 검색사이트 소유 확인 태그 (사이트 첫 페이지만)
if (isHome && reg) { if (reg.google && reg.google.method === 'meta' && reg.google.code) lines.push(``); if (reg.naver && reg.naver.code) lines.push(``); }
// GEO: 사업체 정보 구조화 데이터 (첫 페이지)
if (isHome && geo && geo.useEntity && geo.entity && geo.entity.name) {
// v1.3.16 로케일별 사업체 정보: 언어 폴더마다 그 언어 값(이름·소개·주소)으로, 같은 사업체임을 @id 로 묶고 url 은 그 언어 첫 페이지
const e = geo.entity; lines.push(ld(prune({ '@context': 'https://schema.org', '@type': e.type || 'Organization', '@id': ctx.entityId || undefined, name: e.name, description: e.description, url: baseUrl || ctx.homeUrl || undefined, telephone: e.telephone, email: e.email, address: e.address && Object.values(e.address).some((v) => v && v !== 'KR') ? { '@type': 'PostalAddress', ...e.address } : undefined, openingHours: e.openingHours, priceRange: e.priceRange, sameAs: e.sameAs, knowsLanguage: ctx.knowsLanguage, currenciesAccepted: ctx.currency })));
if (fin.aeo && fin.aeo.summary) lines.push(ld(prune({ '@context': 'https://schema.org', '@type': 'WebSite', name: (fin.seo && fin.seo.siteName) || e.name, url: baseUrl || undefined, inLanguage: ctx.htmlLang || undefined, description: fin.aeo.summary })));
}
// AEO: 이 페이지 FAQ 의 FAQPage 구조화 데이터 + 사이트에 없던 질문은 보이는 FAQ 영역으로
const pageFaqs = faqs.filter((f) => f.page === file);
if (pageFaqs.length) lines.push(ld({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: pageFaqs.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) }));
// 관리 구역 다시 쓰기 (내용이 없으면 구역 없이 둔다)
if (lines.length) { const block = `\n${lines.join('\n')}\n\n`; html = /<\/head>/i.test(html) ? html.replace(/<\/head>/i, `${block}`) : block + html; }
const visible = pageFaqs.filter((f) => !f.onPage);
if (visible.length) {
const sec = `\n