chore: CRM-NEW baseline
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
#!/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, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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(/&/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);
|
||||
});
|
||||
Reference in New Issue
Block a user