Files
crm-ai/scripts/import-kb-helpcenter.js
T
2026-10-23 10:35:28 +08:00

332 lines
11 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.
#!/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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function inline(s) {
let t = escapeHtml(s);
t = t.replace(/`([^`]+)`/g, '<code>$1</code>');
t = t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
t = t.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2">$1</a>');
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(`<p>${inline(para.join(' '))}</p>`);
para = [];
}
};
const flushList = () => {
if (list) {
const tag = list.ordered ? 'ol' : 'ul';
blocks.push(`<${tag}>` + list.items.map((i) => `<li>${inline(i)}</li>`).join('') + `</${tag}>`);
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(/&amp;/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: `<h2>${escapeHtml(s.question)}</h2>\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);
});