|
|
@@ -0,0 +1,184 @@
|
|
|
+'use strict';
|
|
|
+
|
|
|
+const fs = require('fs');
|
|
|
+const path = require('path');
|
|
|
+const { cleanLabel } = require('../../core/usage-context');
|
|
|
+
|
|
|
+function readJson(filePath) {
|
|
|
+ return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
|
|
|
+}
|
|
|
+
|
|
|
+function resolveConfigPath(input = {}) {
|
|
|
+ const value = input.skillConfigPath || input.legacySkillPath || input.skillPath || input.apiConfigPath;
|
|
|
+ if (!value) return null;
|
|
|
+ const candidate = path.resolve(String(value));
|
|
|
+ if (!fs.existsSync(candidate)) throw new Error(`历史 Skill 配置不存在:${candidate}`);
|
|
|
+ const stat = fs.statSync(candidate);
|
|
|
+ if (stat.isDirectory()) {
|
|
|
+ const nested = path.join(candidate, 'api-config.json');
|
|
|
+ if (!fs.existsSync(nested)) throw new Error(`历史 Skill 目录缺少 api-config.json:${candidate}`);
|
|
|
+ return nested;
|
|
|
+ }
|
|
|
+ return candidate;
|
|
|
+}
|
|
|
+
|
|
|
+function loadSkillConfig(input = {}) {
|
|
|
+ if (input.skillConfig && typeof input.skillConfig === 'object') {
|
|
|
+ return { config: input.skillConfig, path: null };
|
|
|
+ }
|
|
|
+ const configPath = resolveConfigPath(input);
|
|
|
+ if (!configPath) return null;
|
|
|
+ return { config: readJson(configPath), path: configPath };
|
|
|
+}
|
|
|
+
|
|
|
+function defaultsFromConfig(config = {}) {
|
|
|
+ const defaults = {};
|
|
|
+ for (const [name, definition] of Object.entries(config.parameters || {})) {
|
|
|
+ if (definition && definition.default !== undefined) defaults[name] = definition.default;
|
|
|
+ }
|
|
|
+ return defaults;
|
|
|
+}
|
|
|
+
|
|
|
+function valueFromTemplate(value, input, fallback = 0) {
|
|
|
+ if (typeof value === 'number') return Number.isFinite(value) ? value : fallback;
|
|
|
+ const text = String(value == null ? '' : value).trim();
|
|
|
+ const match = text.match(/^\$\{([^}]+)\}$/);
|
|
|
+ const key = match ? match[1].split(/[.[\]]/)[0] : text;
|
|
|
+ const raw = key && input[key] !== undefined ? input[key] : undefined;
|
|
|
+ const number = Number(raw);
|
|
|
+ return Number.isFinite(number) ? number : fallback;
|
|
|
+}
|
|
|
+
|
|
|
+function arrayLength(value) {
|
|
|
+ return Array.isArray(value) ? value.length : 0;
|
|
|
+}
|
|
|
+
|
|
|
+function keywordCount(input) {
|
|
|
+ return arrayLength(input.keywords) || arrayLength(input.keywordMatrix) || Number(input.keywordCount) || 1;
|
|
|
+}
|
|
|
+
|
|
|
+function accountCount(input) {
|
|
|
+ return arrayLength(input.accounts) || Number(input.accountCount) || Number(input.asinCount) || 1;
|
|
|
+}
|
|
|
+
|
|
|
+function selectedItemCount(input) {
|
|
|
+ const keywords = keywordCount(input);
|
|
|
+ const perKeyword = Number(input.notesPerKeyword ?? input.itemsPerKeyword ?? input.videosPerKeyword ?? 1);
|
|
|
+ return Math.max(1, keywords * (Number.isFinite(perKeyword) && perKeyword > 0 ? perKeyword : 1));
|
|
|
+}
|
|
|
+
|
|
|
+function countForEach(forEach, input) {
|
|
|
+ const text = String(forEach || '').toLowerCase();
|
|
|
+ if (!text) return 1;
|
|
|
+ if (text.includes('asin') && (text.includes('page') || text.includes('×') || text.includes('x'))) {
|
|
|
+ const pages = Number(input.maxPagesPerAsin ?? input.pagesPerAsin ?? 1);
|
|
|
+ return accountCount(input) * Math.max(1, Number.isFinite(pages) ? pages : 1);
|
|
|
+ }
|
|
|
+ if (text.includes('asin')) return accountCount(input);
|
|
|
+ if (text.includes('keyword') || text.includes('关键词')) return keywordCount(input);
|
|
|
+ if (text.includes('selectednote') || text.includes('selectednotes') || text.includes('入选笔记')) return selectedItemCount(input);
|
|
|
+ if (text.includes('selectedvideo') || text.includes('selectedvideos') || text.includes('入选视频')) return selectedItemCount(input);
|
|
|
+ if (text.includes('note') || text.includes('笔记')) return selectedItemCount(input);
|
|
|
+ if (text.includes('video') || text.includes('视频')) return selectedItemCount(input);
|
|
|
+ if (text.includes('product') || text.includes('商品') || text.includes('sku')) {
|
|
|
+ return Math.max(1, Number(input.productCount ?? input.skuCount ?? input.itemCount ?? 1));
|
|
|
+ }
|
|
|
+ if (text.includes('user') || text.includes('作者') || text.includes('账号')) return accountCount(input);
|
|
|
+ return 1;
|
|
|
+}
|
|
|
+
|
|
|
+function stepCount(step, input) {
|
|
|
+ let count = countForEach(step.forEach, input);
|
|
|
+ const pagination = step.pagination;
|
|
|
+ if (pagination && !String(step.forEach || '').toLowerCase().includes('page')) {
|
|
|
+ const maxPages = valueFromTemplate(pagination.maxPages, input, 1);
|
|
|
+ if (maxPages > 1) count *= maxPages;
|
|
|
+ }
|
|
|
+ return Math.max(1, Math.floor(count));
|
|
|
+}
|
|
|
+
|
|
|
+function modelForConfig(config, api = {}) {
|
|
|
+ const explicit = api.model || api.channel;
|
|
|
+ if (explicit) {
|
|
|
+ const normalized = String(explicit).toLowerCase();
|
|
|
+ if (normalized === 'ecom' || normalized === 'ecommerce') return 'voc-ecom';
|
|
|
+ if (normalized === 'overseas' || normalized === 'overseas-ecom') return 'voc-overseas';
|
|
|
+ if (normalized === 'social') return 'voc-social';
|
|
|
+ return cleanLabel(explicit);
|
|
|
+ }
|
|
|
+ const source = `${config.category || ''} ${api.forwardUrl || ''} ${api.service || ''} ${api.skill || ''}`.toLowerCase();
|
|
|
+ if (source.includes('overseas') || source.includes('amazon') || source.includes('跨境')) return 'voc-overseas';
|
|
|
+ if (source.includes('ecom') || source.includes('taobao') || source.includes('amazon') || source.includes('电商')) return 'voc-ecom';
|
|
|
+ return 'voc-social';
|
|
|
+}
|
|
|
+
|
|
|
+function pathForApi(api = {}, step = {}) {
|
|
|
+ const value = api.proxyPath || api.path || api.endpoint || api.forwardPath || api.service || api.skill || step.name || 'legacy_skill_step';
|
|
|
+ return cleanLabel(value, 'legacy_skill_step');
|
|
|
+}
|
|
|
+
|
|
|
+function operationFromStep(config, step, input, index) {
|
|
|
+ if (!step || step.type === 'compute' || !step.api) return null;
|
|
|
+ const api = Array.isArray(step.api) ? step.api[0] : step.api;
|
|
|
+ const condition = String(step.condition || '').trim();
|
|
|
+ return {
|
|
|
+ model: modelForConfig(config, api),
|
|
|
+ apigId: cleanLabel(api.apigId || api.apig_id),
|
|
|
+ path: pathForApi(api, step),
|
|
|
+ count: stepCount(step, input),
|
|
|
+ label: cleanLabel(step.name, `历史 Skill 步骤 ${index + 1}`),
|
|
|
+ valueScore: condition ? 2 : 4,
|
|
|
+ required: !condition,
|
|
|
+ sourceSkill: cleanLabel(api.skill),
|
|
|
+ method: cleanLabel(api.method || 'GET').toUpperCase(),
|
|
|
+ ...(condition ? { condition } : {})
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function buildLegacySkillPlans(input = {}) {
|
|
|
+ const loaded = loadSkillConfig(input);
|
|
|
+ if (!loaded) return [];
|
|
|
+ const config = loaded.config || {};
|
|
|
+ const values = { ...defaultsFromConfig(config), ...input };
|
|
|
+ const steps = Array.isArray(config.pipeline) ? config.pipeline : [];
|
|
|
+ const operations = steps.map((step, index) => operationFromStep(config, step, values, index)).filter(Boolean);
|
|
|
+ if (!operations.length) return [];
|
|
|
+ const name = cleanLabel(config.name || (loaded.path && path.basename(path.dirname(loaded.path))) || 'legacy_skill');
|
|
|
+ const workflowType = `legacy_skill:${name}`;
|
|
|
+ const build = (mode) => {
|
|
|
+ const selected = mode === 'economy' ? operations.filter(operation => operation.required) : operations;
|
|
|
+ return {
|
|
|
+ id: `${name}:${mode}`,
|
|
|
+ mode,
|
|
|
+ label: ({ economy: '经济版', standard: '标准版', complete: '完整版' })[mode],
|
|
|
+ workflowType,
|
|
|
+ assumptions: {
|
|
|
+ skillName: name,
|
|
|
+ skillConfigPath: loaded.path,
|
|
|
+ parameterDefaults: defaultsFromConfig(config),
|
|
|
+ modePolicy: mode === 'economy' ? '仅保留 pipeline 中无 condition 的必需接口步骤。' : '沿用历史 Skill pipeline 声明的接口步骤和分页上限。'
|
|
|
+ },
|
|
|
+ operations: selected.map(operation => ({ ...operation })),
|
|
|
+ strategy: mode === 'economy'
|
|
|
+ ? '沿用历史 Skill 的主链路,跳过可选画像、补充评论等步骤。'
|
|
|
+ : mode === 'standard'
|
|
|
+ ? '按历史 Skill 默认参数执行全部声明步骤。'
|
|
|
+ : '按历史 Skill 声明的分页和批量范围完整覆盖,不虚构额外调用。'
|
|
|
+ };
|
|
|
+ };
|
|
|
+ const modes = input.mode && input.mode !== 'all'
|
|
|
+ ? [String(input.mode).toLowerCase()]
|
|
|
+ : ['economy', 'standard', 'complete'];
|
|
|
+ const normalized = modes.map(mode => mode === 'low' ? 'economy' : mode === 'full' ? 'complete' : mode)
|
|
|
+ .filter(mode => ['economy', 'standard', 'complete'].includes(mode));
|
|
|
+ return (normalized.length ? normalized : ['standard']).map(build);
|
|
|
+}
|
|
|
+
|
|
|
+module.exports = {
|
|
|
+ resolveConfigPath,
|
|
|
+ loadSkillConfig,
|
|
|
+ defaultsFromConfig,
|
|
|
+ countForEach,
|
|
|
+ stepCount,
|
|
|
+ buildLegacySkillPlans
|
|
|
+};
|