#!/usr/bin/env node 'use strict'; /** * 把旧 customer-service-kb/*.md 导入 LibreDesk 帮助中心(文章 + ai_enabled)。 * * 为什么用文章而不是 snippets: * - 只有帮助中心文章能被 AI 引用([[cite:id]]) * - 会同时进入帮助中心站内搜索 * * 鉴权:用 System 账号登录拿 session + CSRF(不依赖 API key)。 * 用法: * node scripts/import-kb-helpcenter.js --dry-run * node scripts/import-kb-helpcenter.js */ const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const KB_DIR = '/home/dev/code/crm/crm-ai-demo/customer-service-kb'; const DRY = process.argv.includes('--dry-run'); // 文件 -> collection(跳过 site-map:导航类内容会污染 FAQ 向量检索) const COLLECTIONS = [ { file: '01-registration-account.md', slug: 'registration', name: 'Registration & Account' }, { file: '02-ordering.md', slug: 'ordering', name: 'Ordering' }, { file: '03-pre-order.md', slug: 'pre-order', name: 'Pre-order' }, { file: '04-payment-vat-invoice.md', slug: 'payment', name: 'Payment, VAT & Invoices' }, { file: '05-shipping-delivery.md', slug: 'shipping', name: 'Shipping & Delivery' }, { file: '06-returns-refunds.md', slug: 'returns', name: 'Returns & Refunds' }, { file: '07-factory-direct.md', slug: 'factory-direct', name: 'Factory-Direct' }, { file: '09-membership-rewards.md', slug: 'membership', name: 'Membership & Rewards' }, { file: '10-products-and-jewelry-care.md', slug: 'products', name: 'Products & Jewelry Care' }, ]; const HELP_CENTER = { name: 'Yehwang Help Center', slug: 'yehwang-help', page_title: 'Yehwang Help Center', meta_description: 'FAQ for Yehwang wholesale customers: ordering, payment, shipping, returns and more.', default_locale: 'en', allowed_locales: ['en'], template: 'classic', }; function loadEnv() { const out = {}; for (const line of fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')) { const t = line.trim(); if (!t || t.startsWith('#')) continue; const eq = t.indexOf('='); if (eq < 0) continue; let v = t.slice(eq + 1).trim(); if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); out[t.slice(0, eq).trim()] = v; } return out; } // ---------------------------------------------------------------- markdown -> html function escapeHtml(s) { return s .replace(/&(?!(amp|lt|gt|quot|#\d+);)/g, '&') .replace(//g, '>'); } function inline(s) { let t = escapeHtml(s); t = t.replace(/`([^`]+)`/g, '$1'); t = t.replace(/\*\*([^*]+)\*\*/g, '$1'); t = t.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1'); return t; } function answerToHtml(md) { const lines = md.replace(/\r\n/g, '\n').split('\n'); const blocks = []; let para = []; let list = null; // { ordered, items } const flushPara = () => { if (para.length) { blocks.push(`

${inline(para.join(' '))}

`); para = []; } }; const flushList = () => { if (list) { const tag = list.ordered ? 'ol' : 'ul'; blocks.push(`<${tag}>` + list.items.map((i) => `
  • ${inline(i)}
  • `).join('') + ``); list = null; } }; for (const raw of lines) { const line = raw.trim(); if (!line) { flushPara(); flushList(); continue; } const ol = line.match(/^(\d+)[.)]\s+(.*)$/); const ul = line.match(/^[-*+]\s+(.*)$/); if (ol) { flushPara(); if (!list || !list.ordered) { flushList(); list = { ordered: true, items: [] }; } list.items.push(ol[2]); } else if (ul) { flushPara(); if (!list || list.ordered) { flushList(); list = { ordered: false, items: [] }; } list.items.push(ul[1]); } else { flushList(); para.push(line); } } flushPara(); flushList(); return blocks.join('\n'); } function parseFrontmatter(text) { const m = text.match(/^---\n([\s\S]*?)\n---\n?/); if (!m) return { meta: {}, body: text }; const meta = {}; for (const line of m[1].split('\n')) { const kv = line.match(/^([a-z_]+):\s*(.*)$/); if (kv) meta[kv[1]] = kv[2]; } return { meta, body: text.slice(m[0].length) }; } function parseSections(body) { const parts = body.split(/^### Q:\s*/m).slice(1); return parts.map((p) => { const lines = p.split('\n'); const question = lines.shift().trim(); const answerMd = lines.join('\n').trim(); return { question, answerMd }; }).filter((s) => s.question && s.answerMd); } function normalizeQ(q) { return q.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); } function plainText(html) { return html.replace(/<[^>]+>/g, ' ').replace(/&/g, '&').replace(/\s+/g, ' ').trim(); } // ---------------------------------------------------------------- HTTP client function createClient(baseURL) { const cookies = new Map(); let csrf = ''; function cookieHeader() { return [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; '); } function storeCookies(res) { const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean); for (const c of list) { const [pair] = c.split(';'); const eq = pair.indexOf('='); if (eq > 0) { const k = pair.slice(0, eq).trim(); const v = pair.slice(eq + 1).trim(); cookies.set(k, v); if (k === 'csrf_token') csrf = v; } } } async function req(method, urlPath, body) { const headers = { Accept: 'application/json' }; if (cookies.size) headers.Cookie = cookieHeader(); if (body !== undefined) headers['Content-Type'] = 'application/json'; if (csrf && method !== 'GET' && method !== 'HEAD') headers['X-CSRFTOKEN'] = csrf; const res = await fetch(baseURL + urlPath, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), }); storeCookies(res); const text = await res.text(); let json = null; try { json = text ? JSON.parse(text) : null; } catch { /* ignore */ } if (!res.ok) { const msg = json && json.message ? json.message : text.slice(0, 300); throw new Error(`${method} ${urlPath} -> HTTP ${res.status}: ${msg}`); } return json; } return { req, get: (p) => req('GET', p), post: (p, b) => req('POST', p, b), put: (p, b) => req('PUT', p, b) }; } function asData(json) { if (!json) return null; return json.data !== undefined ? json.data : json; } // ---------------------------------------------------------------- main async function main() { const env = loadEnv(); const base = (env.LIBREDESK_BASE_URL || 'http://192.168.31.61:9000').replace(/\/+$/, ''); const email = env.LIBREDESK_SYSTEM_EMAIL || 'System'; const password = env.LIBREDESK_SYSTEM_USER_PASSWORD; if (!password) throw new Error('缺少 LIBREDESK_SYSTEM_USER_PASSWORD'); // 1. 解析 KB const plans = []; const seen = new Set(); let totalQuestions = 0; for (const col of COLLECTIONS) { const file = path.join(KB_DIR, col.file); if (!fs.existsSync(file)) { console.warn(`跳过缺失文件 ${col.file}`); continue; } const { body } = parseFrontmatter(fs.readFileSync(file, 'utf8')); const sections = parseSections(body); const articles = []; for (const s of sections) { const key = normalizeQ(s.question); if (!key || seen.has(key)) continue; seen.add(key); const html = answerToHtml(s.answerMd); const text = plainText(html); articles.push({ title: s.question.replace(/\s+/g, ' ').trim(), content: `

    ${escapeHtml(s.question)}

    \n${html}`, excerpt: text.slice(0, 200), }); } totalQuestions += articles.length; plans.push({ ...col, articles }); } console.log(`解析完成:${plans.length} 个 collection,${totalQuestions} 篇文章(已全局去重)`); for (const p of plans) console.log(` - ${p.name}: ${p.articles.length}`); if (DRY) return; // 2. 登录 const c = createClient(base); await c.post('/api/v1/auth/login', { email, password }); console.log(`登录成功:${email}`); // 3. 帮助中心 const existingHC = asData(await c.get('/api/v1/help-centers')) || []; let hc = existingHC.find((x) => x.slug === HELP_CENTER.slug); if (!hc) { hc = asData(await c.post('/api/v1/help-centers', { ...HELP_CENTER, allowed_locales: HELP_CENTER.allowed_locales, theme: {}, })); console.log(`创建帮助中心 #${hc.id} ${hc.slug}`); } else { console.log(`复用帮助中心 #${hc.id} ${hc.slug}`); } // 4. collections + articles const existingCols = asData(await c.get(`/api/v1/help-centers/${hc.id}/collections`)) || []; // 注意:服务端会用 name 生成 slug(重名时追加 -2),因此按 name 匹配而不是 slug。 const normName = (s) => String(s || '').trim().toLowerCase(); const colByName = new Map(existingCols.map((x) => [normName(x.name), x])); // 现有文章标题(按 collection 分组,避免重复导入) const treeResp = asData(await c.get(`/api/v1/help-centers/${hc.id}/tree`)) || {}; const titlesByColId = new Map(); const walk = (nodes) => { for (const n of nodes || []) { const set = titlesByColId.get(n.id) || new Set(); for (const a of n.articles || []) set.add(normalizeQ(a.title)); titlesByColId.set(n.id, set); walk(n.children); } }; walk(treeResp.tree); let createdCols = 0, createdArticles = 0, skipped = 0; let order = 0; for (const plan of plans) { order += 10; let col = colByName.get(normName(plan.name)); if (!col) { col = asData(await c.post(`/api/v1/help-centers/${hc.id}/collections`, { slug: plan.slug, locale: 'en', name: plan.name, description: '', icon: '', sort_order: order, is_published: true, })); createdCols++; colByName.set(normName(plan.name), col); } const existingTitles = titlesByColId.get(col.id) || new Set(); let added = 0; let i = 0; for (const art of plan.articles) { i += 1; if (existingTitles.has(normalizeQ(art.title))) { skipped++; continue; } await c.post(`/api/v1/collections/${col.id}/articles`, { locale: 'en', title: art.title, content: art.content, excerpt: art.excerpt, sort_order: i * 10, status: 'published', ai_enabled: true, }); added++; createdArticles++; } console.log(` ${plan.name}: +${added}`); } console.log(`\n完成:新建 collection ${createdCols},文章 ${createdArticles},跳过已存在 ${skipped}`); console.log('注意:向量索引依赖 AI Provider 的 embedding 配置;配置后会自动 reindex。'); } main().catch((err) => { console.error('[import-kb]', err.message); process.exit(1); });