Files
AriWeb-Suite/modules/webmaker/public/optimize.js

274 lines
35 KiB
JavaScript

'use strict';
/* ============================================================================
* 관리 탭: 검색·AI 최적화(SEO·AEO·GEO) 검토·반영, 검색사이트 등록 (v1.3.5)
*
* 검색·AI 최적화 카드 (#optimizeCard)
* - 생성 옵션으로 고른 항목은 생성이 끝나면 AI 가 초안을 만든다(GET/POST /api/sites/<id>/optimize[/draft]).
* - 사용자가 초안의 값을 고치고 항목별 "반영" 체크를 정한 뒤 "검토한 내용 반영" 을 누르면(POST …/optimize/apply)
* 서버가 HTML <head>·FAQ 영역·sitemap.xml·robots.txt·llms.txt 에 넣고 버전을 저장한다.
* - 각 입력에 작성 기준(글자 수 등)과 현재 글자 수를 보여 줘 검토를 돕는다.
* 검색사이트 등록 카드 (#searchCard)
* - 배포가 끝난 사이트만. 구글·네이버: 확인 코드 입력 → 태그 넣고 재배포 → 검색사이트에서 확인 → 사이트맵 제출.
* 사람이 해야 하는 단계는 체크리스트로 보이며 완료 표시를 할 수 있다(관리자 콘솔 "검색 등록" 탭에서도 진행 가능).
* - IndexNow: 키 파일을 올리고 빙·네이버 등에 색인 요청.
* 전역: window.OptimizeUI.onSiteOpened(site) — app.js 가 사이트를 열거나 작업이 끝날 때 부른다.
* ========================================================================== */
(() => {
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
const btn = (label, cls, fn) => { const b = el('button', cls || 'ghost small-btn', label); b.type = 'button'; b.onclick = fn; return b; };
let siteId = null; let ov = null; let ed = null; // ov: 서버 상태, ed: 편집 중인 값 (draft 또는 final 의 복사본)
// v1.3.10 다국어: edAll[언어] = 언어별 편집 값, curLang = 지금 보는 언어 탭 (단일 언어 사이트는 edAll 없음)
let edAll = null; let curLang = null;
const LABEL = { none: '사용 안 함', waiting: '생성 후 초안 예정', drafting: '초안 만드는 중', draft: '검토 대기', applied: '반영됨', error: '초안 실패' };
// ---------- 검색·AI 최적화 ----------
async function loadOptimize() {
try { ov = await api(`/api/sites/${siteId}/optimize`); } catch (_) { return; }
const st = ov.status; $('optState').textContent = LABEL[st] || st; $('optState').className = 'pill small ' + (st === 'applied' ? 'done' : st === 'draft' ? 'waiting' : st === 'drafting' || st === 'waiting' ? 'running' : st === 'error' ? 'error' : 'idle');
const box = $('optBody'); box.innerHTML = '';
if (st === 'drafting' || st === 'waiting') { box.appendChild(el('p', 'muted small', st === 'waiting' ? '사이트 생성·검증이 끝나면 AI 가 초안을 만듭니다.' : 'AI 가 사이트 페이지를 읽고 초안을 만들고 있습니다. 끝나면 여기서 검토할 수 있습니다.')); return; }
if (st === 'none' || st === 'error' || (!ov.draft && !ov.final)) { renderStarter(box, st === 'error' ? ov.error : null); return; }
ed = JSON.parse(JSON.stringify(ov.applied && ov.final ? ov.final : ov.draft));
edAll = null;
if (ov.langs) {
// 언어별 편집 값: 기본 언어는 위의 ed, 다른 언어는 반영 값 → 없으면 그 언어 초안
edAll = {}; for (const l of ov.langs) { const src = l.isDefault ? ed : ((ov.applied && ov.langFinals[l.code]) || ov.langDrafts[l.code] || null); edAll[l.code] = l.isDefault ? ed : (src ? JSON.parse(JSON.stringify(src)) : null); }
if (!curLang || !edAll.hasOwnProperty(curLang)) curLang = ov.langs[0].code;
}
renderLangTabs(box);
}
/** v1.3.10 언어 탭 (다국어 사이트): 탭마다 그 언어 폴더(/en/ 등)에 들어갈 값을 따로 검토한다 */
function renderLangTabs(box) {
box.innerHTML = '';
if (!edAll) { renderEditor(box); return; }
const tabs = el('div', 'opt-langs');
for (const l of ov.langs) { const b = btn(`${l.name}${l.isDefault ? ' · ' + T('기본') : ''}`, 'ghost small-btn' + (curLang === l.code ? ' on' : ''), () => { curLang = l.code; renderLangTabs(box); }); b.lang = l.code; tabs.appendChild(b); }
box.appendChild(tabs);
const cur = ov.langs.find((l) => l.code === curLang);
box.appendChild(el('p', 'muted small', TF('{1} 페이지(/{2}/)에 들어갈 값입니다. 언어마다 따로 검토하고, 아래 "반영" 은 모든 언어를 함께 반영합니다.', `${cur.name} (${T(cur.label)})`, cur.code)));
if (!edAll[curLang]) { box.appendChild(el('p', 'msg err small', ov.langErrors && ov.langErrors[curLang] ? TF('이 언어 초안을 만들지 못했습니다: {1}', ov.langErrors[curLang]) : T('이 언어 초안이 아직 없습니다. "AI 로 다시 만들기" 로 만드세요.'))); renderEditor(box, true); return; }
ed = edAll[curLang]; renderEditor(box);
// 값 입력칸만 그 언어의 글 방향·언어로 (아랍어 등은 오른쪽→왼쪽). 안내 문구·버튼은 화면 언어 그대로
box.querySelectorAll('.opt-field input, .opt-field textarea').forEach((i) => { i.dir = cur.dir; i.lang = cur.code; });
}
/** 초안이 없을 때: 항목 고르고 AI 초안 만들기 */
function renderStarter(box, err) {
if (err) box.appendChild(el('p', 'msg err small', '초안을 만들지 못했습니다: ' + err));
box.appendChild(el('p', 'muted small', '검색엔진(SEO)·AI 답변 엔진(AEO)·생성형 AI 검색(GEO)에 맞는 값을 AI 가 사이트 내용으로 초안을 만들고, 검토 후 반영합니다. (기본 등급, 캐시 차감)'));
const row = el('div', 'row'); const ck = {};
for (const [k, l] of [['seo', 'SEO'], ['aeo', 'AEO'], ['geo', 'GEO']]) { const lab = el('label', 'chk'); const c = el('input'); c.type = 'checkbox'; c.checked = ov.wanted[k] !== false; ck[k] = c; lab.appendChild(c); lab.appendChild(document.createTextNode(' ' + l)); row.appendChild(lab); }
row.appendChild(btn('AI 로 초안 만들기', 'primary small-btn', async () => { try { await api(`/api/sites/${siteId}/optimize/draft`, { method: 'POST', body: JSON.stringify({ wanted: { seo: ck.seo.checked, aeo: ck.aeo.checked, geo: ck.geo.checked } }) }); setTimeout(loadOptimize, 400); } catch (e) { alert(e.message); } }));
box.appendChild(row);
}
/** 글자 수 안내가 붙은 입력. rule: [권장 최소, 권장 최대, 최대] */
function field(label, obj, key, opts = {}) {
const wrap = el('label', 'opt-field'); const head = el('span', '', label); wrap.appendChild(head);
const inp = opts.area ? el('textarea') : el('input'); if (opts.area) inp.rows = opts.rows || 3; inp.value = Array.isArray(obj[key]) ? obj[key].join(opts.sep || ', ') : (obj[key] || ''); if (opts.placeholder) inp.placeholder = opts.placeholder;
const cnt = opts.rule ? el('span', 'opt-count') : null; if (cnt) head.appendChild(cnt);
const upd = () => { const v = inp.value; obj[key] = opts.list ? v.split(opts.sep === '\n' ? /\n/ : /,/).map((x) => x.trim()).filter(Boolean) : v; if (cnt) { const n = v.trim().length; const [lo, hi, max] = opts.rule; cnt.textContent = ' ' + (max ? TF('{1}자 (권장 {2}~{3}, 최대 {4})', n, lo, hi, max) : TF('{1}자 (권장 {2}~{3})', n, lo, hi)); /* v1.3.10: 조각 번역(TF) — 값이 인접한 문장은 화면에서 나눠 맞출 수 없음 */ cnt.className = 'opt-count ' + (n > (max || hi) ? 'bad' : n < lo || n > hi ? 'warn' : 'ok'); } };
inp.oninput = upd; upd(); wrap.appendChild(inp); return wrap;
}
const useBox = (obj, key, label) => { const lab = el('label', 'chk'); const c = el('input'); c.type = 'checkbox'; c.checked = obj[key] !== false; c.onchange = () => { obj[key] = c.checked; }; lab.appendChild(c); lab.appendChild(document.createTextNode(' ' + (label || '반영'))); return lab; };
function section(box, title, help) { const d = el('details', 'opt-sec'); d.open = true; const s = el('summary', '', title); d.appendChild(s); if (help) d.appendChild(el('p', 'muted small', help)); box.appendChild(d); return d; }
/** 초안(또는 반영값) 편집 화면 */
function renderEditor(box, buttonsOnly) {
if (!buttonsOnly) box.appendChild(el('p', 'muted small', ov.applied ? TF('반영됨 ({1}). 값을 고친 뒤 다시 반영할 수 있습니다.', new Date(ov.appliedAt).toLocaleString(uiLocale())) + (ov.liveBase ? '' : ' ' + T('운영 주소가 생기면(배포) canonical·사이트맵 주소가 자동으로 채워집니다.')) : TF('AI 초안입니다 ({1}).', ov.draftAt ? new Date(ov.draftAt).toLocaleString(uiLocale()) : '') + ' ' + T(' 내용을 확인·수정하고 반영할 항목만 체크한 뒤 "검토한 내용 반영" 을 누르세요. 반영 전에는 사이트에 들어가지 않습니다.')));
if (!buttonsOnly && ed.seo) {
const d = section(box, 'SEO — 검색 결과 제목·설명·키워드', '제목은 핵심어를 앞쪽에, 페이지마다 다르게. 설명은 페이지 내용을 요약하고 행동을 유도합니다. 과장·키워드 나열은 피하세요.');
const two = el('div', 'two'); two.appendChild(field('사이트 이름', ed.seo, 'siteName')); two.appendChild(field('사이트 핵심 검색어 (쉼표)', ed.seo, 'keywords', { list: true })); d.appendChild(two);
for (const pg of ed.seo.pages) {
const card = el('div', 'opt-page'); const h = el('div', 'opt-page-head'); h.appendChild(el('b', '', pg.file)); h.appendChild(useBox(pg, 'use')); card.appendChild(h);
card.appendChild(field('검색 제목 (title)', pg, 'title', { rule: [25, 35, 60] }));
card.appendChild(field('검색 설명 (meta description)', pg, 'description', { area: true, rows: 2, rule: [70, 110, 155] }));
card.appendChild(field('페이지 검색어 (쉼표)', pg, 'keywords', { list: true }));
const more = el('details', 'opt-more'); more.appendChild(el('summary', '', '공유 미리보기 (SNS)·h1'));
more.appendChild(field('공유 제목 (og:title)', pg, 'ogTitle', { rule: [10, 40, 90] })); more.appendChild(field('공유 설명 (og:description)', pg, 'ogDescription', { area: true, rows: 2, rule: [40, 100, 200] }));
more.appendChild(field('h1 제안 (참고용 — 본문은 바꾸지 않음)', pg, 'h1')); card.appendChild(more); d.appendChild(card);
}
}
if (!buttonsOnly && ed.aeo) {
const d = section(box, 'AEO — 질문형 FAQ·핵심 답변', '질문은 실제 고객이 검색할 문장으로, 답은 첫 문장에 바로 답하고 2~4문장 이내로. "사이트에 있음" 이 아닌 질문은 반영 시 해당 페이지에 "자주 묻는 질문" 영역으로 추가됩니다.');
d.appendChild(field('핵심 답변 요약 (AI·음성 답변용)', ed.aeo, 'summary', { area: true, rows: 2, rule: [60, 200, 400] }));
const list = el('div'); d.appendChild(list);
const drawFaqs = () => {
list.innerHTML = '';
ed.aeo.faqs.forEach((f, i) => {
const card = el('div', 'opt-page'); const h = el('div', 'opt-page-head'); h.appendChild(el('b', '', `질문 ${i + 1}`)); h.appendChild(useBox(f, 'use'));
const onp = el('label', 'chk'); const oc = el('input'); oc.type = 'checkbox'; oc.checked = !!f.onPage; oc.onchange = () => { f.onPage = oc.checked; }; onp.appendChild(oc); onp.appendChild(document.createTextNode(' 사이트에 이미 있음')); h.appendChild(onp);
const sel = el('select'); sel.style.width = 'auto'; sel.style.margin = '0'; for (const fl of ov.files) sel.appendChild(new Option(fl, fl)); sel.value = f.page; sel.onchange = () => { f.page = sel.value; }; h.appendChild(sel);
h.appendChild(btn('삭제', 'ghost small-btn', () => { ed.aeo.faqs.splice(i, 1); drawFaqs(); })); card.appendChild(h);
card.appendChild(field('질문', f, 'q', { rule: [8, 40, 200] })); card.appendChild(field('답변', f, 'a', { area: true, rows: 3, rule: [40, 200, 800] })); list.appendChild(card);
});
list.appendChild(btn('+ 질문 추가', 'ghost small-btn', () => { ed.aeo.faqs.push({ q: '', a: '', page: ov.files[0] || 'index.html', onPage: false, use: true }); drawFaqs(); }));
};
drawFaqs();
}
if (!buttonsOnly && ed.geo) {
const d = section(box, 'GEO — 사업체 정보·인용용 사실·llms.txt', '생성형 AI 검색이 사업체를 정확히 알아보고 인용하도록 합니다. 사실과 다른 값은 지우세요(모르는 값은 비워 둡니다).');
const e = ed.geo.entity; const h = el('div', 'opt-page-head'); h.appendChild(el('b', '', '사업체 정보 (구조화 데이터)')); h.appendChild(useBox(ed.geo, 'useEntity')); d.appendChild(h);
const g1 = el('div', 'two'); const tsel = el('label', 'opt-field'); tsel.appendChild(el('span', '', '유형')); const ts = el('select'); for (const t of ['Organization', 'LocalBusiness', 'Restaurant', 'Store', 'ProfessionalService', 'MedicalClinic', 'EducationalOrganization']) ts.appendChild(new Option(t, t)); ts.value = e.type; ts.onchange = () => { e.type = ts.value; }; tsel.appendChild(ts); g1.appendChild(tsel); g1.appendChild(field('이름', e, 'name')); d.appendChild(g1);
d.appendChild(field('소개', e, 'description', { area: true, rows: 2 }));
const g2 = el('div', 'two'); g2.appendChild(field('전화', e, 'telephone')); g2.appendChild(field('이메일', e, 'email')); d.appendChild(g2);
const g3 = el('div', 'two'); g3.appendChild(field('주소 (도로명)', e.address, 'streetAddress')); g3.appendChild(field('시·군·구', e.address, 'addressLocality')); g3.appendChild(field('시·도', e.address, 'addressRegion')); g3.appendChild(field('우편번호', e.address, 'postalCode')); d.appendChild(g3);
const g4 = el('div', 'two'); g4.appendChild(field('영업시간 (쉼표, 예: Mo-Fr 09:00-18:00)', e, 'openingHours', { list: true })); g4.appendChild(field('가격대', e, 'priceRange')); d.appendChild(g4);
d.appendChild(field('공식 SNS·지도 주소 (한 줄에 하나)', e, 'sameAs', { area: true, rows: 2, list: true, sep: '\n' }));
d.appendChild(field('인용용 핵심 사실 (한 줄에 하나)', ed.geo, 'facts', { area: true, rows: 4, list: true, sep: '\n' }));
const h2 = el('div', 'opt-page-head'); h2.appendChild(el('b', '', 'llms.txt (AI 크롤러용 사이트 요약)')); h2.appendChild(useBox(ed.geo, 'useLlms')); d.appendChild(h2);
const ll = field('', ed.geo, 'llmsTxt', { area: true, rows: 8 }); ll.querySelector('textarea').classList.add('mono'); d.appendChild(ll);
}
const row = el('div', 'row'); const msg = el('span', 'muted small');
row.appendChild(btn(ov.applied ? '고친 내용 다시 반영' : '검토한 내용 반영', 'primary small-btn', async () => {
msg.textContent = T('반영 중…');
// 다국어: 기본 언어 값은 final, 다른 언어는 langs[언어] 로 함께 보낸다
const body = edAll ? { final: edAll[ov.langs[0].code], langs: Object.fromEntries(ov.langs.slice(1).filter((l) => edAll[l.code]).map((l) => [l.code, edAll[l.code]])) } : { final: ed };
try { const r = await api(`/api/sites/${siteId}/optimize/apply`, { method: 'POST', body: JSON.stringify(body) }); msg.textContent = TF('반영했습니다 — 바뀐 파일 {1}개.', r.changed.length) + (r.changed.length ? ` (${r.changed.slice(0, 5).join(', ')}${r.changed.length > 5 ? ' …' : ''})` : '') + ' ' + T('운영 사이트에는 다음 배포 때 올라갑니다.'); if (typeof current !== 'undefined' && current && r.site) Object.assign(current, r.site); /* app.js 의 현재 사이트(전역 let) */ if (typeof loadFrame === 'function') loadFrame(); setTimeout(loadOptimize, 1500); }
catch (e) { msg.textContent = e.message; }
}));
row.appendChild(btn('AI 로 다시 만들기', 'ghost small-btn', async () => { if (!confirm('AI 초안을 다시 만듭니다(캐시 차감). 지금 고친 값은 반영하지 않으면 사라집니다. 계속할까요?')) return; try { await api(`/api/sites/${siteId}/optimize/draft`, { method: 'POST', body: JSON.stringify({ wanted: ov.wanted && (ov.wanted.seo || ov.wanted.aeo || ov.wanted.geo) ? ov.wanted : { seo: !!ed.seo, aeo: !!ed.aeo, geo: !!ed.geo } }) }); setTimeout(loadOptimize, 400); } catch (e) { alert(e.message); } }));
if (ov.applied) row.appendChild(btn('반영한 값 빼기', 'ghost small-btn danger', async () => { if (!confirm('사이트에 넣은 검색·AI 최적화 값(관리 구역·FAQ 영역·자동 생성 파일)을 뺍니다. 초안은 남습니다. 계속할까요?')) return; try { const r = await api(`/api/sites/${siteId}/optimize/remove`, { method: 'POST', body: '{}' }); msg.textContent = TF('뺐습니다 — 바뀐 파일 {1}개', r.changed.length); if (typeof loadFrame === 'function') loadFrame(); loadOptimize(); } catch (e) { alert(e.message); } }));
row.appendChild(msg); box.appendChild(row);
}
// ---------- 검색사이트 등록 ----------
async function loadSearch() {
let v; try { v = await api(`/api/sites/${siteId}/search`); } catch (_) { return; }
const box = $('searchBody'); box.innerHTML = '';
if (!v.availability.ok) { box.appendChild(el('p', 'muted small', v.availability.reason)); return; }
box.appendChild(el('p', 'muted small', TF('운영 주소 {1} 기준으로 등록합니다.', v.liveUrl) + (v.seoApplied ? '' : ' ' + T('검색·AI 최적화 값을 먼저 반영하면 검색 결과 제목·설명이 더 좋아집니다.'))));
for (const e of v.engines) box.appendChild(engineBlock(e));
// IndexNow
const ix = el('div', 'reg-engine'); ix.appendChild(el('b', '', 'IndexNow (빙·네이버 등 색인 요청)'));
ix.appendChild(el('p', 'muted small', v.indexnow && v.indexnow.last ? TF('마지막 요청 {1}: {2}', new Date(v.indexnow.lastPingAt).toLocaleString(uiLocale()), T(v.indexnow.last.detail)) : '사이트에 키 파일을 올리고(재배포) 모든 페이지의 색인을 요청합니다. 페이지를 고친 뒤에도 다시 보낼 수 있습니다.'));
ix.appendChild(btn('색인 요청 보내기', 'ghost small-btn', () => act('indexnow', {})));
box.appendChild(ix);
}
/** 검색엔진 한 개의 등록 단계 표시 */
function engineBlock(e) {
const d = el('div', 'reg-engine'); const head = el('div', 'opt-page-head'); head.appendChild(el('b', '', e.label)); head.appendChild(el('span', 'pill small ' + (e.state === '등록 완료' ? 'done' : e.started ? 'waiting' : 'idle'), e.state)); d.appendChild(head);
if (!e.started) {
const row = el('div', 'row');
if (e.engine === 'google' && e.autoAvailable) { d.appendChild(el('p', 'muted small', T('구글 API 로 확인 코드 받기 → 태그 넣고 재배포 → 소유 확인 → Search Console 등록·사이트맵 제출까지 모두 자동으로 진행합니다.'))); row.appendChild(btn('자동 등록 시작', 'primary small-btn', () => act('start', { engine: e.engine, method: 'meta' }))); } /* v1.3.10 */
else if (e.engine === 'google') { const sel = el('select'); sel.style.width = 'auto'; sel.style.margin = '0'; sel.appendChild(new Option('HTML 태그로 확인 (권장)', 'meta')); sel.appendChild(new Option('도메인 DNS(TXT)로 확인', 'dns')); row.appendChild(sel); row.appendChild(btn('등록 시작', 'primary small-btn', () => act('start', { engine: e.engine, method: sel.value }))); }
else row.appendChild(btn('등록 시작', 'primary small-btn', () => act('start', { engine: e.engine })));
d.appendChild(row); return d;
}
// v1.3.10 자동 진행 상태 (구글 서비스 계정)
if (e.auto) { const p = el('p', 'small ' + (e.auto.state === 'error' ? 'msg err' : 'muted'), `${e.auto.state === 'running' ? '⏳ ' : e.auto.state === 'done' ? '✓ ' : '✗ '}${T(e.auto.detail)}`); d.appendChild(p); if (e.auto.state === 'running') setTimeout(loadSearch, 4000); }
if (e.autoAvailable && e.method !== 'dns' && (!e.auto || e.auto.state === 'error') && e.state !== '등록 완료') { const r0 = el('div', 'row'); r0.appendChild(btn(e.auto ? '자동 진행 다시' : '자동 진행으로 전환', 'primary small-btn', () => act('auto', { engine: e.engine }))); d.appendChild(r0); }
const ol = el('ol', 'reg-steps');
for (const s of e.steps) {
const li = el('li', s.done ? 'done' : ''); li.appendChild(el('span', 'reg-actor ' + s.actor, s.actor === 'auto' ? '자동' : '직접')); li.appendChild(el('b', '', ' ' + s.title)); li.appendChild(el('div', 'muted small', s.detail));
const row = el('div', 'row');
if (s.id === 'code' && s.actor === 'manual') { const inp = el('input'); inp.placeholder = '확인 코드 또는 meta 태그 전체'; inp.value = e.code || ''; inp.style.flex = '1'; row.appendChild(inp); row.appendChild(btn(e.method === 'dns' ? '저장' : '저장하고 태그 넣기·재배포', 'ghost small-btn', () => act('code', { engine: e.engine, code: inp.value }))); }
if (s.actor === 'auto' && e.autoAvailable && e.method !== 'dns') { /* 구글 자동 진행 단계 — 버튼 없음 */ }
else if (s.id === 'apply' && e.code) row.appendChild(btn(s.done ? '다시 넣고 재배포' : '태그 넣고 재배포', 'primary small-btn', () => act('apply', { engine: e.engine })));
if (s.id === 'dns' && e.code) row.appendChild(btn('DNS 확인', 'ghost small-btn', () => act('check', { engine: e.engine })));
if (s.id === 'verify' && e.method !== 'dns' && e.code) row.appendChild(btn('운영 페이지에 태그 있는지 확인', 'ghost small-btn', () => act('check', { engine: e.engine })));
if (['verify', 'sitemap', 'dns'].includes(s.id) && s.actor === 'manual') row.appendChild(btn(s.done ? '완료 취소' : '완료 표시', 'ghost small-btn', () => act('step', { engine: e.engine, step: s.id, done: !s.done })));
if (row.children.length) li.appendChild(row); ol.appendChild(li);
}
d.appendChild(ol);
const foot = el('div', 'row'); const a = el('a', 'btn ghost small-btn', TF('{1} 열기', T(e.label))); a.href = e.console; a.target = '_blank'; a.rel = 'noopener'; foot.appendChild(a);
foot.appendChild(btn('등록 기록 지우기', 'ghost small-btn', () => { if (confirm('이 검색사이트의 등록 진행 기록을 지웁니다(사이트의 확인 태그는 다음 배포 때 빠집니다). 계속할까요?')) act('stop', { engine: e.engine }); })); d.appendChild(foot);
return d;
}
/** 등록 동작 호출 → 결과 표시 후 다시 그림 */
async function act(action, body) {
try { const r = await api(`/api/sites/${siteId}/search/${action}`, { method: 'POST', body: JSON.stringify(body) }); if (r.check) alert(`${r.check.ok ? '✓' : '✗'} ${r.check.detail}`); if (action === 'indexnow' && r.indexnow && r.indexnow.last) alert(r.indexnow.last.detail); }
catch (e) { alert(e.message); }
loadSearch();
}
// ---------- v1.3.10 다국어 카드 ----------
/** 지금 언어 목록과 추가·빼기 체크 (GET/PUT /api/sites/<id>/langs) */
async function loadLangs(site) {
const box = $('langBody'); let d; try { d = await api(`/api/sites/${site.id}/langs`); } catch (_) { return; }
box.innerHTML = ''; const L = d.langs; const multi = !!(L && L.list && L.list.length > 1);
$('langState').textContent = multi ? TF('{1}개 언어', L.list.length) : T('한 언어'); $('langState').className = 'pill small ' + (multi ? 'done' : 'idle');
if (site.source) { box.appendChild(el('p', 'muted small', T('원본 서버에서 가져온 사이트는 구조를 바꿀 수 없어 다국어로 만들 수 없습니다. "사이트 복사하기" 로 새 사이트를 만든 뒤 사용하세요.'))); return; }
if (multi) {
const now = el('div', 'lang-now'); for (const c of L.list) { const lc = d.locales.find((x) => x.code === c); const a = el('a', '', `/${c}/ ${lc ? lc.name : c}${c === L.default ? ' · ' + T('기본') : ''}`); a.href = `${site.url.replace(/\/?$/, '/')}${c}/`; a.target = '_blank'; a.rel = 'noopener'; now.appendChild(a); } box.appendChild(now);
if (L.syncedAt) box.appendChild(el('p', 'muted small', TF('마지막 번역 맞춤: {1}', new Date(L.syncedAt).toLocaleString(uiLocale()))));
} else box.appendChild(el('p', 'muted small', T('지금은 한 언어 사이트입니다. 추가 언어를 고르면 AI 가 페이지를 기본 언어 폴더로 옮긴 뒤(수정 작업 1회) 다른 언어 페이지를 만듭니다.')));
let defSel = null;
if (!multi) { const lab = el('label', 'lang-default', T('기본 언어 (지금 사이트의 언어)')); defSel = el('select'); for (const l of d.locales) defSel.appendChild(new Option(`${l.name} — ${T(l.label)}`, l.code)); defSel.value = site.uiLang && d.locales.some((l) => l.code === site.uiLang) ? site.uiLang : 'ko'; lab.appendChild(defSel); box.appendChild(lab); }
const chips = el('div', 'lang-chips'); const def = () => (multi ? L.default : defSel.value);
for (const l of d.locales) { const lab = el('label', 'chk lang-chip'); const c = el('input'); c.type = 'checkbox'; c.value = l.code; c.checked = multi && L.list.includes(l.code); lab.appendChild(c); lab.appendChild(document.createTextNode(' ' + l.name)); lab.title = T(l.label); chips.appendChild(lab); }
const syncDef = () => { for (const i of chips.querySelectorAll('input')) { const isDef = i.value === def(); i.disabled = isDef; if (isDef) i.checked = true; i.parentElement.classList.toggle('off', isDef); } };
if (defSel) defSel.onchange = syncDef; syncDef(); box.appendChild(chips);
const row = el('div', 'row'); const msg = el('span', 'muted small');
row.appendChild(btn(multi ? T('언어 변경 적용') : T('다국어 사이트로 바꾸기'), 'primary small-btn', async () => {
const list = [...chips.querySelectorAll('input:checked')].map((i) => i.value); if (list.length < 2) { msg.textContent = T('추가 언어를 하나 이상 고르세요'); return; }
if (!confirm(multi ? T('언어 목록을 바꿉니다. 새 언어는 번역(캐시 차감)하고, 뺀 언어 폴더는 지웁니다. 계속할까요?') : T('AI 가 페이지를 기본 언어 폴더로 옮기고(수정 작업, 캐시 차감) 다른 언어 페이지를 번역해 만듭니다. 계속할까요?'))) return;
msg.textContent = T('진행 중…'); try { await api(`/api/sites/${site.id}/langs`, { method: 'PUT', body: JSON.stringify({ default: def(), list, xDefault: multi ? (L.xDefault || L.default) : def() }) }); msg.textContent = T('시작했습니다. 진행 상황은 작업 로그에 보입니다.'); } catch (e) { msg.textContent = e.message; }
}));
row.appendChild(msg); box.appendChild(row);
langSettings(site, d, box, multi);
if (multi) loadQuality(site, box);
}
/**
* v1.3.16 다국어·현지 설정: URL 원칙 안내, x-default, 사업장 시간대·통화·국가(현지 형식의 기준), 사이트 관리 언어(사이트 관리자 화면·알림 메일).
* 저장하면 AI 없이 언어 선택 페이지·현지 형식·검색 값만 다시 쓴다(PUT /api/sites/<id>/langs/settings).
*/
function langSettings(site, d, box, multi) {
const sec = el('details', 'opt-sec'); sec.appendChild(el('summary', '', T('주소 원칙 · x-default · 현지 설정'))); box.appendChild(sec);
sec.appendChild(el('p', 'muted small', T(d.principle || '')));
const two = el('div', 'two'); let xs = null;
if (multi) { const lab = el('label', 'opt-field'); lab.appendChild(el('span', '', T('x-default (다른 언어 방문자에게 보일 언어)'))); xs = el('select'); for (const c of d.langs.list) { const l = d.locales.find((x) => x.code === c); xs.appendChild(new Option(`${l ? l.name : c} (${c})`, c)); } xs.appendChild(new Option(T('언어 선택 페이지 (자동 이동 없음)'), 'root')); xs.value = d.langs.xDefault || d.langs.default; lab.appendChild(xs); two.appendChild(lab); }
const al = el('label', 'opt-field'); al.appendChild(el('span', '', T('사이트 관리 언어 (사이트 관리자 화면·알림 메일)'))); const as = el('select'); for (const l of d.locales) as.appendChild(new Option(`${l.name} (${l.code})`, l.code)); as.value = d.adminLang || 'ko'; al.appendChild(as); two.appendChild(al); sec.appendChild(two);
const three = el('div', 'two'); const inp = (label, val, ph) => { const lab = el('label', 'opt-field'); lab.appendChild(el('span', '', label)); const i = el('input'); i.value = val || ''; i.placeholder = ph; lab.appendChild(i); three.appendChild(lab); return i; };
const tz = inp(T('사업장 시간대'), d.locale.timeZone, 'Asia/Seoul'); const cur = inp(T('가격 통화 (ISO 4217)'), d.locale.currency, 'KRW'); const co = inp(T('사업장 국가 (ISO 3166)'), d.locale.country, 'KR'); sec.appendChild(three);
sec.appendChild(el('p', 'muted small', T('날짜·시각은 사업장 시간대로, 가격은 이 통화로(언어마다 기호·자릿수 형식만 바뀜), 전화번호는 페이지 지역이 사업장 국가와 같으면 국내식·다르면 국제식(+82)으로, 주소·이름은 언어마다 순서가 바뀌어 표시됩니다. 페이지에서는 data-wm-date·datetime·price·tel·address·name·legal 속성으로 쓰입니다.')));
const r2 = el('div', 'row'); const m2 = el('span', 'muted small');
r2.appendChild(btn(T('설정 저장'), 'primary small-btn', async () => { m2.textContent = T('저장 중…'); try { const body = { adminLang: as.value, timeZone: tz.value.trim(), currency: cur.value.trim(), country: co.value.trim() }; if (xs) body.xDefault = xs.value; const r = await api(`/api/sites/${site.id}/langs/settings`, { method: 'PUT', body: JSON.stringify(body) }); m2.textContent = TF('저장했습니다 — 바뀐 파일 {1}개', r.changed); if (typeof loadFrame === 'function') loadFrame(); } catch (e) { m2.textContent = e.message; } }));
r2.appendChild(m2); sec.appendChild(r2);
}
const pct = (x) => `${(x * 100).toFixed(1)}%`;
const REVIEW = { approved: '검수 완료', rejected: '수정 필요', pending: '검수 대기' };
/** v1.3.16 번역 품질 게이트(누락률·잔존율·링크 대응·검수)와 로케일별 메타·구조화 데이터 현황 */
async function loadQuality(site, box) {
let d; try { d = await api(`/api/sites/${site.id}/langs/quality`); } catch (_) { return; }
const q = d.quality; if (!q) return;
const sec = el('details', 'opt-sec'); sec.open = !q.pass; const sm = el('summary', '', `${T('번역 품질 게이트')} — ${q.pass ? T('통과') : T('미통과')}`); sec.appendChild(sm); box.appendChild(sec);
sec.appendChild(el('p', 'muted small', TF('기준: 번역 누락률 ≤ {1} (시스템 문구 누락 0), 원문 잔존율 ≤ {2}, 사이트 안 링크가 기본 언어와 같은 짝, 언어별 검수 완료. 페이지가 바뀌면 그 언어는 다시 "검수 대기" 가 됩니다.', pct(q.thresholds.maxMissing), pct(q.thresholds.maxResidue))));
const wrap = el('div', 'table-wrap'); const t = el('table', 'q-table'); wrap.appendChild(t); sec.appendChild(wrap);
const hr = el('tr'); for (const h of ['언어', '누락률', '잔존율', '링크', '검수', '']) hr.appendChild(el('th', '', T(h))); t.appendChild(hr);
for (const [c, r] of Object.entries(q.languages)) {
const tr = el('tr'); tr.appendChild(el('td', '', c + (r.source ? ' · ' + T('기본') : '')));
if (r.source) { const td0 = el('td', 'muted', T('원문')); td0.colSpan = 3; tr.appendChild(td0); tr.appendChild(el('td', 'ok', T(REVIEW.approved))); tr.appendChild(el('td')); t.appendChild(tr); continue; }
const cell = (ok, text, title) => { const x = el('td', ok ? 'ok' : 'bad', text); if (title) x.title = title; return x; };
tr.appendChild(cell(r.checks.missing, pct(r.missing.rate) + (r.runtimeMissing ? ` · ${TF('시스템 문구 {1}', r.runtimeMissing)}` : ''), r.missing.samples.join('\n')));
tr.appendChild(cell(r.checks.residue, pct(r.residue.rate), r.residue.samples.join('\n')));
tr.appendChild(cell(r.checks.links, r.links.ok ? T('일치') : T('불일치'), [...r.links.issues, ...r.links.missingPages].join('\n')));
tr.appendChild(cell(r.checks.review, T(REVIEW[r.review] || r.review), r.reviewNote || ''));
const act = el('td'); const go = (state) => async () => { const note = state === 'rejected' ? prompt(T('고칠 점을 적어 주세요 (선택)')) || '' : ''; try { await api(`/api/sites/${site.id}/langs/review`, { method: 'POST', body: JSON.stringify({ lang: c, state, note }) }); loadLangs(site); } catch (e) { alert(e.message); } };
const a = el('a', 'btn ghost small-btn', T('보기')); a.href = `${site.url.replace(/\/?$/, '/')}${c}/`; a.target = '_blank'; a.rel = 'noopener'; act.appendChild(a);
if (r.review !== 'approved') act.appendChild(btn(T('검수 완료'), 'ghost small-btn', go('approved'))); if (r.review !== 'rejected') act.appendChild(btn(T('수정 필요'), 'ghost small-btn', go('rejected')));
tr.appendChild(act); t.appendChild(tr);
}
// 로케일별 메타·구조화 데이터
const mx = d.matrix; if (!mx) return;
const ms = el('details', 'opt-sec'); ms.appendChild(el('summary', '', T('로케일별 메타·구조화 데이터'))); box.appendChild(ms);
ms.appendChild(el('p', 'muted small', mx.baseUrl ? TF('운영 주소 {1} 기준 · x-default: {2}', mx.baseUrl, mx.xDefault === 'root' ? T('언어 선택 페이지') : mx.xDefault) : T('배포 전이라 canonical·hreflang·사이트맵의 절대 주소는 배포할 때 채워집니다.')));
const w2 = el('div', 'table-wrap'); const t2 = el('table', 'q-table'); w2.appendChild(t2); ms.appendChild(w2);
const h2 = el('tr'); for (const h of ['언어', '페이지', 'title', 'description', 'canonical', 'og:locale', 'hreflang', 'sitemap']) h2.appendChild(el('th', '', T(h))); t2.appendChild(h2);
const yn = (v) => (v === null ? '—' : v ? '✓' : '✗');
for (const l of mx.languages) for (const pg of l.pages) {
const tr = el('tr'); tr.appendChild(el('td', '', `${l.lang}${pg.file === 'index.html' ? ` · ${l.schema || T('사업체 정보 없음')}${l.geo === 'default' ? ' (' + T('기본 언어 값') + ')' : ''}` : ''}`)); tr.appendChild(el('td', '', pg.file));
if (pg.missing) { const x = el('td', 'bad', T('페이지 없음')); x.colSpan = 6; tr.appendChild(x); t2.appendChild(tr); continue; }
const tdt = el('td', pg.title ? '' : 'bad', pg.title || '✗'); tdt.lang = l.hreflang; tr.appendChild(tdt); const tdd = el('td', pg.description ? '' : 'bad', pg.description ? pg.description.slice(0, 60) : '✗'); tdd.lang = l.hreflang; tr.appendChild(tdd);
tr.appendChild(el('td', pg.canonicalOk === false ? 'bad' : '', yn(pg.canonicalOk))); tr.appendChild(el('td', pg.localeOk ? 'ok' : 'bad', pg.ogLocale || '✗')); tr.appendChild(el('td', '', String(pg.hreflang))); tr.appendChild(el('td', pg.inSitemap === false ? 'bad' : '', yn(pg.inSitemap)));
t2.appendChild(tr);
}
}
/** 사이트를 열거나 작업이 끝났을 때: 생성이 끝난 사이트에만 카드를 보인다 */
function onSiteOpened(site) {
const ready = !!(site && (!site.plan || ['generated', 'published'].includes(site.plan.stage)) && (site.files || []).some((f) => /\.html?$/i.test(f)));
$('optimizeCard').classList.toggle('hidden', !ready); $('searchCard').classList.toggle('hidden', !ready); $('langCard').classList.toggle('hidden', !ready);
if (!ready) { siteId = null; return; }
siteId = site.id; loadOptimize(); loadSearch(); loadLangs(site);
}
window.OptimizeUI = { onSiteOpened };
})();