'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() 는 같은 입력이면 같은 결과를 내도록(멱등) 표시 주석으로 관리 구역을 나눠 덮어쓴다. // <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, '&').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 = /<!-- 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)}`); 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

${esc(ctx.faqTitle || FAQ_TITLE.ko)}

${visible.map((f) => `
${esc(f.q)}

${esc(f.a)}

`).join('')}
\n\n`; if (/]/i.test(html)) html = html.replace(/]/i, (m) => `${sec}${m}`); else if (/<\/main>/i.test(html)) html = html.replace(/<\/main>/i, `${sec}`); else html = html.replace(/<\/body>/i, `${sec}`); } 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', `\n\n${sitemapUrls.map((u) => ` ${esc(u.loc)}${(u.alts || []).map((a) => ``).join('')}${u.home ? '1.0' : ''}`).join('\n')}\n\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(`]+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(/"/g, '"').replace(/&/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, /]*>([\s\S]*?)<\/title>/i), description: meta(h, /${esc(url)}`) : null, canonicalOk: url ? meta(h, /