upload-skills.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  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. const SKILL_PACKAGES = {
  44. 'voc-core': {
  45. label: 'VOC 核心套件',
  46. description: 'Amazon 品类洞察 + 评论分析 + 竞品分析 + 数据合成 + 社媒VOC + 工作坊',
  47. categories: ['voc', 'review-analysis', 'competitor-analysis', 'synthesis', 'social-voc', 'workshop'],
  48. status: 'stable', // stable | testing | hidden
  49. emoji: '🔍'
  50. },
  51. 'social-media': {
  52. label: '社媒数据套件',
  53. description: 'TikTok / Instagram / 抖音数据采集',
  54. categories: ['social-media', 'douyin'],
  55. status: 'stable',
  56. emoji: '📱'
  57. },
  58. 'ai-creation': {
  59. label: 'AI 创作套件',
  60. description: '即梦AI图片视频生成 + 一键成片(测试中)',
  61. categories: ['jimeng', 'video-creation'],
  62. status: 'testing',
  63. emoji: '🎨'
  64. },
  65. '_other': {
  66. label: '其他/测试',
  67. description: '测试技能,不对外提供',
  68. categories: ['other'],
  69. status: 'hidden',
  70. emoji: '🧪'
  71. }
  72. };
  73. function getPackageForCategory(category) {
  74. for (const [pkgId, pkg] of Object.entries(SKILL_PACKAGES)) {
  75. if (pkg.categories.includes(category || 'other')) return pkgId;
  76. }
  77. return '_other';
  78. }
  79. // ============================================
  80. // 七牛上传
  81. // ============================================
  82. function getUploadToken(bucket, key) {
  83. const mac = new qiniu.auth.digest.Mac(QINIU_ACCESS_KEY, QINIU_SECRET_KEY);
  84. const putPolicy = new qiniu.rs.PutPolicy({
  85. scope: `${bucket}:${key}`,
  86. expires: 3600
  87. });
  88. return putPolicy.uploadToken(mac);
  89. }
  90. function uploadFile(localFile, cdnKey) {
  91. return new Promise((resolve, reject) => {
  92. const token = getUploadToken(BUCKET, cdnKey);
  93. const formUploader = new qiniu.form_up.FormUploader(new qiniu.conf.Config({
  94. zone: qiniu.zone.Zone_z2
  95. }));
  96. const putExtra = new qiniu.form_up.PutExtra();
  97. formUploader.putFile(token, cdnKey, localFile, putExtra, (err, body, info) => {
  98. if (err) {
  99. reject(err);
  100. return;
  101. }
  102. if (info.statusCode === 200) {
  103. resolve({ key: body.key, url: `${CDN_DOMAIN}/${body.key}` });
  104. } else {
  105. reject(new Error(`Upload failed: ${info.statusCode} ${JSON.stringify(body)}`));
  106. }
  107. });
  108. });
  109. }
  110. function uploadBuffer(buffer, cdnKey) {
  111. return new Promise((resolve, reject) => {
  112. const token = getUploadToken(BUCKET, cdnKey);
  113. const formUploader = new qiniu.form_up.FormUploader(new qiniu.conf.Config({
  114. zone: qiniu.zone.Zone_z2
  115. }));
  116. const putExtra = new qiniu.form_up.PutExtra();
  117. formUploader.put(token, cdnKey, buffer, putExtra, (err, body, info) => {
  118. if (err) {
  119. reject(err);
  120. return;
  121. }
  122. if (info.statusCode === 200) {
  123. resolve({ key: body.key, url: `${CDN_DOMAIN}/${body.key}` });
  124. } else {
  125. reject(new Error(`Upload failed: ${info.statusCode} ${JSON.stringify(body)}`));
  126. }
  127. });
  128. });
  129. }
  130. // ============================================
  131. // Parse REST API helpers
  132. // ============================================
  133. const PARSE_HEADERS = {
  134. 'X-Parse-Application-Id': APP_ID,
  135. 'X-Parse-Master-Key': MASTER_KEY,
  136. 'Content-Type': 'application/json'
  137. };
  138. async function ensureSkillClass() {
  139. const resp = await fetch(`${PARSE_BASE}/schemas/Skill`, {
  140. headers: PARSE_HEADERS
  141. });
  142. if (resp.ok) return;
  143. // Class 不存在,创建
  144. const createResp = await fetch(`${PARSE_BASE}/schemas/Skill`, {
  145. method: 'POST',
  146. headers: PARSE_HEADERS,
  147. body: JSON.stringify({
  148. className: 'Skill',
  149. fields: {
  150. name: { type: 'String' },
  151. displayName: { type: 'String' },
  152. description: { type: 'String' },
  153. category: { type: 'String' },
  154. version: { type: 'String' },
  155. author: { type: 'String' },
  156. cdnUrls: { type: 'Object' },
  157. cdnPrefix: { type: 'String' }
  158. }
  159. })
  160. });
  161. const result = await createResp.json();
  162. if (createResp.ok) {
  163. console.log(' ✅ Skill 表已创建');
  164. } else {
  165. console.log(' ⚠️ 创建 Skill 表:', result.error || JSON.stringify(result));
  166. }
  167. }
  168. async function parseQuery(className, where) {
  169. const qs = new URLSearchParams({
  170. where: JSON.stringify(where),
  171. limit: '1'
  172. });
  173. const resp = await fetch(`${PARSE_BASE}/classes/${className}?${qs}`, {
  174. headers: PARSE_HEADERS
  175. });
  176. const data = await resp.json();
  177. return data.results || [];
  178. }
  179. async function parseCreate(className, body) {
  180. const resp = await fetch(`${PARSE_BASE}/classes/${className}`, {
  181. method: 'POST',
  182. headers: PARSE_HEADERS,
  183. body: JSON.stringify(body)
  184. });
  185. return resp.json();
  186. }
  187. async function parseUpdate(className, objectId, body) {
  188. const resp = await fetch(`${PARSE_BASE}/classes/${className}/${objectId}`, {
  189. method: 'PUT',
  190. headers: PARSE_HEADERS,
  191. body: JSON.stringify(body)
  192. });
  193. return resp.json();
  194. }
  195. async function upsertSkill(skillData) {
  196. const existing = await parseQuery('Skill', { name: skillData.name });
  197. if (existing.length > 0) {
  198. const result = await parseUpdate('Skill', existing[0].objectId, skillData);
  199. return { action: 'updated', objectId: existing[0].objectId, ...result };
  200. } else {
  201. const result = await parseCreate('Skill', skillData);
  202. return { action: 'created', ...result };
  203. }
  204. }
  205. // ============================================
  206. // Skill metadata extraction
  207. // ============================================
  208. function parseSkillMdFrontmatter(filePath) {
  209. try {
  210. const content = fs.readFileSync(filePath, 'utf8');
  211. const match = content.match(/^---\n([\s\S]*?)\n---/);
  212. if (!match) return {};
  213. const fm = {};
  214. match[1].split('\n').forEach(line => {
  215. const idx = line.indexOf(':');
  216. if (idx > 0) {
  217. const key = line.substring(0, idx).trim();
  218. const val = line.substring(idx + 1).trim();
  219. fm[key] = val;
  220. }
  221. });
  222. return fm;
  223. } catch (e) {
  224. return {};
  225. }
  226. }
  227. function parseApiConfig(filePath) {
  228. try {
  229. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  230. } catch (e) {
  231. return {};
  232. }
  233. }
  234. function extractSkillMeta(skillDir, skillName) {
  235. const skillMdPath = path.join(skillDir, 'SKILL.md');
  236. const apiConfigPath = path.join(skillDir, 'api-config.json');
  237. const fm = parseSkillMdFrontmatter(skillMdPath);
  238. const cfg = fs.existsSync(apiConfigPath) ? parseApiConfig(apiConfigPath) : {};
  239. return {
  240. name: skillName,
  241. displayName: cfg.displayName || fm.name || skillName,
  242. description: cfg.description || fm.description || '',
  243. category: cfg.category || '',
  244. version: cfg.version || fm.version || '1.0.0',
  245. author: fm.author || 'nkkj-BrainHack'
  246. };
  247. }
  248. // ============================================
  249. // Collect skills
  250. // ============================================
  251. function collectSkills() {
  252. const skills = [];
  253. const dir = DEPLOYED_SKILLS_DIR;
  254. if (!fs.existsSync(dir)) {
  255. console.error(`Skills 目录不存在: ${dir}`);
  256. process.exit(1);
  257. }
  258. const entries = fs.readdirSync(dir).filter(d => {
  259. const fullPath = path.join(dir, d);
  260. if (!fs.statSync(fullPath).isDirectory()) return false;
  261. if (SKILL_FILTER.length > 0 && !SKILL_FILTER.includes(d)) return false;
  262. return fs.existsSync(path.join(fullPath, 'SKILL.md'));
  263. });
  264. for (const skillName of entries) {
  265. const skillDir = path.join(dir, skillName);
  266. const files = [];
  267. for (const fileName of ['SKILL.md', 'api-config.json']) {
  268. const filePath = path.join(skillDir, fileName);
  269. if (fs.existsSync(filePath)) {
  270. files.push({
  271. localPath: filePath,
  272. cdnKey: `${CDN_PREFIX}/${skillName}/${fileName}`,
  273. fileName
  274. });
  275. }
  276. }
  277. if (files.length > 0) {
  278. const meta = extractSkillMeta(skillDir, skillName);
  279. skills.push({ name: skillName, files, meta });
  280. }
  281. }
  282. return skills;
  283. }
  284. // ============================================
  285. // Generate README.md
  286. // ============================================
  287. function generateReadme(skillEntries) {
  288. const now = new Date().toISOString().replace('T', ' ').substring(0, 19);
  289. // 按技能包分组
  290. const pkgMap = {};
  291. for (const entry of skillEntries) {
  292. const pkgId = getPackageForCategory(entry.meta.category);
  293. if (!pkgMap[pkgId]) pkgMap[pkgId] = [];
  294. pkgMap[pkgId].push(entry);
  295. }
  296. // 统计可见技能数
  297. let visibleCount = 0;
  298. for (const [pkgId, entries] of Object.entries(pkgMap)) {
  299. if (SKILL_PACKAGES[pkgId]?.status !== 'hidden') visibleCount += entries.length;
  300. }
  301. const lines = [
  302. '# OpenClaw Skills 技能市场',
  303. '',
  304. `> 自动生成于 ${now},共 ${visibleCount} 个技能(分 ${Object.keys(pkgMap).filter(k => SKILL_PACKAGES[k]?.status !== 'hidden').length} 个套件)`,
  305. '',
  306. ];
  307. // 套件总览表
  308. lines.push('## 📦 套件总览');
  309. lines.push('');
  310. lines.push('| 套件 | 说明 | 技能数 | 状态 | 安装清单 |');
  311. lines.push('|------|------|--------|------|---------|');
  312. for (const [pkgId, pkg] of Object.entries(SKILL_PACKAGES)) {
  313. if (pkg.status === 'hidden') continue;
  314. const count = (pkgMap[pkgId] || []).length;
  315. const statusBadge = pkg.status === 'stable' ? '✅ 正式' : '🧪 测试中';
  316. const installUrl = `${CDN_DOMAIN}/${CDN_PREFIX}/packages/${pkgId}.json`;
  317. lines.push(`| ${pkg.emoji} **${pkg.label}** | ${pkg.description} | ${count} | ${statusBadge} | [${pkgId}.json](${installUrl}) |`);
  318. }
  319. lines.push('');
  320. lines.push('---');
  321. lines.push('');
  322. // 每个套件详细内容
  323. for (const [pkgId, pkg] of Object.entries(SKILL_PACKAGES)) {
  324. if (pkg.status === 'hidden') continue;
  325. const entries = pkgMap[pkgId] || [];
  326. if (entries.length === 0) continue;
  327. const statusNote = pkg.status === 'testing' ? ' ⚠️ 测试中,功能可能变更' : '';
  328. lines.push(`## ${pkg.emoji} ${pkg.label}${statusNote}`);
  329. lines.push('');
  330. lines.push(`> ${pkg.description}`);
  331. lines.push('>');
  332. const installUrl = `${CDN_DOMAIN}/${CDN_PREFIX}/packages/${pkgId}.json`;
  333. lines.push(`> 📥 一键安装清单:[\`${pkgId}.json\`](${installUrl})`);
  334. lines.push('');
  335. // 按 category 子分组
  336. const catMap = {};
  337. for (const entry of entries) {
  338. const cat = entry.meta.category || '其他';
  339. if (!catMap[cat]) catMap[cat] = [];
  340. catMap[cat].push(entry);
  341. }
  342. for (const [cat, catEntries] of Object.entries(catMap).sort()) {
  343. lines.push(`### ${cat}`);
  344. lines.push('');
  345. lines.push('| 技能名 | 说明 | 版本 | 下载 |');
  346. lines.push('|--------|------|------|------|');
  347. for (const entry of catEntries) {
  348. const m = entry.meta;
  349. const desc = m.description.length > 60 ? m.description.substring(0, 60) + '...' : m.description;
  350. const skillMdUrl = entry.cdnUrls['SKILL.md'] || '';
  351. const apiCfgUrl = entry.cdnUrls['api-config.json'] || '';
  352. const links = [];
  353. if (skillMdUrl) links.push(`[SKILL.md](${skillMdUrl})`);
  354. if (apiCfgUrl) links.push(`[api-config.json](${apiCfgUrl})`);
  355. lines.push(`| **${m.displayName}** (\`${m.name}\`) | ${desc} | ${m.version} | ${links.join(' / ')} |`);
  356. }
  357. lines.push('');
  358. }
  359. }
  360. lines.push('---');
  361. lines.push('');
  362. lines.push('## 快速使用');
  363. lines.push('');
  364. lines.push('每个 Skill 包含两个文件:');
  365. lines.push('- **SKILL.md** — 技能说明文档(功能、入参、调用链、注意事项)');
  366. lines.push('- **api-config.json** — API 配置(endpoint、参数 schema、错误处理)');
  367. lines.push('');
  368. lines.push('下载后放入 `~/.openclaw/skills/<skill-name>/` 即可被 OpenClaw Agent 识别和调用。');
  369. lines.push('');
  370. lines.push('### 按套件安装');
  371. lines.push('');
  372. lines.push('下载对应套件的 `.json` 安装清单,包含该套件所有技能的 CDN 下载地址。');
  373. lines.push('');
  374. return lines.join('\n');
  375. }
  376. // 生成每个技能包的 install.json 清单
  377. function generatePackageManifests(skillEntries) {
  378. const pkgMap = {};
  379. for (const entry of skillEntries) {
  380. const pkgId = getPackageForCategory(entry.meta.category);
  381. if (!pkgMap[pkgId]) pkgMap[pkgId] = [];
  382. pkgMap[pkgId].push(entry);
  383. }
  384. const manifests = {};
  385. for (const [pkgId, pkg] of Object.entries(SKILL_PACKAGES)) {
  386. if (pkg.status === 'hidden') continue;
  387. const entries = pkgMap[pkgId] || [];
  388. if (entries.length === 0) continue;
  389. manifests[pkgId] = {
  390. package: pkgId,
  391. label: pkg.label,
  392. description: pkg.description,
  393. status: pkg.status,
  394. skillCount: entries.length,
  395. generatedAt: new Date().toISOString(),
  396. skills: entries.map(e => ({
  397. name: e.meta.name,
  398. displayName: e.meta.displayName,
  399. category: e.meta.category,
  400. version: e.meta.version,
  401. files: e.cdnUrls
  402. }))
  403. };
  404. }
  405. return manifests;
  406. }
  407. // ============================================
  408. // Main
  409. // ============================================
  410. async function main() {
  411. console.log('==================================================');
  412. console.log(' OpenClaw Skills CDN 上传 + Parse 同步脚本');
  413. console.log('==================================================');
  414. console.log('');
  415. // 检查凭证
  416. if (QINIU_ACCESS_KEY === 'YOUR_ACCESS_KEY_HERE' || QINIU_SECRET_KEY === 'YOUR_SECRET_KEY_HERE') {
  417. console.error('❌ 请先配置七牛云凭证!');
  418. console.error(' 方式1: 编辑脚本中的 QINIU_ACCESS_KEY / QINIU_SECRET_KEY');
  419. console.error(' 方式2: 设置环境变量 QINIU_AK / QINIU_SK');
  420. process.exit(1);
  421. }
  422. // 收集技能
  423. const skills = collectSkills();
  424. console.log(`📦 找到 ${skills.length} 个技能待上传`);
  425. console.log(`📂 源目录: ${DEPLOYED_SKILLS_DIR}`);
  426. console.log(`☁️ Bucket: ${BUCKET}`);
  427. console.log(`🔗 CDN前缀: ${CDN_PREFIX}`);
  428. console.log('');
  429. let totalFiles = 0;
  430. skills.forEach(s => { totalFiles += s.files.length; });
  431. console.log(`📄 总计 ${totalFiles} 个文件`);
  432. console.log('');
  433. // ── Step 1: 上传文件到七牛 ──
  434. console.log('── Step 1: 上传文件到七牛云 ──');
  435. let successCount = 0;
  436. let failCount = 0;
  437. const results = [];
  438. for (const skill of skills) {
  439. process.stdout.write(` 📦 ${skill.name} ... `);
  440. const skillResults = [];
  441. const cdnUrls = {};
  442. for (const file of skill.files) {
  443. try {
  444. const result = await uploadFile(file.localPath, file.cdnKey);
  445. skillResults.push({ ...result, fileName: file.fileName, status: 'ok' });
  446. cdnUrls[file.fileName] = result.url;
  447. successCount++;
  448. } catch (err) {
  449. skillResults.push({ fileName: file.fileName, status: 'fail', error: err.message });
  450. failCount++;
  451. }
  452. }
  453. const allOk = skillResults.every(r => r.status === 'ok');
  454. console.log(allOk ? '✅' : '⚠️');
  455. results.push({ name: skill.name, files: skillResults, meta: skill.meta, cdnUrls });
  456. }
  457. console.log('');
  458. console.log(` 上传完成!成功: ${successCount} 失败: ${failCount}`);
  459. console.log('');
  460. // ── Step 2: 写入 Parse Skill 表 ──
  461. console.log('── Step 2: 同步到 Parse 数据库 (Skill 表) ──');
  462. await ensureSkillClass();
  463. let dbSuccess = 0;
  464. let dbFail = 0;
  465. for (const entry of results) {
  466. if (Object.keys(entry.cdnUrls).length === 0) continue;
  467. process.stdout.write(` 💾 ${entry.name} ... `);
  468. try {
  469. // Parse 不允许嵌套 key 含 '.',将文件名中的 '.' 替换为 '_'
  470. const safeCdnUrls = {};
  471. for (const [k, v] of Object.entries(entry.cdnUrls)) {
  472. safeCdnUrls[k.replace(/\./g, '_')] = v;
  473. }
  474. const skillData = {
  475. name: entry.meta.name,
  476. displayName: entry.meta.displayName,
  477. description: entry.meta.description,
  478. category: entry.meta.category,
  479. version: entry.meta.version,
  480. author: entry.meta.author,
  481. cdnUrls: safeCdnUrls,
  482. cdnPrefix: `${CDN_DOMAIN}/${CDN_PREFIX}/${entry.name}`
  483. };
  484. const result = await upsertSkill(skillData);
  485. console.log(`${result.action === 'created' ? '🆕 创建' : '🔄 更新'} (${result.objectId})`);
  486. dbSuccess++;
  487. } catch (err) {
  488. console.log(`❌ ${err.message}`);
  489. dbFail++;
  490. }
  491. }
  492. console.log('');
  493. console.log(` 数据库同步完成!成功: ${dbSuccess} 失败: ${dbFail}`);
  494. console.log('');
  495. // ── Step 3: 生成 README.md + 技能包清单 并上传 ──
  496. console.log('── Step 3: 生成 README.md + 技能包清单 并上传 ──');
  497. let readmeUrl = '';
  498. try {
  499. const readme = generateReadme(results);
  500. const readmeKey = `${CDN_PREFIX}/README.md`;
  501. const readmeResult = await uploadBuffer(Buffer.from(readme, 'utf8'), readmeKey);
  502. readmeUrl = readmeResult.url;
  503. console.log(` ✅ README.md 已上传: ${readmeUrl}`);
  504. } catch (err) {
  505. console.log(` ❌ README.md 上传失败: ${err.message}`);
  506. }
  507. // 上传每个技能包的 install.json
  508. const manifests = generatePackageManifests(results);
  509. const packageUrls = {};
  510. for (const [pkgId, manifest] of Object.entries(manifests)) {
  511. try {
  512. const key = `${CDN_PREFIX}/packages/${pkgId}.json`;
  513. const buf = Buffer.from(JSON.stringify(manifest, null, 2), 'utf8');
  514. const res = await uploadBuffer(buf, key);
  515. packageUrls[pkgId] = res.url;
  516. const pkg = SKILL_PACKAGES[pkgId];
  517. const statusTag = pkg.status === 'testing' ? ' 🧪' : ' ✅';
  518. console.log(` ${statusTag} ${pkg.label} (${manifest.skillCount}个技能): ${res.url}`);
  519. } catch (err) {
  520. console.log(` ❌ ${pkgId}.json 上传失败: ${err.message}`);
  521. }
  522. }
  523. console.log('');
  524. // ── 输出 URL 汇总 ──
  525. console.log('📋 技能包下载地址:');
  526. console.log('');
  527. for (const [pkgId, url] of Object.entries(packageUrls)) {
  528. const pkg = SKILL_PACKAGES[pkgId];
  529. const statusTag = pkg.status === 'testing' ? '🧪' : '✅';
  530. console.log(` ${statusTag} ${pkg.label}`);
  531. console.log(` 👉 ${url}`);
  532. }
  533. console.log('');
  534. // 保存 URL 列表到文件(按技能包分组)
  535. const urlMap = { _packages: packageUrls };
  536. if (readmeUrl) urlMap['_README'] = readmeUrl;
  537. for (const entry of results) {
  538. if (Object.keys(entry.cdnUrls).length > 0) {
  539. urlMap[entry.name] = entry.cdnUrls;
  540. }
  541. }
  542. const outputPath = path.resolve(__dirname, '..', 'skills-cdn-urls.json');
  543. fs.writeFileSync(outputPath, JSON.stringify(urlMap, null, 2), 'utf8');
  544. console.log(`💾 URL 列表已保存到: ${outputPath}`);
  545. console.log('');
  546. console.log('==================================================');
  547. console.log('📌 技能市场索引:');
  548. console.log(` README: ${CDN_DOMAIN}/${CDN_PREFIX}/README.md`);
  549. for (const [pkgId, url] of Object.entries(packageUrls)) {
  550. const pkg = SKILL_PACKAGES[pkgId];
  551. console.log(` ${pkg.emoji} ${pkg.label}: ${url}`);
  552. }
  553. console.log('==================================================');
  554. }
  555. main().catch(err => {
  556. console.error('❌ 上传出错:', err);
  557. process.exit(1);
  558. });