chore: CRM-NEW baseline

This commit is contained in:
wangliang
2026-10-23 10:35:28 +08:00
commit aa499f6dbf
111 changed files with 15126 additions and 0 deletions
+331
View File
@@ -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, '&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);
});