Files

377 lines
41 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 : 페이지별 <title>·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() 는 같은 입력이면 같은 결과를 내도록(멱등) 표시 주석으로 관리 구역을 나눠 덮어쓴다.
// <head> 안: <!-- wm-seo:start --> … <!-- wm-seo:end -->, 본문 FAQ: <!-- wm-aeo-faq:start --> … <!-- wm-aeo-faq:end -->
// 파일(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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 = /<!-- wm-seo:start -->[\s\S]*?<!-- wm-seo:end -->\n/;
const FAQ_RE = /<!-- wm-aeo-faq:start -->[\s\S]*?<!-- wm-aeo-faq:end -->\n/;
/** 페이지 주소: 기준 주소 + 파일 (index.html 은 폴더 주소로) */
const pageUrl = (base, file) => base ? base.replace(/\/?$/, '/') + file.replace(/(^|\/)index\.html$/, '$1') : '';
/** JSON-LD 를 <script> 안에 안전하게 넣기 (</script> 끊김 방지) */
const ld = (obj) => `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;
/** 빈 값 제거 (구조화 데이터 검사기 경고 방지) */
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');
/**
* 한 폴더의 페이지들에 관리 구역(<head> 의 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 (/<title[^>]*>[\s\S]*?<\/title>/i.test(html)) html = html.replace(/<title[^>]*>[\s\S]*?<\/title>/i, `<title>${esc(p.title)}</title>`); else lines.push(`<title>${esc(p.title)}</title>`); }
if (p.description) { html = html.replace(/<meta\s+name=["']description["'][^>]*>/gi, ''); lines.push(`<meta name="description" content="${esc(p.description)}">`); }
const kw = [...new Set([...(p.keywords || []), ...((fin.seo && fin.seo.keywords) || []).slice(0, 4)])]; if (kw.length) lines.push(`<meta name="keywords" content="${esc(kw.join(', '))}">`);
lines.push('<meta name="robots" content="index, follow">');
if (url) lines.push(`<link rel="canonical" href="${esc(url)}">`);
lines.push(`<meta property="og:type" content="${isHome ? 'website' : 'article'}">`, `<meta property="og:title" content="${esc(p.ogTitle || p.title)}">`, `<meta property="og:description" content="${esc(p.ogDescription || p.description)}">`);
if (fin.seo.siteName) lines.push(`<meta property="og:site_name" content="${esc(fin.seo.siteName)}">`); lines.push(`<meta property="og:locale" content="${esc(ctx.ogLocale || fin.seo.locale || 'ko_KR')}">`);
for (const a of ctx.altLocales || []) lines.push(`<meta property="og:locale:alternate" content="${esc(a)}">`);
if (url) lines.push(`<meta property="og:url" content="${esc(url)}">`);
lines.push('<meta name="twitter:card" content="summary_large_image">');
} else if (ctx.always) {
// v1.3.16 다국어: 이 언어의 검색 값을 아직 반영하지 않았어도 로케일 정보(canonical·og:locale·og:url)는 언어마다 분명히 넣는다
if (url) lines.push(`<link rel="canonical" href="${esc(url)}">`);
lines.push(`<meta property="og:locale" content="${esc(ctx.ogLocale)}">`); for (const a of ctx.altLocales || []) lines.push(`<meta property="og:locale:alternate" content="${esc(a)}">`);
if (url) lines.push(`<meta property="og:url" content="${esc(url)}">`);
}
// 다국어: 같은 페이지의 모든 언어 버전 (검색엔진이 언어별 주소를 알아보게) — 절대 주소가 필요해 운영 주소가 있을 때만
for (const a of ctx.alternates || []) lines.push(`<link rel="alternate" hreflang="${esc(a.hreflang)}" href="${esc(pageUrl(a.base, file))}">`);
// 검색사이트 소유 확인 태그 (사이트 첫 페이지만)
if (isHome && reg) { if (reg.google && reg.google.method === 'meta' && reg.google.code) lines.push(`<meta name="google-site-verification" content="${esc(reg.google.code)}">`); if (reg.naver && reg.naver.code) lines.push(`<meta name="naver-site-verification" content="${esc(reg.naver.code)}">`); }
// 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 = `<!-- wm-seo:start --><!-- AI Webmaker 검색·AI 최적화 (관리 탭에서 고치세요. 이 구역은 반영·배포 때 다시 써집니다) -->\n${lines.join('\n')}\n<!-- wm-seo:end -->\n`; html = /<\/head>/i.test(html) ? html.replace(/<\/head>/i, `${block}</head>`) : block + html; }
const visible = pageFaqs.filter((f) => !f.onPage);
if (visible.length) {
const sec = `<!-- wm-aeo-faq:start --><!-- AI Webmaker 자주 묻는 질문 (관리 탭 › 검색·AI 최적화에서 고치세요) -->\n<section class="wm-faq" data-wm-aeo style="max-width:960px;margin:40px auto;padding:0 20px"><h2>${esc(ctx.faqTitle || FAQ_TITLE.ko)}</h2>${visible.map((f) => `<details style="border-bottom:1px solid #e5e7eb;padding:12px 0"><summary style="cursor:pointer;font-weight:600">${esc(f.q)}</summary><p style="margin:8px 0 0">${esc(f.a)}</p></details>`).join('')}</section>\n<!-- wm-aeo-faq:end -->\n`;
if (/<footer[\s>]/i.test(html)) html = html.replace(/<footer[\s>]/i, (m) => `${sec}${m}`); else if (/<\/main>/i.test(html)) html = html.replace(/<\/main>/i, `${sec}</main>`); else html = html.replace(/<\/body>/i, `${sec}</body>`);
}
if (html !== orig) { fs.writeFileSync(fp, html, { mode: 0o644 }); changed.push(prefix + file); }
}
return changed;
}
/**
* 반영 값(final)과 검색 등록 값(searchReg)을 사이트 파일에 넣는다. 같은 입력이면 결과가 같다(멱등).
* @param {object} site
* @param {string} siteDir
* @param {string|null} baseUrl 운영 주소(https://도메인/). 없으면 canonical·og:url·sitemap·hreflang 의 절대 주소를 생략한다
* — 배포할 때마다 배포 주소로 다시 부른다(server.js deploySiteTo).
* @returns {{changed: string[]}}
*/
function materialize(site, siteDir, baseUrl) {
const opt = site.optimize || {}; const fin = opt.applied ? (opt.final || {}) : {}; const reg = site.searchReg || {};
const base = baseUrl ? baseUrl.replace(/\/?$/, '/') : null; let changed = []; let sitemapUrls = [];
const multi = multilang.isMulti(site);
const finOf = (lang) => (!opt.applied ? {} : lang === site.langs.default ? (opt.final || {}) : ((opt.langFinals || {})[lang] || {}));
if (!multi) {
const files = htmlFiles(siteDir);
changed = writePages(siteDir, '', files, fin, base, { verify: reg, faqTitle: faqTitleFor(null, fin.seo && fin.seo.locale) });
sitemapUrls = files.map((f) => ({ loc: pageUrl(base, f), home: f === 'index.html' }));
} else {
// 다국어: 언어 폴더마다 그 언어의 반영 값 + 모든 언어 버전 대체 링크, 맨 위 언어 선택 페이지에는 확인 태그와 대체 링크
const { list, default: def } = site.langs; const L = multilang.LOCALES;
// v1.3.16 x-default: 사이트 설정(site.langs.xDefault)이 가리키는 언어 폴더, 'root' 이면 맨 위 언어 선택 페이지
const xd = multilang.xDefaultOf(site); const xBase = (file) => (xd === 'root' ? (file === 'index.html' ? base : null) : `${base}${xd}/`);
const files = multilang.htmlUnder(path.join(siteDir, def)); const geoDef = finOf(def).geo || null; const home = multilang.siteLocale(site);
for (const c of list) {
const f = finOf(c);
// 이 언어 GEO 값이 없으면 기본 언어 사업체 정보를 쓴다(로케일 현황에 "기본 언어 값 사용" 으로 표시)
const fin2 = f.geo || !geoDef ? f : { ...f, geo: geoDef };
const alt = (file) => (base ? [...list.map((x) => ({ hreflang: L[x].html, base: `${base}${x}/` })), ...(xBase(file) ? [{ hreflang: 'x-default', base: xBase(file) }] : [])] : []);
const dirC = path.join(siteDir, c);
for (const file of multilang.htmlUnder(dirC)) changed = changed.concat(writePages(dirC, `${c}/`, [file], fin2, base ? `${base}${c}/` : null, { ogLocale: L[c].og, altLocales: list.filter((x) => x !== c).map((x) => L[x].og), alternates: alt(file), faqTitle: faqTitleFor(c), htmlLang: L[c].html, homeUrl: base ? `${base}${c}/` : undefined, always: true, knowsLanguage: list.map((x) => L[x].html), currency: home.currency, entityId: base ? `${base}#business` : undefined }));
}
const rootAlt = base ? [...list.map((c) => ({ hreflang: L[c].html, base: `${base}${c}/` })), { hreflang: 'x-default', base: xd === 'root' ? base : `${base}${xd}/` }] : [];
changed = changed.concat(writePages(siteDir, '', fs.existsSync(path.join(siteDir, 'index.html')) ? ['index.html'] : [], null, base, { verify: reg, alternates: rootAlt }));
sitemapUrls = list.flatMap((c) => files.map((file) => ({ loc: pageUrl(`${base}${c}/`, file), home: file === 'index.html' && c === def, alts: [...list.map((x) => ({ hreflang: L[x].html, href: pageUrl(`${base}${x}/`, file) })), ...(xBase(file) ? [{ hreflang: 'x-default', href: pageUrl(xBase(file), file) }] : [])] })));
}
// 파일들 — 우리가 쓴 적 없는(사용자가 만든) 파일은 덮어쓰지 않는다
const written = (site.optimize = site.optimize || {}).written || (site.optimize.written = {});
const put = (name, content) => { const fp = path.join(siteDir, name); let cur = null; try { cur = fs.readFileSync(fp, 'utf8'); } catch (_) {} if (cur !== null && written[name] !== sha(cur)) return; if (content == null) { if (cur !== null && written[name]) { try { fs.unlinkSync(fp); } catch (_) {} delete written[name]; changed.push(name); } return; } if (cur === content) return; fs.writeFileSync(fp, content, { mode: 0o644 }); written[name] = sha(content); changed.push(name); };
const anySeo = multi ? site.langs.list.some((c) => finOf(c).seo) : !!fin.seo;
const wantSitemap = !!(anySeo || (reg.google && reg.google.started) || (reg.naver && reg.naver.started));
const hasAlts = sitemapUrls.some((u) => u.alts);
if (wantSitemap && base) put('sitemap.xml', `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${hasAlts ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : ''}>\n${sitemapUrls.map((u) => ` <url><loc>${esc(u.loc)}</loc>${(u.alts || []).map((a) => `<xhtml:link rel="alternate" hreflang="${esc(a.hreflang)}" href="${esc(a.href)}"/>`).join('')}${u.home ? '<priority>1.0</priority>' : ''}</url>`).join('\n')}\n</urlset>\n`);
// i18n-ignore-start (사이트 파일 내용 — 화면 문구 아님)
if (!wantSitemap) { put('sitemap.xml', null); put('robots.txt', null); } // 더 이상 쓰지 않으면 우리가 만든 파일만 지운다
if (wantSitemap) put('robots.txt', `# AI Webmaker 자동 생성 (관리 탭 › 검색·AI 최적화)\nUser-agent: *\nAllow: /\n${base ? `Sitemap: ${base}sitemap.xml\n` : ''}`);
const geo = multi ? finOf(site.langs.default).geo : fin.geo;
const langLinks = multi && base ? `\n## Languages\n${site.langs.list.map((c) => `- [${multilang.LOCALES[c].name}](${base}${c}/)`).join('\n')}\n` : '';
put('llms.txt', geo && geo.useLlms && geo.llmsTxt ? geo.llmsTxt.trim() + (geo.facts && geo.facts.length ? `\n\n## 주요 사실\n${geo.facts.map((f) => `- ${f}`).join('\n')}\n` : '\n') + langLinks : null);
if (reg.indexnow && reg.indexnow.key) put(`${reg.indexnow.key}.txt`, reg.indexnow.key);
// i18n-ignore-end
return { changed };
}
// ---------- 검색사이트 등록 ----------
/** 등록할 수 있는 상태인지: 배포가 끝났고(운영 주소 있음) 생성·수정·배포가 진행 중이 아님 */
function regAvailability(site, liveUrl, busy) {
if (busy) return { ok: false, reason: '생성·수정·배포가 진행 중입니다. 끝난 뒤에 검색사이트 등록을 할 수 있습니다.' };
if (site.plan && !['generated', 'published'].includes(site.plan.stage)) return { ok: false, reason: '사이트 생성이 끝나지 않았습니다.' };
if (!liveUrl) return { ok: false, reason: '배포가 끝난 사이트만 검색사이트에 등록할 수 있습니다. 먼저 발행·배포하세요.' };
return { ok: true };
}
const ENGINES = {
google: { label: '구글 (Search Console)', console: 'https://search.google.com/search-console', meta: 'google-site-verification' },
naver: { label: '네이버 (서치어드바이저)', console: 'https://searchadvisor.naver.com/console/board', meta: 'naver-site-verification' },
};
/** meta 태그 통째로 붙여넣어도 content 값만 뽑는다 */
function codeFrom(input) { const s = String(input || '').trim(); const m = s.match(/content=["']([^"']+)["']/i); const v = (m ? m[1] : s).replace(/^google-site-verification=/, ''); if (!/^[A-Za-z0-9_\-=]{8,200}$/.test(v)) throw httpError('확인 코드 형식이 올바르지 않습니다. 검색사이트가 준 meta 태그의 content 값(또는 태그 전체)을 붙여넣으세요'); return v; }
/**
* 엔진별 단계와 상태. actor: auto(서비스가 함) | manual(사람이 검색사이트·DNS 에서 함)
* done 표시는 site.searchReg[engine].steps[stepId] 에 날짜로 남는다.
* v1.3.10: opts.googleAuto(구글 서비스 계정 키가 있음)이면 구글 단계는 모두 자동(코드 받기 → 태그·재배포 → 소유 확인 → 속성 추가·사이트맵 제출).
* 네이버는 공개 API 가 없어 "코드 받기·확인 누르기" 만 사람이 하고, 코드를 넣으면 태그·재배포·운영 확인은 자동,
* 사이트맵은 robots.txt 의 Sitemap 줄과 IndexNow(네이버 참여)로 알려지므로 제출은 선택 단계다.
*/
function steps(site, engine, liveUrl, opts = {}) {
const r = (site.searchReg || {})[engine] || {}; const E = ENGINES[engine]; const s = r.steps || {}; const base = liveUrl ? liveUrl.replace(/\/?$/, '/') : '';
const list = []; const auto = r.auto || {};
if (engine === 'google' && r.method !== 'dns' && opts.googleAuto) {
const at = (k) => (s[k] ? s[k].slice(0, 16).replace('T', ' ') : '');
list.push({ id: 'code', actor: 'auto', title: '구글 API 로 확인 코드(meta 태그)를 받습니다', detail: r.code ? '받음' : '등록을 시작하면 자동으로 받습니다', done: !!r.code });
list.push({ id: 'apply', actor: 'auto', title: '확인 태그를 첫 페이지에 넣고 다시 배포합니다', detail: r.appliedAt ? `넣고 배포함 (${r.appliedAt.slice(0, 16).replace('T', ' ')})` : '자동', done: !!r.appliedAt });
list.push({ id: 'verify', actor: 'auto', title: '구글에 소유 확인을 요청합니다', detail: s.verify ? `확인됨 (${at('verify')})${r.ownerAdded ? ` · 소유자 추가: ${r.ownerAdded}` : ''}` : '자동', done: !!s.verify });
list.push({ id: 'sitemap', actor: 'auto', title: 'Search Console 에 사이트를 추가하고 사이트맵을 제출합니다', detail: s.sitemap ? `제출함 (${at('sitemap')}) — ${base}sitemap.xml` : '자동', done: !!s.sitemap });
return list;
}
if (engine === 'google' && r.method === 'dns') {
list.push({ id: 'code', actor: 'manual', title: 'Search Console 에서 "도메인" 속성을 추가하고 TXT 확인 값을 받습니다', detail: `${E.console} › 속성 추가 › 도메인 › ${(liveUrl || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '')} 입력 → 표시되는 google-site-verification=… 값을 아래에 입력`, done: !!r.code });
list.push({ id: 'dns', actor: 'manual', title: '도메인 DNS 에 TXT 레코드를 등록합니다', detail: r.code ? `호스트 @ · 종류 TXT · 값 google-site-verification=${r.code}` : '먼저 확인 값을 입력하세요', done: !!s.dns, dnsCheck: true });
} else {
list.push({ id: 'code', actor: 'manual', title: `${E.label} 에 사이트를 추가하고 HTML 태그(meta) 확인 코드를 받습니다`, detail: engine === 'google' ? `${E.console} › 속성 추가 › URL 접두어 › ${base} 입력 › 다른 확인 방법 › HTML 태그 → content 값 복사 (관리자 콘솔 › 연동 키에 구글 서비스 계정 키를 넣으면 이 단계부터 모두 자동)` : `${E.console} › 사이트 등록 › ${base} 입력 › HTML 태그 → content 값 복사`, done: !!r.code });
list.push({ id: 'apply', actor: 'auto', title: '확인 태그를 첫 페이지에 넣고 다시 배포합니다', detail: r.appliedAt ? `넣고 배포함 (${r.appliedAt.slice(0, 16).replace('T', ' ')})${r.liveOk ? ' · 운영 페이지에서 태그 확인됨' : ''}` : '코드를 저장하면 자동으로 넣고 재배포합니다', done: !!r.appliedAt });
}
list.push({ id: 'verify', actor: 'manual', title: `${E.label} 에서 "확인" 을 누릅니다`, detail: r.liveOk || r.method === 'dns' ? '소유 확인이 성공하면 완료 표시' : '태그가 운영 페이지에 보인 뒤 누르세요', done: !!s.verify });
if (engine === 'naver') list.push({ id: 'sitemap', actor: 'manual', optional: true, title: '(선택) 사이트맵을 제출합니다', detail: `서치어드바이저 › 요청 › 사이트맵 제출 에 ${base}sitemap.xml — robots.txt 의 Sitemap 줄과 IndexNow 자동 색인 요청으로도 알려지므로 하지 않아도 됩니다`, done: !!s.sitemap });
else list.push({ id: 'sitemap', actor: 'manual', title: '사이트맵을 제출합니다', detail: `Search Console › Sitemaps 에 ${base}sitemap.xml 입력`, done: !!s.sitemap });
return list;
}
/** 엔진 상태 요약 (필수 단계만 완료 판정 — 선택 단계 제외) */
function engineStatus(site, engine, liveUrl, opts = {}) {
const r = (site.searchReg || {})[engine] || {}; if (!r.started) return { engine, label: ENGINES[engine].label, started: false, state: '시작 안 함', autoAvailable: engine === 'google' && !!opts.googleAuto };
const st = steps(site, engine, liveUrl, opts); const req = st.filter((x) => !x.optional); const doneAll = req.every((x) => x.done); const next = req.find((x) => !x.done);
const auto = r.auto || null; const running = auto && auto.state === 'running';
return { engine, label: ENGINES[engine].label, started: true, method: r.method || 'meta', code: r.code || '', steps: st, auto, autoAvailable: engine === 'google' && !!opts.googleAuto,
state: doneAll ? '등록 완료' : running ? '자동 진행 중' : auto && auto.state === 'error' ? '자동 진행 실패' : (next.actor === 'manual' ? '수동 작업 대기' : '자동 진행 대기'), next: next ? next.title : null, console: ENGINES[engine].console };
}
/** DNS TXT 확인 (google 도메인 속성) */
async function checkTxt(domain, value) { try { const recs = (await dns.resolveTxt(domain)).map((r) => r.join('')); return recs.includes(value); } catch (_) { return false; } }
/** 운영 첫 페이지에 확인 태그가 실제로 보이는지 (배포 반영 확인용) */
async function liveHasMeta(liveUrl, name, code) { try { const r = await require('./netguard').safeFetch(liveUrl, { timeout: 15000, maxBytes: 1024 * 1024, userAgent: 'AI-Webmaker-SearchReg' }); const t = r.buf.toString('utf8'); /* v1.6.31 (요구 5): 사설·내부 주소 차단, 리다이렉트마다 재검증 */ return new RegExp(`<meta[^>]+name=["']${name}["'][^>]+content=["']${code.replace(/[^\w-]/g, '')}["']`, 'i').test(t); } catch (_) { return false; } }
/** IndexNow 키 (사이트별, 처음 한 번 만든다) */
function ensureIndexNowKey(site) { site.searchReg = site.searchReg || {}; site.searchReg.indexnow = site.searchReg.indexnow || {}; if (!site.searchReg.indexnow.key) site.searchReg.indexnow.key = crypto.randomBytes(16).toString('hex'); return site.searchReg.indexnow.key; }
/**
* IndexNow 색인 요청 (빙·네이버·얀덱스 등 참여 검색엔진이 공유). 운영 주소의 키 파일로 소유를 확인한다.
* @param {string} liveUrl https://도메인/
* @param {string[]} files HTML 파일 목록
*/
async function pingIndexNow(liveUrl, key, files) {
const base = liveUrl.replace(/\/?$/, '/'); const host = new URL(base).host;
const body = { host, key, keyLocation: `${base}${key}.txt`, urlList: files.slice(0, 100).map((f) => pageUrl(base, f)) };
const r = await fetch(process.env.INDEXNOW_ENDPOINT || 'https://api.indexnow.org/indexnow', // INDEXNOW_ENDPOINT: 시험용 대체 주소
{ method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(body), signal: AbortSignal.timeout(20000) });
return { status: r.status, ok: r.status === 200 || r.status === 202, detail: r.status === 200 || r.status === 202 ? `색인 요청 접수 (${body.urlList.length}개 주소)` : r.status === 403 ? '키 파일을 찾지 못했습니다 — 재배포 후 다시 시도하세요' : r.status === 422 ? '주소가 키 파일의 도메인과 맞지 않습니다' : `응답 ${r.status}` };
}
/**
* v1.3.16 로케일별 메타·구조화 데이터 현황 (관리 탭 다국어 카드 표). 실제 페이지 파일을 읽어 언어 × 페이지마다
* title·description·canonical·og:locale·og:title·hreflang·sitemap 포함 여부, 첫 페이지의 사업체(LocalBusiness 등) 구조화 데이터와
* 그 값이 이 언어 것인지(기본 언어 값 대체 여부)를 돌려준다.
*/
function localeMatrix(site, siteDir, baseUrl) {
if (!multilang.isMulti(site)) return null;
const { list, default: def } = site.langs; const L = multilang.LOCALES; const files = multilang.htmlUnder(path.join(siteDir, def));
let sm = ''; try { sm = fs.readFileSync(path.join(siteDir, 'sitemap.xml'), 'utf8'); } catch (_) {}
const opt = site.optimize || {}; const langFin = (c) => (c === def ? opt.final : (opt.langFinals || {})[c]) || null;
const meta = (h, re) => { const m = h.match(re); return m ? m[1].replace(/&quot;/g, '"').replace(/&amp;/g, '&') : ''; };
return { baseUrl: baseUrl || null, xDefault: multilang.xDefaultOf(site), languages: list.map((c) => {
const lf = langFin(c);
const pages = files.map((file) => {
let h = ''; try { h = fs.readFileSync(path.join(siteDir, c, file), 'utf8'); } catch (_) { return { file, missing: true }; }
const url = baseUrl ? pageUrl(`${baseUrl.replace(/\/?$/, '/')}${c}/`, file) : null;
return { file, title: meta(h, /<title[^>]*>([\s\S]*?)<\/title>/i), description: meta(h, /<meta\s+name=["']description["']\s+content=["']([^"']*)["']/i), canonical: meta(h, /<link\s+rel=["']canonical["']\s+href=["']([^"']*)["']/i), ogLocale: meta(h, /<meta\s+property=["']og:locale["']\s+content=["']([^"']*)["']/i), ogTitle: meta(h, /<meta\s+property=["']og:title["']\s+content=["']([^"']*)["']/i), hreflang: (h.match(/<link rel="alternate" hreflang=/g) || []).length, inSitemap: url ? sm.includes(`<loc>${esc(url)}</loc>`) : null, canonicalOk: url ? meta(h, /<link\s+rel=["']canonical["']\s+href=["']([^"']*)["']/i) === url : null, localeOk: meta(h, /<meta\s+property=["']og:locale["']\s+content=["']([^"']*)["']/i) === L[c].og };
});
let home = ''; try { home = fs.readFileSync(path.join(siteDir, c, 'index.html'), 'utf8'); } catch (_) {}
const schema = (home.match(/"@type":"(LocalBusiness|Organization|Restaurant|Store|ProfessionalService|MedicalClinic|EducationalOrganization)"/) || [])[1] || null;
return { lang: c, name: L[c].name, label: L[c].label, ogLocale: L[c].og, hreflang: L[c].html, seo: !!(lf && lf.seo), aeo: !!(lf && lf.aeo), geo: lf && lf.geo ? 'own' : (langFin(def) && langFin(def).geo ? 'default' : 'none'), schema, pages };
}) };
}
module.exports = { localeMatrix, wantedFrom, anyWanted, htmlFiles, pageFiles, draftPrompt, localizePrompt, normalize, normalizeFinal, materialize, regAvailability, ENGINES, codeFrom, steps, engineStatus, checkTxt, liveHasMeta, ensureIndexNowKey, pingIndexNow, pageUrl };