upload-skills.js 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857
  1. #!/usr/bin/env node
  2. /**
  3. * OpenClaw Skills + Workshop CDN 上传脚本 v4.1
  4. *
  5. * 功能:
  6. * 1. 将所有 skills 上传到七牛云 CDN,生成下载 URL
  7. * 2. 将每个 skill 的元数据写入 Parse 数据库 Skill 表
  8. * 3. 将 workshop 文件(playbook + session + memory templates)上传到 CDN
  9. * 4. 生成 README.md(包含所有 skill 简介 + workshop 文件 + 下载链接)并上传到 CDN
  10. *
  11. * 使用方法:
  12. * node scripts/deploy/upload-skills.js # 上传全部(skills + workshop)
  13. * node scripts/deploy/upload-skills.js --skills-only # 只上传 skills
  14. * node scripts/deploy/upload-skills.js --workshop-only # 只上传 workshop
  15. *
  16. * 前置条件:
  17. * 1. npm install qiniu
  18. * 2. 配置下方 QINIU_ACCESS_KEY 和 QINIU_SECRET_KEY
  19. * 3. 先运行 deploy-to-openclaw.ps1 确保 ~/.openclaw 是最新的
  20. *
  21. * 参考:hongben-server/modules/fmode-tikhub-server/upload.sh
  22. */
  23. const qiniu = require('qiniu');
  24. const fs = require('fs');
  25. const path = require('path');
  26. // ============================================
  27. // 七牛云配置
  28. // ============================================
  29. const QINIU_ACCESS_KEY = process.env.QINIU_AK || 'EXsA-z_n4LGmWrwC088bygcGJtAditnWQe2nH-ZE';
  30. const QINIU_SECRET_KEY = process.env.QINIU_SK || 'HWTL92OL-Tup0-8ex8A9jnG3OaJzTxlF4OwiiDsX';
  31. const BUCKET = 'nova-repos';
  32. const CDN_DOMAIN = 'https://repos.fmode.cn';
  33. const CDN_PREFIX = 'x/openclaw-skills';
  34. // ============================================
  35. // Parse 数据库配置
  36. // ============================================
  37. const PARSE_BASE = 'https://server.fmode.cn/parse';
  38. const APP_ID = 'ncloudmaster';
  39. const MASTER_KEY = process.env.PARSE_MASTER_KEY || 'SnkK12*&sunq2#@20!';
  40. // 源目录(部署目录)
  41. const OPENCLAW_DIR = path.join(process.env.USERPROFILE || process.env.HOME, '.openclaw');
  42. const DEPLOYED_SKILLS_DIR = path.join(OPENCLAW_DIR, 'skills');
  43. const WORKSHOP_DIR = path.join(OPENCLAW_DIR, 'workspace');
  44. const MEMORY_TEMPLATES_DIR = path.join(WORKSHOP_DIR, 'memory');
  45. // CLI 参数
  46. const args = process.argv.slice(2);
  47. const SKILLS_ONLY = args.includes('--skills-only');
  48. const WORKSHOP_ONLY = args.includes('--workshop-only');
  49. // 要上传的技能目录列表(如果为空,则上传所有 skills)
  50. const SKILL_FILTER = []; // 例如 ['tiktok-video-search', 'jimeng-img-v4'] 指定只上传某些
  51. // ============================================
  52. // 技能包分组定义
  53. // ============================================
  54. const SKILL_PACKAGES = {
  55. 'voc-core': {
  56. label: 'VOC 核心套件',
  57. description: 'Amazon 品类洞察 + 评论分析 + 竞品分析 + 数据合成 + 社媒VOC + 工作坊',
  58. categories: ['voc', 'review-analysis', 'competitor-analysis', 'synthesis', 'social-voc', 'workshop'],
  59. status: 'stable', // stable | testing | hidden
  60. emoji: '🔍'
  61. },
  62. 'social-media': {
  63. label: '社媒数据套件',
  64. description: 'TikTok / Instagram / 抖音数据采集',
  65. categories: ['social-media', 'douyin'],
  66. status: 'stable',
  67. emoji: '📱'
  68. },
  69. 'ai-creation': {
  70. label: 'AI 创作套件',
  71. description: '即梦AI图片视频生成 + 一键成片(测试中)',
  72. categories: ['jimeng', 'video-creation'],
  73. status: 'testing',
  74. emoji: '🎨'
  75. },
  76. '_other': {
  77. label: '其他/测试',
  78. description: '测试技能,不对外提供',
  79. categories: ['other'],
  80. status: 'hidden',
  81. emoji: '🧪'
  82. }
  83. };
  84. // ============================================
  85. // Workshop 文件定义 (v4.1)
  86. // ============================================
  87. const WORKSHOP_FILES = {
  88. playbooks: [
  89. 'workshop-voc-playbook.md',
  90. 'workshop-global-rules.md',
  91. 'workshop-session-0-intake.md',
  92. 'workshop-session-1-category.md',
  93. 'workshop-session-2-brand.md',
  94. 'workshop-session-3-voc.md',
  95. 'workshop-session-4-deep.md',
  96. 'workshop-session-5-report.md'
  97. ],
  98. supporting: [
  99. 'product-analysis-playbook.md',
  100. 'synthesis-prompts.md',
  101. 'voc-report-schema-spec.md'
  102. ],
  103. memoryTemplates: [
  104. 'workshop-progress.json',
  105. 'brand-context.json',
  106. 'analysis-request.json',
  107. 'calibration-notes.json',
  108. 'stage-1-output.json',
  109. 'stage-2-output.json',
  110. 'stage-3-output.json',
  111. 'stage-4-output.json',
  112. 'stage-5-output.json'
  113. ]
  114. };
  115. function getPackageForCategory(category) {
  116. for (const [pkgId, pkg] of Object.entries(SKILL_PACKAGES)) {
  117. if (pkg.categories.includes(category || 'other')) return pkgId;
  118. }
  119. return '_other';
  120. }
  121. // ============================================
  122. // 七牛上传
  123. // ============================================
  124. function getUploadToken(bucket, key) {
  125. const mac = new qiniu.auth.digest.Mac(QINIU_ACCESS_KEY, QINIU_SECRET_KEY);
  126. const putPolicy = new qiniu.rs.PutPolicy({
  127. scope: `${bucket}:${key}`,
  128. expires: 3600
  129. });
  130. return putPolicy.uploadToken(mac);
  131. }
  132. function uploadFile(localFile, cdnKey) {
  133. return new Promise((resolve, reject) => {
  134. const token = getUploadToken(BUCKET, cdnKey);
  135. const formUploader = new qiniu.form_up.FormUploader(new qiniu.conf.Config({
  136. zone: qiniu.zone.Zone_z2
  137. }));
  138. const putExtra = new qiniu.form_up.PutExtra();
  139. formUploader.putFile(token, cdnKey, localFile, putExtra, (err, body, info) => {
  140. if (err) {
  141. reject(err);
  142. return;
  143. }
  144. if (info.statusCode === 200) {
  145. resolve({ key: body.key, url: `${CDN_DOMAIN}/${body.key}` });
  146. } else {
  147. reject(new Error(`Upload failed: ${info.statusCode} ${JSON.stringify(body)}`));
  148. }
  149. });
  150. });
  151. }
  152. function uploadBuffer(buffer, cdnKey) {
  153. return new Promise((resolve, reject) => {
  154. const token = getUploadToken(BUCKET, cdnKey);
  155. const formUploader = new qiniu.form_up.FormUploader(new qiniu.conf.Config({
  156. zone: qiniu.zone.Zone_z2
  157. }));
  158. const putExtra = new qiniu.form_up.PutExtra();
  159. formUploader.put(token, cdnKey, buffer, putExtra, (err, body, info) => {
  160. if (err) {
  161. reject(err);
  162. return;
  163. }
  164. if (info.statusCode === 200) {
  165. resolve({ key: body.key, url: `${CDN_DOMAIN}/${body.key}` });
  166. } else {
  167. reject(new Error(`Upload failed: ${info.statusCode} ${JSON.stringify(body)}`));
  168. }
  169. });
  170. });
  171. }
  172. // ============================================
  173. // Parse REST API helpers
  174. // ============================================
  175. const PARSE_HEADERS = {
  176. 'X-Parse-Application-Id': APP_ID,
  177. 'X-Parse-Master-Key': MASTER_KEY,
  178. 'Content-Type': 'application/json'
  179. };
  180. async function ensureSkillClass() {
  181. const resp = await fetch(`${PARSE_BASE}/schemas/Skill`, {
  182. headers: PARSE_HEADERS
  183. });
  184. if (resp.ok) return;
  185. // Class 不存在,创建
  186. const createResp = await fetch(`${PARSE_BASE}/schemas/Skill`, {
  187. method: 'POST',
  188. headers: PARSE_HEADERS,
  189. body: JSON.stringify({
  190. className: 'Skill',
  191. fields: {
  192. name: { type: 'String' },
  193. displayName: { type: 'String' },
  194. description: { type: 'String' },
  195. category: { type: 'String' },
  196. version: { type: 'String' },
  197. author: { type: 'String' },
  198. cdnUrls: { type: 'Object' },
  199. cdnPrefix: { type: 'String' }
  200. }
  201. })
  202. });
  203. const result = await createResp.json();
  204. if (createResp.ok) {
  205. console.log(' ✅ Skill 表已创建');
  206. } else {
  207. console.log(' ⚠️ 创建 Skill 表:', result.error || JSON.stringify(result));
  208. }
  209. }
  210. async function parseQuery(className, where) {
  211. const qs = new URLSearchParams({
  212. where: JSON.stringify(where),
  213. limit: '1'
  214. });
  215. const resp = await fetch(`${PARSE_BASE}/classes/${className}?${qs}`, {
  216. headers: PARSE_HEADERS
  217. });
  218. const data = await resp.json();
  219. return data.results || [];
  220. }
  221. async function parseCreate(className, body) {
  222. const resp = await fetch(`${PARSE_BASE}/classes/${className}`, {
  223. method: 'POST',
  224. headers: PARSE_HEADERS,
  225. body: JSON.stringify(body)
  226. });
  227. return resp.json();
  228. }
  229. async function parseUpdate(className, objectId, body) {
  230. const resp = await fetch(`${PARSE_BASE}/classes/${className}/${objectId}`, {
  231. method: 'PUT',
  232. headers: PARSE_HEADERS,
  233. body: JSON.stringify(body)
  234. });
  235. return resp.json();
  236. }
  237. async function upsertSkill(skillData) {
  238. const existing = await parseQuery('Skill', { name: skillData.name });
  239. if (existing.length > 0) {
  240. const result = await parseUpdate('Skill', existing[0].objectId, skillData);
  241. return { action: 'updated', objectId: existing[0].objectId, ...result };
  242. } else {
  243. const result = await parseCreate('Skill', skillData);
  244. return { action: 'created', ...result };
  245. }
  246. }
  247. // ============================================
  248. // Skill metadata extraction
  249. // ============================================
  250. function parseSkillMdFrontmatter(filePath) {
  251. try {
  252. const content = fs.readFileSync(filePath, 'utf8');
  253. const match = content.match(/^---\n([\s\S]*?)\n---/);
  254. if (!match) return {};
  255. const fm = {};
  256. match[1].split('\n').forEach(line => {
  257. const idx = line.indexOf(':');
  258. if (idx > 0) {
  259. const key = line.substring(0, idx).trim();
  260. const val = line.substring(idx + 1).trim();
  261. fm[key] = val;
  262. }
  263. });
  264. return fm;
  265. } catch (e) {
  266. return {};
  267. }
  268. }
  269. function parseApiConfig(filePath) {
  270. try {
  271. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  272. } catch (e) {
  273. return {};
  274. }
  275. }
  276. function extractSkillMeta(skillDir, skillName) {
  277. const skillMdPath = path.join(skillDir, 'SKILL.md');
  278. const apiConfigPath = path.join(skillDir, 'api-config.json');
  279. const fm = parseSkillMdFrontmatter(skillMdPath);
  280. const cfg = fs.existsSync(apiConfigPath) ? parseApiConfig(apiConfigPath) : {};
  281. return {
  282. name: skillName,
  283. displayName: cfg.displayName || fm.name || skillName,
  284. description: cfg.description || fm.description || '',
  285. category: cfg.category || '',
  286. version: cfg.version || fm.version || '1.0.0',
  287. author: fm.author || 'nkkj-BrainHack'
  288. };
  289. }
  290. // ============================================
  291. // Collect workshop files
  292. // ============================================
  293. function collectWorkshopFiles() {
  294. const files = [];
  295. // Playbooks + supporting docs
  296. const allMdFiles = [...WORKSHOP_FILES.playbooks, ...WORKSHOP_FILES.supporting];
  297. for (const fileName of allMdFiles) {
  298. const filePath = path.join(WORKSHOP_DIR, fileName);
  299. if (fs.existsSync(filePath)) {
  300. files.push({
  301. localPath: filePath,
  302. cdnKey: `${CDN_PREFIX}/workshop/${fileName}`,
  303. fileName,
  304. type: WORKSHOP_FILES.playbooks.includes(fileName) ? 'playbook' : 'supporting'
  305. });
  306. } else {
  307. console.log(` ⚠️ 跳过 ${fileName}(不存在)`);
  308. }
  309. }
  310. // Memory templates
  311. for (const fileName of WORKSHOP_FILES.memoryTemplates) {
  312. const filePath = path.join(MEMORY_TEMPLATES_DIR, fileName);
  313. if (fs.existsSync(filePath)) {
  314. files.push({
  315. localPath: filePath,
  316. cdnKey: `${CDN_PREFIX}/workshop/memory-templates/${fileName}`,
  317. fileName: `memory-templates/${fileName}`,
  318. type: 'memory-template'
  319. });
  320. } else {
  321. console.log(` ⚠️ 跳过 memory-templates/${fileName}(不存在)`);
  322. }
  323. }
  324. return files;
  325. }
  326. function generateWorkshopManifest(workshopResults) {
  327. const playbooks = {};
  328. const supporting = {};
  329. const memoryTemplates = {};
  330. for (const item of workshopResults) {
  331. if (item.status !== 'ok') continue;
  332. if (item.type === 'playbook') {
  333. playbooks[item.fileName] = item.url;
  334. } else if (item.type === 'supporting') {
  335. supporting[item.fileName] = item.url;
  336. } else if (item.type === 'memory-template') {
  337. const baseName = item.fileName.replace('memory-templates/', '');
  338. memoryTemplates[baseName] = item.url;
  339. }
  340. }
  341. return {
  342. package: 'workshop',
  343. label: 'VOC虾工作坊',
  344. description: 'VOC 工作坊完整流程文件(v4.1 Side Quest 架构)',
  345. version: '4.1.0',
  346. generatedAt: new Date().toISOString(),
  347. architecture: {
  348. sessions: 6,
  349. sideQuests: 17,
  350. stateFlow: 'pending → in_progress → awaitingCalibration → exploring → completed'
  351. },
  352. installTarget: {
  353. playbooks: '~/.openclaw/workspace/',
  354. memoryTemplates: '~/.openclaw/workspace/memory/'
  355. },
  356. files: { playbooks, supporting, memoryTemplates }
  357. };
  358. }
  359. // ============================================
  360. // Collect skills
  361. // ============================================
  362. function collectSkills() {
  363. const skills = [];
  364. const dir = DEPLOYED_SKILLS_DIR;
  365. if (!fs.existsSync(dir)) {
  366. console.error(`Skills 目录不存在: ${dir}`);
  367. process.exit(1);
  368. }
  369. const entries = fs.readdirSync(dir).filter(d => {
  370. const fullPath = path.join(dir, d);
  371. if (!fs.statSync(fullPath).isDirectory()) return false;
  372. if (SKILL_FILTER.length > 0 && !SKILL_FILTER.includes(d)) return false;
  373. return fs.existsSync(path.join(fullPath, 'SKILL.md'));
  374. });
  375. for (const skillName of entries) {
  376. const skillDir = path.join(dir, skillName);
  377. const files = [];
  378. for (const fileName of ['SKILL.md', 'api-config.json']) {
  379. const filePath = path.join(skillDir, fileName);
  380. if (fs.existsSync(filePath)) {
  381. files.push({
  382. localPath: filePath,
  383. cdnKey: `${CDN_PREFIX}/${skillName}/${fileName}`,
  384. fileName
  385. });
  386. }
  387. }
  388. if (files.length > 0) {
  389. const meta = extractSkillMeta(skillDir, skillName);
  390. skills.push({ name: skillName, files, meta });
  391. }
  392. }
  393. return skills;
  394. }
  395. // ============================================
  396. // Generate README.md
  397. // ============================================
  398. function generateReadme(skillEntries, workshopManifestUrl) {
  399. const now = new Date().toISOString().replace('T', ' ').substring(0, 19);
  400. // 按技能包分组
  401. const pkgMap = {};
  402. for (const entry of skillEntries) {
  403. const pkgId = getPackageForCategory(entry.meta.category);
  404. if (!pkgMap[pkgId]) pkgMap[pkgId] = [];
  405. pkgMap[pkgId].push(entry);
  406. }
  407. // 统计可见技能数
  408. let visibleCount = 0;
  409. for (const [pkgId, entries] of Object.entries(pkgMap)) {
  410. if (SKILL_PACKAGES[pkgId]?.status !== 'hidden') visibleCount += entries.length;
  411. }
  412. const lines = [
  413. '# OpenClaw Skills 技能市场 + Workshop',
  414. '',
  415. `> 自动生成于 ${now},共 ${visibleCount} 个技能(分 ${Object.keys(pkgMap).filter(k => SKILL_PACKAGES[k]?.status !== 'hidden').length} 个套件)+ VOC工作坊 v4.1`,
  416. '',
  417. ];
  418. // 套件总览表
  419. lines.push('## 📦 套件总览');
  420. lines.push('');
  421. lines.push('| 套件 | 说明 | 技能数 | 状态 | 安装清单 |');
  422. lines.push('|------|------|--------|------|---------|');
  423. for (const [pkgId, pkg] of Object.entries(SKILL_PACKAGES)) {
  424. if (pkg.status === 'hidden') continue;
  425. const count = (pkgMap[pkgId] || []).length;
  426. const statusBadge = pkg.status === 'stable' ? '✅ 正式' : '🧪 测试中';
  427. const installUrl = `${CDN_DOMAIN}/${CDN_PREFIX}/packages/${pkgId}.json`;
  428. lines.push(`| ${pkg.emoji} **${pkg.label}** | ${pkg.description} | ${count} | ${statusBadge} | [${pkgId}.json](${installUrl}) |`);
  429. }
  430. lines.push('');
  431. lines.push('---');
  432. lines.push('');
  433. // 每个套件详细内容
  434. for (const [pkgId, pkg] of Object.entries(SKILL_PACKAGES)) {
  435. if (pkg.status === 'hidden') continue;
  436. const entries = pkgMap[pkgId] || [];
  437. if (entries.length === 0) continue;
  438. const statusNote = pkg.status === 'testing' ? ' ⚠️ 测试中,功能可能变更' : '';
  439. lines.push(`## ${pkg.emoji} ${pkg.label}${statusNote}`);
  440. lines.push('');
  441. lines.push(`> ${pkg.description}`);
  442. lines.push('>');
  443. const installUrl = `${CDN_DOMAIN}/${CDN_PREFIX}/packages/${pkgId}.json`;
  444. lines.push(`> 📥 一键安装清单:[\`${pkgId}.json\`](${installUrl})`);
  445. lines.push('');
  446. // 按 category 子分组
  447. const catMap = {};
  448. for (const entry of entries) {
  449. const cat = entry.meta.category || '其他';
  450. if (!catMap[cat]) catMap[cat] = [];
  451. catMap[cat].push(entry);
  452. }
  453. for (const [cat, catEntries] of Object.entries(catMap).sort()) {
  454. lines.push(`### ${cat}`);
  455. lines.push('');
  456. lines.push('| 技能名 | 说明 | 版本 | 下载 |');
  457. lines.push('|--------|------|------|------|');
  458. for (const entry of catEntries) {
  459. const m = entry.meta;
  460. const desc = m.description.length > 60 ? m.description.substring(0, 60) + '...' : m.description;
  461. const skillMdUrl = entry.cdnUrls['SKILL.md'] || '';
  462. const apiCfgUrl = entry.cdnUrls['api-config.json'] || '';
  463. const links = [];
  464. if (skillMdUrl) links.push(`[SKILL.md](${skillMdUrl})`);
  465. if (apiCfgUrl) links.push(`[api-config.json](${apiCfgUrl})`);
  466. lines.push(`| **${m.displayName}** (\`${m.name}\`) | ${desc} | ${m.version} | ${links.join(' / ')} |`);
  467. }
  468. lines.push('');
  469. }
  470. }
  471. lines.push('---');
  472. lines.push('');
  473. lines.push('## 快速使用');
  474. lines.push('');
  475. lines.push('每个 Skill 包含两个文件:');
  476. lines.push('- **SKILL.md** — 技能说明文档(功能、入参、调用链、注意事项)');
  477. lines.push('- **api-config.json** — API 配置(endpoint、参数 schema、错误处理)');
  478. lines.push('');
  479. lines.push('下载后放入 `~/.openclaw/skills/<skill-name>/` 即可被 OpenClaw Agent 识别和调用。');
  480. lines.push('');
  481. lines.push('### 按套件安装');
  482. lines.push('');
  483. lines.push('下载对应套件的 `.json` 安装清单,包含该套件所有技能的 CDN 下载地址。');
  484. lines.push('');
  485. // Workshop section
  486. if (workshopManifestUrl) {
  487. lines.push('---');
  488. lines.push('');
  489. lines.push('## 🦐 VOC虾工作坊 v4.1');
  490. lines.push('');
  491. lines.push('> VOC 工作坊完整流程文件,含 Side Quest 支线探索系统');
  492. lines.push('>');
  493. lines.push(`> 📥 安装清单:[\`workshop.json\`](${workshopManifestUrl})`);
  494. lines.push('');
  495. lines.push('**架构**:6个Session + 17个Side Quest,主线感知 + 支线深耕');
  496. lines.push('');
  497. lines.push('| 文件 | 说明 |');
  498. lines.push('|------|------|');
  499. lines.push('| `workshop-voc-playbook.md` | 入口路由 |');
  500. lines.push('| `workshop-global-rules.md` | 全局规则 + Side Quest Protocol |');
  501. lines.push('| `workshop-session-0~5-*.md` | Session 0-5 流程文件 |');
  502. lines.push('| `memory-templates/*.json` | 记忆模板(9个JSON) |');
  503. lines.push('');
  504. lines.push('安装方式:将 playbook 文件放入 `~/.openclaw/workspace/`,memory templates 放入 `~/.openclaw/workspace/memory/`');
  505. lines.push('');
  506. }
  507. return lines.join('\n');
  508. }
  509. // 生成每个技能包的 install.json 清单
  510. function generatePackageManifests(skillEntries) {
  511. const pkgMap = {};
  512. for (const entry of skillEntries) {
  513. const pkgId = getPackageForCategory(entry.meta.category);
  514. if (!pkgMap[pkgId]) pkgMap[pkgId] = [];
  515. pkgMap[pkgId].push(entry);
  516. }
  517. const manifests = {};
  518. for (const [pkgId, pkg] of Object.entries(SKILL_PACKAGES)) {
  519. if (pkg.status === 'hidden') continue;
  520. const entries = pkgMap[pkgId] || [];
  521. if (entries.length === 0) continue;
  522. manifests[pkgId] = {
  523. package: pkgId,
  524. label: pkg.label,
  525. description: pkg.description,
  526. status: pkg.status,
  527. skillCount: entries.length,
  528. generatedAt: new Date().toISOString(),
  529. skills: entries.map(e => ({
  530. name: e.meta.name,
  531. displayName: e.meta.displayName,
  532. category: e.meta.category,
  533. version: e.meta.version,
  534. files: e.cdnUrls
  535. }))
  536. };
  537. }
  538. return manifests;
  539. }
  540. // ============================================
  541. // Main
  542. // ============================================
  543. async function main() {
  544. console.log('==================================================');
  545. console.log(' OpenClaw Skills + Workshop CDN 上传 v4.1');
  546. console.log('==================================================');
  547. console.log('');
  548. if (SKILLS_ONLY) console.log(' 📌 模式: 仅上传 Skills');
  549. if (WORKSHOP_ONLY) console.log(' 📌 模式: 仅上传 Workshop');
  550. console.log('');
  551. // 检查凭证
  552. if (QINIU_ACCESS_KEY === 'YOUR_ACCESS_KEY_HERE' || QINIU_SECRET_KEY === 'YOUR_SECRET_KEY_HERE') {
  553. console.error('❌ 请先配置七牛云凭证!');
  554. console.error(' 方式1: 编辑脚本中的 QINIU_ACCESS_KEY / QINIU_SECRET_KEY');
  555. console.error(' 方式2: 设置环境变量 QINIU_AK / QINIU_SK');
  556. process.exit(1);
  557. }
  558. console.log(`☁️ Bucket: ${BUCKET}`);
  559. console.log(`🔗 CDN前缀: ${CDN_PREFIX}`);
  560. console.log('');
  561. // 收集技能
  562. const skills = WORKSHOP_ONLY ? [] : collectSkills();
  563. if (!WORKSHOP_ONLY) {
  564. console.log(`📦 找到 ${skills.length} 个技能待上传`);
  565. console.log(`📂 Skills源: ${DEPLOYED_SKILLS_DIR}`);
  566. }
  567. // 收集 workshop 文件
  568. const workshopFiles = SKILLS_ONLY ? [] : collectWorkshopFiles();
  569. if (!SKILLS_ONLY) {
  570. console.log(`� 找到 ${workshopFiles.length} 个 workshop 文件待上传`);
  571. console.log(`📂 Workshop源: ${WORKSHOP_DIR}`);
  572. }
  573. let totalFiles = 0;
  574. skills.forEach(s => { totalFiles += s.files.length; });
  575. totalFiles += workshopFiles.length;
  576. console.log(`📄 总计 ${totalFiles} 个文件`);
  577. console.log('');
  578. // ── Step 1: 上传 Skill 文件到七牛 ──
  579. let successCount = 0;
  580. let failCount = 0;
  581. const results = [];
  582. if (!WORKSHOP_ONLY && skills.length > 0) {
  583. console.log('── Step 1: 上传 Skills 到七牛云 ──');
  584. for (const skill of skills) {
  585. process.stdout.write(` 📦 ${skill.name} ... `);
  586. const skillResults = [];
  587. const cdnUrls = {};
  588. for (const file of skill.files) {
  589. try {
  590. const result = await uploadFile(file.localPath, file.cdnKey);
  591. skillResults.push({ ...result, fileName: file.fileName, status: 'ok' });
  592. cdnUrls[file.fileName] = result.url;
  593. successCount++;
  594. } catch (err) {
  595. skillResults.push({ fileName: file.fileName, status: 'fail', error: err.message });
  596. failCount++;
  597. }
  598. }
  599. const allOk = skillResults.every(r => r.status === 'ok');
  600. console.log(allOk ? '✅' : '⚠️');
  601. results.push({ name: skill.name, files: skillResults, meta: skill.meta, cdnUrls });
  602. }
  603. console.log('');
  604. console.log(` Skills 上传完成!成功: ${successCount} 失败: ${failCount}`);
  605. console.log('');
  606. } else if (!WORKSHOP_ONLY) {
  607. console.log('── Step 1: 跳过(无 Skills)──');
  608. console.log('');
  609. }
  610. // ── Step 1b: 上传 Workshop 文件到七牛 ──
  611. let wsSuccessCount = 0;
  612. let wsFailCount = 0;
  613. const workshopResults = [];
  614. if (!SKILLS_ONLY && workshopFiles.length > 0) {
  615. console.log('── Step 1b: 上传 Workshop 文件到七牛云 ──');
  616. for (const file of workshopFiles) {
  617. process.stdout.write(` 📝 ${file.fileName} ... `);
  618. try {
  619. const result = await uploadFile(file.localPath, file.cdnKey);
  620. workshopResults.push({ ...result, fileName: file.fileName, type: file.type, status: 'ok' });
  621. wsSuccessCount++;
  622. console.log('✅');
  623. } catch (err) {
  624. workshopResults.push({ fileName: file.fileName, type: file.type, status: 'fail', error: err.message });
  625. wsFailCount++;
  626. console.log(`❌ ${err.message}`);
  627. }
  628. }
  629. console.log('');
  630. console.log(` Workshop 上传完成!成功: ${wsSuccessCount} 失败: ${wsFailCount}`);
  631. console.log('');
  632. }
  633. // ── Step 2: 写入 Parse Skill 表 ──
  634. let dbSuccess = 0;
  635. let dbFail = 0;
  636. if (!WORKSHOP_ONLY && results.length > 0) {
  637. console.log('── Step 2: 同步到 Parse 数据库 (Skill 表) ──');
  638. await ensureSkillClass();
  639. for (const entry of results) {
  640. if (Object.keys(entry.cdnUrls).length === 0) continue;
  641. process.stdout.write(` 💾 ${entry.name} ... `);
  642. try {
  643. // Parse 不允许嵌套 key 含 '.',将文件名中的 '.' 替换为 '_'
  644. const safeCdnUrls = {};
  645. for (const [k, v] of Object.entries(entry.cdnUrls)) {
  646. safeCdnUrls[k.replace(/\./g, '_')] = v;
  647. }
  648. const skillData = {
  649. name: entry.meta.name,
  650. displayName: entry.meta.displayName,
  651. description: entry.meta.description,
  652. category: entry.meta.category,
  653. version: entry.meta.version,
  654. author: entry.meta.author,
  655. cdnUrls: safeCdnUrls,
  656. cdnPrefix: `${CDN_DOMAIN}/${CDN_PREFIX}/${entry.name}`
  657. };
  658. const result = await upsertSkill(skillData);
  659. console.log(`${result.action === 'created' ? '🆕 创建' : '🔄 更新'} (${result.objectId})`);
  660. dbSuccess++;
  661. } catch (err) {
  662. console.log(`❌ ${err.message}`);
  663. dbFail++;
  664. }
  665. }
  666. console.log('');
  667. console.log(` 数据库同步完成!成功: ${dbSuccess} 失败: ${dbFail}`);
  668. console.log('');
  669. }
  670. // ── Step 3: 生成清单 + README 并上传 ──
  671. console.log('── Step 3: 生成清单 + README 并上传 ──');
  672. const packageUrls = {};
  673. // 上传每个技能包的 install.json
  674. if (!WORKSHOP_ONLY && results.length > 0) {
  675. const manifests = generatePackageManifests(results);
  676. for (const [pkgId, manifest] of Object.entries(manifests)) {
  677. try {
  678. const key = `${CDN_PREFIX}/packages/${pkgId}.json`;
  679. const buf = Buffer.from(JSON.stringify(manifest, null, 2), 'utf8');
  680. const res = await uploadBuffer(buf, key);
  681. packageUrls[pkgId] = res.url;
  682. const pkg = SKILL_PACKAGES[pkgId];
  683. const statusTag = pkg.status === 'testing' ? ' 🧪' : ' ✅';
  684. console.log(` ${statusTag} ${pkg.label} (${manifest.skillCount}个技能): ${res.url}`);
  685. } catch (err) {
  686. console.log(` ❌ ${pkgId}.json 上传失败: ${err.message}`);
  687. }
  688. }
  689. }
  690. // 上传 workshop manifest
  691. let workshopManifestUrl = '';
  692. if (!SKILLS_ONLY && workshopResults.length > 0) {
  693. try {
  694. const wsManifest = generateWorkshopManifest(workshopResults);
  695. const key = `${CDN_PREFIX}/packages/workshop.json`;
  696. const buf = Buffer.from(JSON.stringify(wsManifest, null, 2), 'utf8');
  697. const res = await uploadBuffer(buf, key);
  698. workshopManifestUrl = res.url;
  699. packageUrls['workshop'] = res.url;
  700. const okCount = workshopResults.filter(r => r.status === 'ok').length;
  701. console.log(` 🦐 VOC虾工作坊 (${okCount}个文件): ${res.url}`);
  702. } catch (err) {
  703. console.log(` ❌ workshop.json 上传失败: ${err.message}`);
  704. }
  705. }
  706. // 上传 README.md
  707. let readmeUrl = '';
  708. try {
  709. const readme = generateReadme(results, workshopManifestUrl);
  710. const readmeKey = `${CDN_PREFIX}/README.md`;
  711. const readmeResult = await uploadBuffer(Buffer.from(readme, 'utf8'), readmeKey);
  712. readmeUrl = readmeResult.url;
  713. console.log(` ✅ README.md 已上传: ${readmeUrl}`);
  714. } catch (err) {
  715. console.log(` ❌ README.md 上传失败: ${err.message}`);
  716. }
  717. console.log('');
  718. // ── 输出 URL 汇总 ──
  719. console.log('📋 下载地址:');
  720. console.log('');
  721. for (const [pkgId, url] of Object.entries(packageUrls)) {
  722. if (pkgId === 'workshop') {
  723. console.log(` 🦐 VOC虾工作坊`);
  724. } else {
  725. const pkg = SKILL_PACKAGES[pkgId];
  726. if (!pkg) continue;
  727. const statusTag = pkg.status === 'testing' ? '🧪' : '✅';
  728. console.log(` ${statusTag} ${pkg.label}`);
  729. }
  730. console.log(` 👉 ${url}`);
  731. }
  732. console.log('');
  733. // 保存 URL 列表到文件(按技能包分组)
  734. const urlMap = { _packages: packageUrls };
  735. if (readmeUrl) urlMap['_README'] = readmeUrl;
  736. for (const entry of results) {
  737. if (Object.keys(entry.cdnUrls).length > 0) {
  738. urlMap[entry.name] = entry.cdnUrls;
  739. }
  740. }
  741. if (workshopResults.length > 0) {
  742. urlMap['_workshop'] = {};
  743. for (const item of workshopResults) {
  744. if (item.status === 'ok') urlMap['_workshop'][item.fileName] = item.url;
  745. }
  746. }
  747. const outputPath = path.resolve(__dirname, '..', 'skills-cdn-urls.json');
  748. fs.writeFileSync(outputPath, JSON.stringify(urlMap, null, 2), 'utf8');
  749. console.log(`💾 URL 列表已保存到: ${outputPath}`);
  750. console.log('');
  751. console.log('==================================================');
  752. console.log('📌 索引:');
  753. console.log(` README: ${CDN_DOMAIN}/${CDN_PREFIX}/README.md`);
  754. for (const [pkgId, url] of Object.entries(packageUrls)) {
  755. if (pkgId === 'workshop') {
  756. console.log(` 🦐 VOC虾工作坊: ${url}`);
  757. } else {
  758. const pkg = SKILL_PACKAGES[pkgId];
  759. if (pkg) console.log(` ${pkg.emoji} ${pkg.label}: ${url}`);
  760. }
  761. }
  762. console.log('==================================================');
  763. }
  764. main().catch(err => {
  765. console.error('❌ 上传出错:', err);
  766. process.exit(1);
  767. });