|
|
@@ -2,10 +2,13 @@
|
|
|
/**
|
|
|
* OpenClaw Skills CDN 上传脚本
|
|
|
*
|
|
|
- * 功能:将所有 skills 上传到七牛云 CDN,生成下载 URL
|
|
|
+ * 功能:
|
|
|
+ * 1. 将所有 skills 上传到七牛云 CDN,生成下载 URL
|
|
|
+ * 2. 将每个 skill 的元数据写入 Parse 数据库 Skill 表
|
|
|
+ * 3. 生成 README.md(包含所有 skill 简介 + 下载链接)并上传到 CDN
|
|
|
*
|
|
|
* 使用方法:
|
|
|
- * node scripts/upload-skills.js
|
|
|
+ * node scripts/deploy/upload-skills.js
|
|
|
*
|
|
|
* 前置条件:
|
|
|
* 1. npm install qiniu
|
|
|
@@ -19,7 +22,7 @@ const fs = require('fs');
|
|
|
const path = require('path');
|
|
|
|
|
|
// ============================================
|
|
|
-// 七牛云配置(需要填入你的 AccessKey / SecretKey)
|
|
|
+// 七牛云配置
|
|
|
// ============================================
|
|
|
const QINIU_ACCESS_KEY = process.env.QINIU_AK || 'EXsA-z_n4LGmWrwC088bygcGJtAditnWQe2nH-ZE';
|
|
|
const QINIU_SECRET_KEY = process.env.QINIU_SK || 'HWTL92OL-Tup0-8ex8A9jnG3OaJzTxlF4OwiiDsX';
|
|
|
@@ -27,21 +30,26 @@ const BUCKET = 'nova-repos';
|
|
|
const CDN_DOMAIN = 'https://repos.fmode.cn';
|
|
|
const CDN_PREFIX = 'x/openclaw-skills';
|
|
|
|
|
|
+// ============================================
|
|
|
+// Parse 数据库配置
|
|
|
+// ============================================
|
|
|
+const PARSE_BASE = 'https://server.fmode.cn/parse';
|
|
|
+const APP_ID = 'ncloudmaster';
|
|
|
+
|
|
|
// Skills 源目录(部署目录)
|
|
|
-const SKILLS_DIR = path.resolve(__dirname, '..', 'workshop');
|
|
|
const DEPLOYED_SKILLS_DIR = path.join(process.env.USERPROFILE || process.env.HOME, '.openclaw', 'skills');
|
|
|
|
|
|
// 要上传的技能目录列表(如果为空,则上传所有 skills)
|
|
|
const SKILL_FILTER = []; // 例如 ['tiktok-video-search', 'jimeng-img-v4'] 指定只上传某些
|
|
|
|
|
|
// ============================================
|
|
|
-// 上传逻辑
|
|
|
+// 七牛上传
|
|
|
// ============================================
|
|
|
|
|
|
function getUploadToken(bucket, key) {
|
|
|
const mac = new qiniu.auth.digest.Mac(QINIU_ACCESS_KEY, QINIU_SECRET_KEY);
|
|
|
const putPolicy = new qiniu.rs.PutPolicy({
|
|
|
- scope: `${bucket}:${key}`, // 覆盖上传
|
|
|
+ scope: `${bucket}:${key}`,
|
|
|
expires: 3600
|
|
|
});
|
|
|
return putPolicy.uploadToken(mac);
|
|
|
@@ -51,7 +59,7 @@ function uploadFile(localFile, cdnKey) {
|
|
|
return new Promise((resolve, reject) => {
|
|
|
const token = getUploadToken(BUCKET, cdnKey);
|
|
|
const formUploader = new qiniu.form_up.FormUploader(new qiniu.conf.Config({
|
|
|
- zone: qiniu.zone.Zone_z2 // 华南(nova-repos 所在区域)
|
|
|
+ zone: qiniu.zone.Zone_z2
|
|
|
}));
|
|
|
const putExtra = new qiniu.form_up.PutExtra();
|
|
|
|
|
|
@@ -69,7 +77,133 @@ function uploadFile(localFile, cdnKey) {
|
|
|
});
|
|
|
}
|
|
|
|
|
|
-async function collectSkills() {
|
|
|
+function uploadBuffer(buffer, cdnKey) {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const token = getUploadToken(BUCKET, cdnKey);
|
|
|
+ const formUploader = new qiniu.form_up.FormUploader(new qiniu.conf.Config({
|
|
|
+ zone: qiniu.zone.Zone_z2
|
|
|
+ }));
|
|
|
+ const putExtra = new qiniu.form_up.PutExtra();
|
|
|
+
|
|
|
+ formUploader.put(token, cdnKey, buffer, putExtra, (err, body, info) => {
|
|
|
+ if (err) {
|
|
|
+ reject(err);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (info.statusCode === 200) {
|
|
|
+ resolve({ key: body.key, url: `${CDN_DOMAIN}/${body.key}` });
|
|
|
+ } else {
|
|
|
+ reject(new Error(`Upload failed: ${info.statusCode} ${JSON.stringify(body)}`));
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// ============================================
|
|
|
+// Parse REST API helpers
|
|
|
+// ============================================
|
|
|
+
|
|
|
+async function parseQuery(className, where) {
|
|
|
+ const qs = new URLSearchParams({
|
|
|
+ where: JSON.stringify(where),
|
|
|
+ limit: '1'
|
|
|
+ });
|
|
|
+ const resp = await fetch(`${PARSE_BASE}/classes/${className}?${qs}`, {
|
|
|
+ headers: { 'X-Parse-Application-Id': APP_ID }
|
|
|
+ });
|
|
|
+ const data = await resp.json();
|
|
|
+ return data.results || [];
|
|
|
+}
|
|
|
+
|
|
|
+async function parseCreate(className, body) {
|
|
|
+ const resp = await fetch(`${PARSE_BASE}/classes/${className}`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: {
|
|
|
+ 'X-Parse-Application-Id': APP_ID,
|
|
|
+ 'Content-Type': 'application/json'
|
|
|
+ },
|
|
|
+ body: JSON.stringify(body)
|
|
|
+ });
|
|
|
+ return resp.json();
|
|
|
+}
|
|
|
+
|
|
|
+async function parseUpdate(className, objectId, body) {
|
|
|
+ const resp = await fetch(`${PARSE_BASE}/classes/${className}/${objectId}`, {
|
|
|
+ method: 'PUT',
|
|
|
+ headers: {
|
|
|
+ 'X-Parse-Application-Id': APP_ID,
|
|
|
+ 'Content-Type': 'application/json'
|
|
|
+ },
|
|
|
+ body: JSON.stringify(body)
|
|
|
+ });
|
|
|
+ return resp.json();
|
|
|
+}
|
|
|
+
|
|
|
+async function upsertSkill(skillData) {
|
|
|
+ const existing = await parseQuery('Skill', { name: skillData.name });
|
|
|
+ if (existing.length > 0) {
|
|
|
+ const result = await parseUpdate('Skill', existing[0].objectId, skillData);
|
|
|
+ return { action: 'updated', objectId: existing[0].objectId, ...result };
|
|
|
+ } else {
|
|
|
+ const result = await parseCreate('Skill', skillData);
|
|
|
+ return { action: 'created', ...result };
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ============================================
|
|
|
+// Skill metadata extraction
|
|
|
+// ============================================
|
|
|
+
|
|
|
+function parseSkillMdFrontmatter(filePath) {
|
|
|
+ try {
|
|
|
+ const content = fs.readFileSync(filePath, 'utf8');
|
|
|
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
|
+ if (!match) return {};
|
|
|
+ const fm = {};
|
|
|
+ match[1].split('\n').forEach(line => {
|
|
|
+ const idx = line.indexOf(':');
|
|
|
+ if (idx > 0) {
|
|
|
+ const key = line.substring(0, idx).trim();
|
|
|
+ const val = line.substring(idx + 1).trim();
|
|
|
+ fm[key] = val;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return fm;
|
|
|
+ } catch (e) {
|
|
|
+ return {};
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function parseApiConfig(filePath) {
|
|
|
+ try {
|
|
|
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
|
+ } catch (e) {
|
|
|
+ return {};
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function extractSkillMeta(skillDir, skillName) {
|
|
|
+ const skillMdPath = path.join(skillDir, 'SKILL.md');
|
|
|
+ const apiConfigPath = path.join(skillDir, 'api-config.json');
|
|
|
+
|
|
|
+ const fm = parseSkillMdFrontmatter(skillMdPath);
|
|
|
+ const cfg = fs.existsSync(apiConfigPath) ? parseApiConfig(apiConfigPath) : {};
|
|
|
+
|
|
|
+ return {
|
|
|
+ name: skillName,
|
|
|
+ displayName: cfg.displayName || fm.name || skillName,
|
|
|
+ description: cfg.description || fm.description || '',
|
|
|
+ category: cfg.category || '',
|
|
|
+ version: cfg.version || fm.version || '1.0.0',
|
|
|
+ author: fm.author || 'nkkj-BrainHack'
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+// ============================================
|
|
|
+// Collect skills
|
|
|
+// ============================================
|
|
|
+
|
|
|
+function collectSkills() {
|
|
|
const skills = [];
|
|
|
const dir = DEPLOYED_SKILLS_DIR;
|
|
|
|
|
|
@@ -82,7 +216,6 @@ async function collectSkills() {
|
|
|
const fullPath = path.join(dir, d);
|
|
|
if (!fs.statSync(fullPath).isDirectory()) return false;
|
|
|
if (SKILL_FILTER.length > 0 && !SKILL_FILTER.includes(d)) return false;
|
|
|
- // 至少有 SKILL.md
|
|
|
return fs.existsSync(path.join(fullPath, 'SKILL.md'));
|
|
|
});
|
|
|
|
|
|
@@ -102,16 +235,77 @@ async function collectSkills() {
|
|
|
}
|
|
|
|
|
|
if (files.length > 0) {
|
|
|
- skills.push({ name: skillName, files });
|
|
|
+ const meta = extractSkillMeta(skillDir, skillName);
|
|
|
+ skills.push({ name: skillName, files, meta });
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return skills;
|
|
|
}
|
|
|
|
|
|
+// ============================================
|
|
|
+// Generate README.md
|
|
|
+// ============================================
|
|
|
+
|
|
|
+function generateReadme(skillEntries) {
|
|
|
+ const now = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
|
|
+ const lines = [
|
|
|
+ '# OpenClaw Skills 目录',
|
|
|
+ '',
|
|
|
+ `> 自动生成于 ${now},共 ${skillEntries.length} 个技能`,
|
|
|
+ '',
|
|
|
+ '---',
|
|
|
+ ''
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 按 category 分组
|
|
|
+ const grouped = {};
|
|
|
+ for (const entry of skillEntries) {
|
|
|
+ const cat = entry.meta.category || '其他';
|
|
|
+ if (!grouped[cat]) grouped[cat] = [];
|
|
|
+ grouped[cat].push(entry);
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const [cat, entries] of Object.entries(grouped).sort()) {
|
|
|
+ lines.push(`## ${cat}`);
|
|
|
+ lines.push('');
|
|
|
+ lines.push('| 技能名 | 说明 | 版本 | 下载 |');
|
|
|
+ lines.push('|--------|------|------|------|');
|
|
|
+
|
|
|
+ for (const entry of entries) {
|
|
|
+ const m = entry.meta;
|
|
|
+ const desc = m.description.length > 60 ? m.description.substring(0, 60) + '...' : m.description;
|
|
|
+ const skillMdUrl = entry.cdnUrls['SKILL.md'] || '';
|
|
|
+ const apiCfgUrl = entry.cdnUrls['api-config.json'] || '';
|
|
|
+ const links = [];
|
|
|
+ if (skillMdUrl) links.push(`[SKILL.md](${skillMdUrl})`);
|
|
|
+ if (apiCfgUrl) links.push(`[api-config.json](${apiCfgUrl})`);
|
|
|
+ lines.push(`| **${m.displayName}** (\`${m.name}\`) | ${desc} | ${m.version} | ${links.join(' / ')} |`);
|
|
|
+ }
|
|
|
+ lines.push('');
|
|
|
+ }
|
|
|
+
|
|
|
+ lines.push('---');
|
|
|
+ lines.push('');
|
|
|
+ lines.push('## 快速使用');
|
|
|
+ lines.push('');
|
|
|
+ lines.push('每个 Skill 包含两个文件:');
|
|
|
+ lines.push('- **SKILL.md** — 技能说明文档(功能、入参、调用链、注意事项)');
|
|
|
+ lines.push('- **api-config.json** — API 配置(endpoint、参数 schema、错误处理)');
|
|
|
+ lines.push('');
|
|
|
+ lines.push('下载后放入 `~/.openclaw/skills/<skill-name>/` 即可被 OpenClaw Agent 识别和调用。');
|
|
|
+ lines.push('');
|
|
|
+
|
|
|
+ return lines.join('\n');
|
|
|
+}
|
|
|
+
|
|
|
+// ============================================
|
|
|
+// Main
|
|
|
+// ============================================
|
|
|
+
|
|
|
async function main() {
|
|
|
console.log('==================================================');
|
|
|
- console.log(' OpenClaw Skills CDN 上传脚本');
|
|
|
+ console.log(' OpenClaw Skills CDN 上传 + Parse 同步脚本');
|
|
|
console.log('==================================================');
|
|
|
console.log('');
|
|
|
|
|
|
@@ -120,26 +314,24 @@ async function main() {
|
|
|
console.error('❌ 请先配置七牛云凭证!');
|
|
|
console.error(' 方式1: 编辑脚本中的 QINIU_ACCESS_KEY / QINIU_SECRET_KEY');
|
|
|
console.error(' 方式2: 设置环境变量 QINIU_AK / QINIU_SK');
|
|
|
- console.error('');
|
|
|
- console.error(' 示例: set QINIU_AK=xxx & set QINIU_SK=yyy & node scripts/upload-skills.js');
|
|
|
process.exit(1);
|
|
|
}
|
|
|
|
|
|
// 收集技能
|
|
|
- const skills = await collectSkills();
|
|
|
+ const skills = collectSkills();
|
|
|
console.log(`📦 找到 ${skills.length} 个技能待上传`);
|
|
|
console.log(`📂 源目录: ${DEPLOYED_SKILLS_DIR}`);
|
|
|
console.log(`☁️ Bucket: ${BUCKET}`);
|
|
|
console.log(`🔗 CDN前缀: ${CDN_PREFIX}`);
|
|
|
console.log('');
|
|
|
|
|
|
- // 汇总文件
|
|
|
let totalFiles = 0;
|
|
|
skills.forEach(s => { totalFiles += s.files.length; });
|
|
|
console.log(`📄 总计 ${totalFiles} 个文件`);
|
|
|
console.log('');
|
|
|
|
|
|
- // 执行上传
|
|
|
+ // ── Step 1: 上传文件到七牛 ──
|
|
|
+ console.log('── Step 1: 上传文件到七牛云 ──');
|
|
|
let successCount = 0;
|
|
|
let failCount = 0;
|
|
|
const results = [];
|
|
|
@@ -147,11 +339,13 @@ async function main() {
|
|
|
for (const skill of skills) {
|
|
|
process.stdout.write(` 📦 ${skill.name} ... `);
|
|
|
const skillResults = [];
|
|
|
+ const cdnUrls = {};
|
|
|
|
|
|
for (const file of skill.files) {
|
|
|
try {
|
|
|
const result = await uploadFile(file.localPath, file.cdnKey);
|
|
|
skillResults.push({ ...result, fileName: file.fileName, status: 'ok' });
|
|
|
+ cdnUrls[file.fileName] = result.url;
|
|
|
successCount++;
|
|
|
} catch (err) {
|
|
|
skillResults.push({ fileName: file.fileName, status: 'fail', error: err.message });
|
|
|
@@ -161,35 +355,70 @@ async function main() {
|
|
|
|
|
|
const allOk = skillResults.every(r => r.status === 'ok');
|
|
|
console.log(allOk ? '✅' : '⚠️');
|
|
|
- results.push({ name: skill.name, files: skillResults });
|
|
|
+ results.push({ name: skill.name, files: skillResults, meta: skill.meta, cdnUrls });
|
|
|
}
|
|
|
|
|
|
- // 输出结果
|
|
|
console.log('');
|
|
|
- console.log('==================================================');
|
|
|
console.log(` 上传完成!成功: ${successCount} 失败: ${failCount}`);
|
|
|
- console.log('==================================================');
|
|
|
console.log('');
|
|
|
|
|
|
- // 生成 URL 列表
|
|
|
+ // ── Step 2: 写入 Parse Skill 表 ──
|
|
|
+ console.log('── Step 2: 同步到 Parse 数据库 (Skill 表) ──');
|
|
|
+ let dbSuccess = 0;
|
|
|
+ let dbFail = 0;
|
|
|
+
|
|
|
+ for (const entry of results) {
|
|
|
+ if (Object.keys(entry.cdnUrls).length === 0) continue;
|
|
|
+ process.stdout.write(` 💾 ${entry.name} ... `);
|
|
|
+ try {
|
|
|
+ const skillData = {
|
|
|
+ name: entry.meta.name,
|
|
|
+ displayName: entry.meta.displayName,
|
|
|
+ description: entry.meta.description,
|
|
|
+ category: entry.meta.category,
|
|
|
+ version: entry.meta.version,
|
|
|
+ author: entry.meta.author,
|
|
|
+ cdnUrls: entry.cdnUrls,
|
|
|
+ cdnPrefix: `${CDN_DOMAIN}/${CDN_PREFIX}/${entry.name}`
|
|
|
+ };
|
|
|
+ const result = await upsertSkill(skillData);
|
|
|
+ console.log(`${result.action === 'created' ? '🆕 创建' : '🔄 更新'} (${result.objectId})`);
|
|
|
+ dbSuccess++;
|
|
|
+ } catch (err) {
|
|
|
+ console.log(`❌ ${err.message}`);
|
|
|
+ dbFail++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log('');
|
|
|
+ console.log(` 数据库同步完成!成功: ${dbSuccess} 失败: ${dbFail}`);
|
|
|
+ console.log('');
|
|
|
+
|
|
|
+ // ── Step 3: 生成 README.md 并上传 ──
|
|
|
+ console.log('── Step 3: 生成 README.md 并上传 ──');
|
|
|
+ try {
|
|
|
+ const readme = generateReadme(results);
|
|
|
+ const readmeKey = `${CDN_PREFIX}/README.md`;
|
|
|
+ const readmeResult = await uploadBuffer(Buffer.from(readme, 'utf8'), readmeKey);
|
|
|
+ console.log(` ✅ README.md 已上传: ${readmeResult.url}`);
|
|
|
+ } catch (err) {
|
|
|
+ console.log(` ❌ README.md 上传失败: ${err.message}`);
|
|
|
+ }
|
|
|
+ console.log('');
|
|
|
+
|
|
|
+ // ── 输出 URL 汇总 ──
|
|
|
console.log('📋 CDN 下载地址列表:');
|
|
|
console.log('');
|
|
|
|
|
|
const urlMap = {};
|
|
|
- for (const skill of results) {
|
|
|
- const urls = {};
|
|
|
- for (const file of skill.files) {
|
|
|
- if (file.status === 'ok') {
|
|
|
- urls[file.fileName] = file.url;
|
|
|
- console.log(` ${skill.name}/${file.fileName}`);
|
|
|
- console.log(` 👉 ${file.url}`);
|
|
|
- } else {
|
|
|
- console.log(` ${skill.name}/${file.fileName} ❌ ${file.error}`);
|
|
|
+ for (const entry of results) {
|
|
|
+ if (Object.keys(entry.cdnUrls).length > 0) {
|
|
|
+ urlMap[entry.name] = entry.cdnUrls;
|
|
|
+ for (const [fn, url] of Object.entries(entry.cdnUrls)) {
|
|
|
+ console.log(` ${entry.name}/${fn}`);
|
|
|
+ console.log(` 👉 ${url}`);
|
|
|
}
|
|
|
}
|
|
|
- if (Object.keys(urls).length > 0) {
|
|
|
- urlMap[skill.name] = urls;
|
|
|
- }
|
|
|
}
|
|
|
|
|
|
// 保存 URL 列表到文件
|
|
|
@@ -199,9 +428,11 @@ async function main() {
|
|
|
console.log(`💾 URL 列表已保存到: ${outputPath}`);
|
|
|
console.log('');
|
|
|
|
|
|
- // 输出快速索引 URL
|
|
|
- console.log('📌 技能索引(可用于 OpenClaw 远程加载):');
|
|
|
- console.log(` ${CDN_DOMAIN}/${CDN_PREFIX}/`);
|
|
|
+ console.log('==================================================');
|
|
|
+ console.log('📌 技能索引:');
|
|
|
+ console.log(` README: ${CDN_DOMAIN}/${CDN_PREFIX}/README.md`);
|
|
|
+ console.log(` CDN: ${CDN_DOMAIN}/${CDN_PREFIX}/`);
|
|
|
+ console.log('==================================================');
|
|
|
}
|
|
|
|
|
|
main().catch(err => {
|