upload-skills.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. #!/usr/bin/env node
  2. /**
  3. * OpenClaw Skills CDN 上传脚本
  4. *
  5. * 功能:
  6. * 1. 将所有 skills 上传到七牛云 CDN,生成下载 URL
  7. * 2. 将每个 skill 的元数据写入 Parse 数据库 Skill 表
  8. * 3. 生成 README.md(包含所有 skill 简介 + 下载链接)并上传到 CDN
  9. *
  10. * 使用方法:
  11. * node scripts/deploy/upload-skills.js
  12. *
  13. * 前置条件:
  14. * 1. npm install qiniu
  15. * 2. 配置下方 QINIU_ACCESS_KEY 和 QINIU_SECRET_KEY
  16. *
  17. * 参考:hongben-server/modules/fmode-tikhub-server/upload.sh
  18. */
  19. const qiniu = require('qiniu');
  20. const fs = require('fs');
  21. const path = require('path');
  22. // ============================================
  23. // 七牛云配置
  24. // ============================================
  25. const QINIU_ACCESS_KEY = process.env.QINIU_AK || 'EXsA-z_n4LGmWrwC088bygcGJtAditnWQe2nH-ZE';
  26. const QINIU_SECRET_KEY = process.env.QINIU_SK || 'HWTL92OL-Tup0-8ex8A9jnG3OaJzTxlF4OwiiDsX';
  27. const BUCKET = 'nova-repos';
  28. const CDN_DOMAIN = 'https://repos.fmode.cn';
  29. const CDN_PREFIX = 'x/openclaw-skills';
  30. // ============================================
  31. // Parse 数据库配置
  32. // ============================================
  33. const PARSE_BASE = 'https://server.fmode.cn/parse';
  34. const APP_ID = 'ncloudmaster';
  35. const MASTER_KEY = process.env.PARSE_MASTER_KEY || 'SnkK12*&sunq2#@20!';
  36. // Skills 源目录(部署目录)
  37. const DEPLOYED_SKILLS_DIR = path.join(process.env.USERPROFILE || process.env.HOME, '.openclaw', 'skills');
  38. // 要上传的技能目录列表(如果为空,则上传所有 skills)
  39. const SKILL_FILTER = []; // 例如 ['tiktok-video-search', 'jimeng-img-v4'] 指定只上传某些
  40. // ============================================
  41. // 七牛上传
  42. // ============================================
  43. function getUploadToken(bucket, key) {
  44. const mac = new qiniu.auth.digest.Mac(QINIU_ACCESS_KEY, QINIU_SECRET_KEY);
  45. const putPolicy = new qiniu.rs.PutPolicy({
  46. scope: `${bucket}:${key}`,
  47. expires: 3600
  48. });
  49. return putPolicy.uploadToken(mac);
  50. }
  51. function uploadFile(localFile, cdnKey) {
  52. return new Promise((resolve, reject) => {
  53. const token = getUploadToken(BUCKET, cdnKey);
  54. const formUploader = new qiniu.form_up.FormUploader(new qiniu.conf.Config({
  55. zone: qiniu.zone.Zone_z2
  56. }));
  57. const putExtra = new qiniu.form_up.PutExtra();
  58. formUploader.putFile(token, cdnKey, localFile, putExtra, (err, body, info) => {
  59. if (err) {
  60. reject(err);
  61. return;
  62. }
  63. if (info.statusCode === 200) {
  64. resolve({ key: body.key, url: `${CDN_DOMAIN}/${body.key}` });
  65. } else {
  66. reject(new Error(`Upload failed: ${info.statusCode} ${JSON.stringify(body)}`));
  67. }
  68. });
  69. });
  70. }
  71. function uploadBuffer(buffer, cdnKey) {
  72. return new Promise((resolve, reject) => {
  73. const token = getUploadToken(BUCKET, cdnKey);
  74. const formUploader = new qiniu.form_up.FormUploader(new qiniu.conf.Config({
  75. zone: qiniu.zone.Zone_z2
  76. }));
  77. const putExtra = new qiniu.form_up.PutExtra();
  78. formUploader.put(token, cdnKey, buffer, putExtra, (err, body, info) => {
  79. if (err) {
  80. reject(err);
  81. return;
  82. }
  83. if (info.statusCode === 200) {
  84. resolve({ key: body.key, url: `${CDN_DOMAIN}/${body.key}` });
  85. } else {
  86. reject(new Error(`Upload failed: ${info.statusCode} ${JSON.stringify(body)}`));
  87. }
  88. });
  89. });
  90. }
  91. // ============================================
  92. // Parse REST API helpers
  93. // ============================================
  94. const PARSE_HEADERS = {
  95. 'X-Parse-Application-Id': APP_ID,
  96. 'X-Parse-Master-Key': MASTER_KEY,
  97. 'Content-Type': 'application/json'
  98. };
  99. async function ensureSkillClass() {
  100. const resp = await fetch(`${PARSE_BASE}/schemas/Skill`, {
  101. headers: PARSE_HEADERS
  102. });
  103. if (resp.ok) return;
  104. // Class 不存在,创建
  105. const createResp = await fetch(`${PARSE_BASE}/schemas/Skill`, {
  106. method: 'POST',
  107. headers: PARSE_HEADERS,
  108. body: JSON.stringify({
  109. className: 'Skill',
  110. fields: {
  111. name: { type: 'String' },
  112. displayName: { type: 'String' },
  113. description: { type: 'String' },
  114. category: { type: 'String' },
  115. version: { type: 'String' },
  116. author: { type: 'String' },
  117. cdnUrls: { type: 'Object' },
  118. cdnPrefix: { type: 'String' }
  119. }
  120. })
  121. });
  122. const result = await createResp.json();
  123. if (createResp.ok) {
  124. console.log(' ✅ Skill 表已创建');
  125. } else {
  126. console.log(' ⚠️ 创建 Skill 表:', result.error || JSON.stringify(result));
  127. }
  128. }
  129. async function parseQuery(className, where) {
  130. const qs = new URLSearchParams({
  131. where: JSON.stringify(where),
  132. limit: '1'
  133. });
  134. const resp = await fetch(`${PARSE_BASE}/classes/${className}?${qs}`, {
  135. headers: PARSE_HEADERS
  136. });
  137. const data = await resp.json();
  138. return data.results || [];
  139. }
  140. async function parseCreate(className, body) {
  141. const resp = await fetch(`${PARSE_BASE}/classes/${className}`, {
  142. method: 'POST',
  143. headers: PARSE_HEADERS,
  144. body: JSON.stringify(body)
  145. });
  146. return resp.json();
  147. }
  148. async function parseUpdate(className, objectId, body) {
  149. const resp = await fetch(`${PARSE_BASE}/classes/${className}/${objectId}`, {
  150. method: 'PUT',
  151. headers: PARSE_HEADERS,
  152. body: JSON.stringify(body)
  153. });
  154. return resp.json();
  155. }
  156. async function upsertSkill(skillData) {
  157. const existing = await parseQuery('Skill', { name: skillData.name });
  158. if (existing.length > 0) {
  159. const result = await parseUpdate('Skill', existing[0].objectId, skillData);
  160. return { action: 'updated', objectId: existing[0].objectId, ...result };
  161. } else {
  162. const result = await parseCreate('Skill', skillData);
  163. return { action: 'created', ...result };
  164. }
  165. }
  166. // ============================================
  167. // Skill metadata extraction
  168. // ============================================
  169. function parseSkillMdFrontmatter(filePath) {
  170. try {
  171. const content = fs.readFileSync(filePath, 'utf8');
  172. const match = content.match(/^---\n([\s\S]*?)\n---/);
  173. if (!match) return {};
  174. const fm = {};
  175. match[1].split('\n').forEach(line => {
  176. const idx = line.indexOf(':');
  177. if (idx > 0) {
  178. const key = line.substring(0, idx).trim();
  179. const val = line.substring(idx + 1).trim();
  180. fm[key] = val;
  181. }
  182. });
  183. return fm;
  184. } catch (e) {
  185. return {};
  186. }
  187. }
  188. function parseApiConfig(filePath) {
  189. try {
  190. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  191. } catch (e) {
  192. return {};
  193. }
  194. }
  195. function extractSkillMeta(skillDir, skillName) {
  196. const skillMdPath = path.join(skillDir, 'SKILL.md');
  197. const apiConfigPath = path.join(skillDir, 'api-config.json');
  198. const fm = parseSkillMdFrontmatter(skillMdPath);
  199. const cfg = fs.existsSync(apiConfigPath) ? parseApiConfig(apiConfigPath) : {};
  200. return {
  201. name: skillName,
  202. displayName: cfg.displayName || fm.name || skillName,
  203. description: cfg.description || fm.description || '',
  204. category: cfg.category || '',
  205. version: cfg.version || fm.version || '1.0.0',
  206. author: fm.author || 'nkkj-BrainHack'
  207. };
  208. }
  209. // ============================================
  210. // Collect skills
  211. // ============================================
  212. function collectSkills() {
  213. const skills = [];
  214. const dir = DEPLOYED_SKILLS_DIR;
  215. if (!fs.existsSync(dir)) {
  216. console.error(`Skills 目录不存在: ${dir}`);
  217. process.exit(1);
  218. }
  219. const entries = fs.readdirSync(dir).filter(d => {
  220. const fullPath = path.join(dir, d);
  221. if (!fs.statSync(fullPath).isDirectory()) return false;
  222. if (SKILL_FILTER.length > 0 && !SKILL_FILTER.includes(d)) return false;
  223. return fs.existsSync(path.join(fullPath, 'SKILL.md'));
  224. });
  225. for (const skillName of entries) {
  226. const skillDir = path.join(dir, skillName);
  227. const files = [];
  228. for (const fileName of ['SKILL.md', 'api-config.json']) {
  229. const filePath = path.join(skillDir, fileName);
  230. if (fs.existsSync(filePath)) {
  231. files.push({
  232. localPath: filePath,
  233. cdnKey: `${CDN_PREFIX}/${skillName}/${fileName}`,
  234. fileName
  235. });
  236. }
  237. }
  238. if (files.length > 0) {
  239. const meta = extractSkillMeta(skillDir, skillName);
  240. skills.push({ name: skillName, files, meta });
  241. }
  242. }
  243. return skills;
  244. }
  245. // ============================================
  246. // Generate README.md
  247. // ============================================
  248. function generateReadme(skillEntries) {
  249. const now = new Date().toISOString().replace('T', ' ').substring(0, 19);
  250. const lines = [
  251. '# OpenClaw Skills 目录',
  252. '',
  253. `> 自动生成于 ${now},共 ${skillEntries.length} 个技能`,
  254. '',
  255. '---',
  256. ''
  257. ];
  258. // 按 category 分组
  259. const grouped = {};
  260. for (const entry of skillEntries) {
  261. const cat = entry.meta.category || '其他';
  262. if (!grouped[cat]) grouped[cat] = [];
  263. grouped[cat].push(entry);
  264. }
  265. for (const [cat, entries] of Object.entries(grouped).sort()) {
  266. lines.push(`## ${cat}`);
  267. lines.push('');
  268. lines.push('| 技能名 | 说明 | 版本 | 下载 |');
  269. lines.push('|--------|------|------|------|');
  270. for (const entry of entries) {
  271. const m = entry.meta;
  272. const desc = m.description.length > 60 ? m.description.substring(0, 60) + '...' : m.description;
  273. const skillMdUrl = entry.cdnUrls['SKILL.md'] || '';
  274. const apiCfgUrl = entry.cdnUrls['api-config.json'] || '';
  275. const links = [];
  276. if (skillMdUrl) links.push(`[SKILL.md](${skillMdUrl})`);
  277. if (apiCfgUrl) links.push(`[api-config.json](${apiCfgUrl})`);
  278. lines.push(`| **${m.displayName}** (\`${m.name}\`) | ${desc} | ${m.version} | ${links.join(' / ')} |`);
  279. }
  280. lines.push('');
  281. }
  282. lines.push('---');
  283. lines.push('');
  284. lines.push('## 快速使用');
  285. lines.push('');
  286. lines.push('每个 Skill 包含两个文件:');
  287. lines.push('- **SKILL.md** — 技能说明文档(功能、入参、调用链、注意事项)');
  288. lines.push('- **api-config.json** — API 配置(endpoint、参数 schema、错误处理)');
  289. lines.push('');
  290. lines.push('下载后放入 `~/.openclaw/skills/<skill-name>/` 即可被 OpenClaw Agent 识别和调用。');
  291. lines.push('');
  292. return lines.join('\n');
  293. }
  294. // ============================================
  295. // Main
  296. // ============================================
  297. async function main() {
  298. console.log('==================================================');
  299. console.log(' OpenClaw Skills CDN 上传 + Parse 同步脚本');
  300. console.log('==================================================');
  301. console.log('');
  302. // 检查凭证
  303. if (QINIU_ACCESS_KEY === 'YOUR_ACCESS_KEY_HERE' || QINIU_SECRET_KEY === 'YOUR_SECRET_KEY_HERE') {
  304. console.error('❌ 请先配置七牛云凭证!');
  305. console.error(' 方式1: 编辑脚本中的 QINIU_ACCESS_KEY / QINIU_SECRET_KEY');
  306. console.error(' 方式2: 设置环境变量 QINIU_AK / QINIU_SK');
  307. process.exit(1);
  308. }
  309. // 收集技能
  310. const skills = collectSkills();
  311. console.log(`📦 找到 ${skills.length} 个技能待上传`);
  312. console.log(`📂 源目录: ${DEPLOYED_SKILLS_DIR}`);
  313. console.log(`☁️ Bucket: ${BUCKET}`);
  314. console.log(`🔗 CDN前缀: ${CDN_PREFIX}`);
  315. console.log('');
  316. let totalFiles = 0;
  317. skills.forEach(s => { totalFiles += s.files.length; });
  318. console.log(`📄 总计 ${totalFiles} 个文件`);
  319. console.log('');
  320. // ── Step 1: 上传文件到七牛 ──
  321. console.log('── Step 1: 上传文件到七牛云 ──');
  322. let successCount = 0;
  323. let failCount = 0;
  324. const results = [];
  325. for (const skill of skills) {
  326. process.stdout.write(` 📦 ${skill.name} ... `);
  327. const skillResults = [];
  328. const cdnUrls = {};
  329. for (const file of skill.files) {
  330. try {
  331. const result = await uploadFile(file.localPath, file.cdnKey);
  332. skillResults.push({ ...result, fileName: file.fileName, status: 'ok' });
  333. cdnUrls[file.fileName] = result.url;
  334. successCount++;
  335. } catch (err) {
  336. skillResults.push({ fileName: file.fileName, status: 'fail', error: err.message });
  337. failCount++;
  338. }
  339. }
  340. const allOk = skillResults.every(r => r.status === 'ok');
  341. console.log(allOk ? '✅' : '⚠️');
  342. results.push({ name: skill.name, files: skillResults, meta: skill.meta, cdnUrls });
  343. }
  344. console.log('');
  345. console.log(` 上传完成!成功: ${successCount} 失败: ${failCount}`);
  346. console.log('');
  347. // ── Step 2: 写入 Parse Skill 表 ──
  348. console.log('── Step 2: 同步到 Parse 数据库 (Skill 表) ──');
  349. await ensureSkillClass();
  350. let dbSuccess = 0;
  351. let dbFail = 0;
  352. for (const entry of results) {
  353. if (Object.keys(entry.cdnUrls).length === 0) continue;
  354. process.stdout.write(` 💾 ${entry.name} ... `);
  355. try {
  356. // Parse 不允许嵌套 key 含 '.',将文件名中的 '.' 替换为 '_'
  357. const safeCdnUrls = {};
  358. for (const [k, v] of Object.entries(entry.cdnUrls)) {
  359. safeCdnUrls[k.replace(/\./g, '_')] = v;
  360. }
  361. const skillData = {
  362. name: entry.meta.name,
  363. displayName: entry.meta.displayName,
  364. description: entry.meta.description,
  365. category: entry.meta.category,
  366. version: entry.meta.version,
  367. author: entry.meta.author,
  368. cdnUrls: safeCdnUrls,
  369. cdnPrefix: `${CDN_DOMAIN}/${CDN_PREFIX}/${entry.name}`
  370. };
  371. const result = await upsertSkill(skillData);
  372. console.log(`${result.action === 'created' ? '🆕 创建' : '🔄 更新'} (${result.objectId})`);
  373. dbSuccess++;
  374. } catch (err) {
  375. console.log(`❌ ${err.message}`);
  376. dbFail++;
  377. }
  378. }
  379. console.log('');
  380. console.log(` 数据库同步完成!成功: ${dbSuccess} 失败: ${dbFail}`);
  381. console.log('');
  382. // ── Step 3: 生成 README.md 并上传(包含在技能文件夹内)──
  383. console.log('── Step 3: 生成 README.md 并上传 ──');
  384. let readmeUrl = '';
  385. try {
  386. const readme = generateReadme(results);
  387. const readmeKey = `${CDN_PREFIX}/README.md`;
  388. const readmeResult = await uploadBuffer(Buffer.from(readme, 'utf8'), readmeKey);
  389. readmeUrl = readmeResult.url;
  390. console.log(` ✅ README.md 已上传: ${readmeUrl}`);
  391. } catch (err) {
  392. console.log(` ❌ README.md 上传失败: ${err.message}`);
  393. }
  394. console.log('');
  395. // ── 输出 URL 汇总(README 包含在内)──
  396. console.log('📋 CDN 下载地址列表:');
  397. console.log('');
  398. const urlMap = {};
  399. // README 放在最前面
  400. if (readmeUrl) {
  401. urlMap['_README'] = { 'README.md': readmeUrl };
  402. console.log(` README.md`);
  403. console.log(` 👉 ${readmeUrl}`);
  404. }
  405. for (const entry of results) {
  406. if (Object.keys(entry.cdnUrls).length > 0) {
  407. urlMap[entry.name] = entry.cdnUrls;
  408. for (const [fn, url] of Object.entries(entry.cdnUrls)) {
  409. console.log(` ${entry.name}/${fn}`);
  410. console.log(` 👉 ${url}`);
  411. }
  412. }
  413. }
  414. // 保存 URL 列表到文件
  415. const outputPath = path.resolve(__dirname, '..', 'skills-cdn-urls.json');
  416. fs.writeFileSync(outputPath, JSON.stringify(urlMap, null, 2), 'utf8');
  417. console.log('');
  418. console.log(`💾 URL 列表已保存到: ${outputPath}`);
  419. console.log('');
  420. console.log('==================================================');
  421. console.log('📌 技能索引(README 已包含在文件夹内):');
  422. console.log(` README: ${CDN_DOMAIN}/${CDN_PREFIX}/README.md`);
  423. console.log(` CDN: ${CDN_DOMAIN}/${CDN_PREFIX}/`);
  424. console.log('==================================================');
  425. }
  426. main().catch(err => {
  427. console.error('❌ 上传出错:', err);
  428. process.exit(1);
  429. });