openyida 2026.9.11 → 2026.9.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +7 -5
  2. package/bin/yida.js +1 -0
  3. package/lib/ai/ai.js +2 -0
  4. package/lib/app/canvas-compile.js +29 -26
  5. package/lib/app/compile.js +39 -1
  6. package/lib/app/services/canvas-page-compiler.js +3 -3
  7. package/lib/app/update-app.js +17 -2
  8. package/lib/asset/ai-image.js +37 -65
  9. package/lib/asset/asset-cmd.js +151 -110
  10. package/lib/asset/asset-plan.js +105 -0
  11. package/lib/asset/asset-resolve.js +343 -225
  12. package/lib/asset/asset-status.js +20 -31
  13. package/lib/asset/attachment-upload.js +29 -0
  14. package/lib/asset/host-capabilities.js +91 -0
  15. package/lib/asset/image-metadata.js +104 -0
  16. package/lib/asset/url-verify.js +90 -130
  17. package/lib/core/agent-capabilities.js +11 -0
  18. package/lib/core/command-manifest.js +25 -11
  19. package/lib/core/locales/en.js +6 -2
  20. package/lib/core/locales/zh.js +6 -2
  21. package/lib/design-plan/materialize.js +7 -9
  22. package/lib/design-plan/normalize.js +11 -1
  23. package/lib/process/services/process-actions.js +145 -0
  24. package/lib/process/services/process-compiler.js +4 -30
  25. package/lib/process/services/process-view-verifier.js +31 -0
  26. package/package.json +1 -1
  27. package/scripts/postinstall.js +1 -1
  28. package/yida-skills/SKILL.md +3 -2
  29. package/yida-skills/skills/yida-app/SKILL.md +1 -1
  30. package/yida-skills/skills/yida-app/workflow/step-2-design.md +11 -1
  31. package/yida-skills/skills/yida-app/workflow/step-3-create-or-reuse-app.md +21 -1
  32. package/yida-skills/skills/yida-app/workflow/step-5-seed-records.md +11 -7
  33. package/yida-skills/skills/yida-app/workflow/step-7-page-code.md +4 -0
  34. package/yida-skills/skills/yida-canvas-custom-page/SKILL.md +5 -3
  35. package/yida-skills/skills/yida-canvas-custom-page/references/component-library-guide.md +1 -1
  36. package/yida-skills/skills/yida-canvas-custom-page/references/page-generation-guide.md +3 -27
  37. package/yida-skills/skills/yida-canvas-table-form/SKILL.md +1 -1
  38. package/yida-skills/skills/yida-create-app/SKILL.md +4 -4
  39. package/yida-skills/skills/yida-create-process/SKILL.md +2 -0
  40. package/yida-skills/skills/yida-custom-page/references/assets-guide.md +9 -12
  41. package/yida-skills/skills/yida-design/SKILL.md +1 -0
  42. package/yida-skills/skills/yida-design/references/ask-human-interaction-contract.md +4 -4
  43. package/yida-skills/skills/yida-design/references/asset-workflow.md +14 -53
  44. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/references/build-plan-compact-schema.md +1 -1
  45. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/references/build-plan-schema.md +21 -4
  46. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/scripts/render_build_plan.py +1 -2
  47. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/scripts/validate_design_themes.py +1 -1
  48. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/README.md +1 -1
  49. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/airy-media-grid.md +1 -1
  50. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/airy-modular-clarity.md +1 -1
  51. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/airy-structured-clarity.md +1 -1
  52. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/ambient-halo-layered.md +1 -1
  53. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/dark-focus-layered.md +1 -1
  54. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/dark-luminous-modular.md +1 -1
  55. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/deep-stage-duotone.md +1 -1
  56. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/hairline-runway-clarity.md +1 -1
  57. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/high-contrast-modular.md +1 -1
  58. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/interlocked-vivid-modules.md +1 -1
  59. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/matrix-grid-focus.md +1 -1
  60. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/media-rail-inspector.md +1 -1
  61. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/mist-layered-signal.md +1 -1
  62. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/modular-rail-signal.md +1 -1
  63. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/mono-grid-signal.md +2 -2
  64. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/ribbon-ledger-lift.md +1 -1
  65. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/segmented-meter-clarity.md +1 -1
  66. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/split-rail-precision.md +1 -1
  67. package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/status-framed-media-grid.md +1 -1
  68. package/yida-skills/skills/yida-design/workflow/output-design.md +4 -0
  69. package/yida-skills/skills/yida-design/workflow/step-4-wireframe-interaction.md +1 -1
  70. package/yida-skills/skills/yida-design/workflow/step-5-visual-states.md +2 -1
  71. package/yida-skills/skills/yida-image-assets/SKILL.md +91 -0
  72. package/yida-skills/skills/yida-image-assets/references/manifest-contract.md +87 -0
  73. package/yida-skills/skills/yida-image-assets/references/source-policy.md +19 -0
  74. package/yida-skills/skills/yida-nav-shell/references/nav-shell-patterns.md +1 -1
  75. package/yida-skills/skills/yida-prd/workflow/output-prd.md +2 -2
  76. package/yida-skills/skills/yida-process-rule/SKILL.md +6 -1
  77. package/yida-skills/skills/yida-process-rule/references/approval-actions.md +48 -0
  78. package/yida-skills/skills/yida-publish-page/SKILL.md +5 -4
  79. package/yida-skills/skills/yida-rechart/SKILL.md +1 -1
  80. package/yida-skills/skills/yida-requirement-analysis/SKILL.md +1 -1
  81. package/yida-skills/skills/yida-requirement-analysis/workflow/prepare-brief.md +22 -13
  82. package/yida-skills/skills-index.json +18 -1
@@ -1,30 +1,17 @@
1
1
 
2
- /**
3
- * asset-status.js - 素材能力检测(CDN 配置 + 推荐素材策略)
4
- *
5
- * P3 素材工具化的第一步:在生成官网/落地页前,先诚实地回答
6
- * “当前环境能提供什么样的素材能力”,据此选择素材策略,绝不在
7
- * 无 CDN / 无素材时假装已上传或已交付最终版。
8
- *
9
- * 用法:
10
- * const { getAssetStatus } = require('./asset-status');
11
- * const s = getAssetStatus();
12
- * // { cdnConfigured, canUpload, canGenerate, recommendedStrategy, reasons, cdn }
13
- */
14
-
15
2
  'use strict';
16
3
 
17
4
  const { loadCdnConfig, validateCdnConfig, getCdnConfigPath } = require('../cdn/cdn-config');
18
5
  const { detectImageGenerator } = require('./ai-image');
6
+ const { buildHostAssetCapabilities } = require('./host-capabilities');
7
+ const { detectRuntimeCapabilities } = require('../core/utils');
19
8
 
20
9
  /**
21
- * 素材策略枚举
22
- * - cdn-upload : 有 CDN,可把本地/生成的图片转存为稳定 CDN URL
23
- * - external-url : 无 CDN,外链素材由 asset resolve 统一处理
24
- * - low-fidelity : 既无 CDN 也无可用素材 → 只能交付低保真草稿并标注素材缺口
10
+ * 素材策略枚举。
25
11
  */
26
12
  const STRATEGY = {
27
13
  CDN_UPLOAD: 'cdn-upload',
14
+ YIDA_ATTACHMENT: 'yida-attachment',
28
15
  EXTERNAL_URL: 'external-url',
29
16
  LOW_FIDELITY: 'low-fidelity-draft',
30
17
  };
@@ -36,41 +23,43 @@ const STRATEGY = {
36
23
  * @returns {{
37
24
  * cdnConfigured: boolean,
38
25
  * canUpload: boolean,
39
- * canGenerate: boolean,
26
+ * canGenerate: boolean|null,
40
27
  * recommendedStrategy: string,
41
28
  * reasons: string[],
42
29
  * cdn: { configPath: string, missing: string[], domain: string }
43
30
  * }}
44
31
  */
45
32
  function getAssetStatus(options = {}) {
46
- const online = options.online !== false; // 默认认为可联网处理外链素材
33
+ const online = options.online !== false;
47
34
  const config = loadCdnConfig();
48
35
  const { valid, missing } = validateCdnConfig(config);
49
36
 
50
- const generator = detectImageGenerator();
37
+ const runtime = options.runtime || detectRuntimeCapabilities({ env: options.env });
38
+ const hostCapabilities = buildHostAssetCapabilities({ env: options.env, runtime });
39
+ const generator = detectImageGenerator({ hostCapabilities });
51
40
  const reasons = [];
52
41
 
53
42
  let recommendedStrategy;
54
- if (valid) {
55
- recommendedStrategy = STRATEGY.CDN_UPLOAD;
56
- reasons.push('CDN 已配置:本地或 AI 生成图片可转存为稳定 CDN URL 并回填 spec。');
57
- } else if (online) {
58
- recommendedStrategy = STRATEGY.EXTERNAL_URL;
59
- reasons.push('未配置 CDN:外链素材交给 `openyida asset resolve` 统一处理,或交付草稿。');
60
- reasons.push('缺失 CDN 字段:' + (missing.join(', ') || '(未知)') + ',运行 `openyida cdn-config` 可开启上传能力。');
43
+ if (online) {
44
+ recommendedStrategy = STRATEGY.YIDA_ATTACHMENT;
45
+ reasons.push('图片默认上传宜搭附件,需要登录态和 appType;超过 20 MiB 的外链保留原链接。');
61
46
  } else {
62
47
  recommendedStrategy = STRATEGY.LOW_FIDELITY;
63
- reasons.push('未配置 CDN 且无法处理外链素材:只能交付低保真草稿并标注素材缺口。');
48
+ reasons.push('离线模式不校验外链,也不上传图片。');
64
49
  }
65
50
 
66
- if (!generator.available) {
67
- reasons.push('AI 图片生成不可用:' + generator.reason + '(不会伪造图片 URL)。');
51
+ if (generator.available !== true) {
52
+ reasons.push(generator.reason);
68
53
  }
69
54
 
70
55
  return {
71
56
  cdnConfigured: valid,
72
- canUpload: valid,
57
+ canUpload: online,
58
+ uploadTarget: 'yida-attachment',
59
+ requiresLogin: true,
60
+ requiresAppType: true,
73
61
  canGenerate: generator.available,
62
+ hostCapabilities,
74
63
  recommendedStrategy,
75
64
  reasons,
76
65
  cdn: {
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { CliError } = require('../core/cli-error');
5
+ const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
6
+
7
+ async function uploadAttachment(files, options = {}) {
8
+ // Reuse the ImageField upload/sign/public-URL flow, without invoking recognition.
9
+ const { inferAppType, getAuthRef, uploadImageForAI } = require('../ai/ai');
10
+ const appType = inferAppType(options);
11
+ if (!appType) {throw new CliError('ASSET_APP_TYPE_REQUIRED', { code: 'ASSET_APP_TYPE_REQUIRED' });}
12
+ for (const file of files) {
13
+ if (fs.statSync(file).size > MAX_IMAGE_BYTES) {
14
+ throw new CliError('ASSET_TOO_LARGE', { code: 'ASSET_TOO_LARGE' });
15
+ }
16
+ }
17
+ const authRef = getAuthRef(options);
18
+ const results = [];
19
+ for (const file of files) {
20
+ const upload = await uploadImageForAI(file, { appType }, authRef);
21
+ if (typeof upload.imageUrl !== 'string' || !/^https?:\/\//i.test(upload.imageUrl)) {
22
+ throw new CliError('ASSET_ATTACHMENT_URL_INVALID', { code: 'ASSET_ATTACHMENT_URL_INVALID' });
23
+ }
24
+ results.push({ success: true, originalPath: file, cdnUrl: upload.imageUrl });
25
+ }
26
+ return results;
27
+ }
28
+
29
+ module.exports = { MAX_IMAGE_BYTES, uploadAttachment };
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ const CAPABILITY_ENV = Object.freeze({
4
+ onlineSearch: 'OPENYIDA_AGENT_ONLINE_SEARCH',
5
+ imageSearch: 'OPENYIDA_AGENT_IMAGE_SEARCH',
6
+ imageGeneration: 'OPENYIDA_AGENT_IMAGE_GENERATION',
7
+ });
8
+
9
+ function readDeclaredCapability(value) {
10
+ if (value === undefined || value === null || String(value).trim() === '') {
11
+ return { status: 'unknown', available: null, source: 'host_tool_inventory_required' };
12
+ }
13
+ const normalized = String(value).trim().toLowerCase();
14
+ if (['1', 'true', 'yes', 'available', 'enabled'].includes(normalized)) {
15
+ return { status: 'available', available: true, source: 'environment_declaration' };
16
+ }
17
+ if (['0', 'false', 'no', 'unavailable', 'disabled'].includes(normalized)) {
18
+ return { status: 'unavailable', available: false, source: 'environment_declaration' };
19
+ }
20
+ return { status: 'unknown', available: null, source: 'invalid_environment_declaration' };
21
+ }
22
+
23
+ function hasDeclaredCapability(value) {
24
+ return value !== undefined && value !== null && String(value).trim() !== '';
25
+ }
26
+
27
+ function availableCapability(source) {
28
+ return { status: 'available', available: true, source };
29
+ }
30
+
31
+ function unknownCapability() {
32
+ return { status: 'unknown', available: null, source: 'host_tool_inventory_required' };
33
+ }
34
+
35
+ function detectHostClass(runtime = {}, env = process.env) {
36
+ const managedRuntime = String(env.OPENYIDA_MANAGED_RUNTIME || '').trim().toLowerCase();
37
+ if (managedRuntime === 'cloud' || runtime.runtime === 'web_sandbox' || runtime.tool === 'mulerun') {
38
+ return 'managed_cloud';
39
+ }
40
+ if (
41
+ ['local', 'non_cloud', 'non-cloud'].includes(managedRuntime) ||
42
+ ['desktop_shell', 'agent_cli', 'local'].includes(runtime.runtime)
43
+ ) {
44
+ return 'non_cloud';
45
+ }
46
+ return 'unknown';
47
+ }
48
+
49
+ function resolveCapability(value, runtimeDefault) {
50
+ return hasDeclaredCapability(value) ? readDeclaredCapability(value) : runtimeDefault;
51
+ }
52
+
53
+ function buildHostAssetCapabilities(options = {}) {
54
+ const env = options.env || process.env;
55
+ const runtime = options.runtime || {};
56
+ const hostClass = detectHostClass(runtime, env);
57
+ const searchDefault = () => hostClass === 'non_cloud'
58
+ ? availableCapability('non_cloud_runtime_default')
59
+ : unknownCapability();
60
+ const generationDefault = runtime.tool === 'qwenwork'
61
+ ? availableCapability('qwenwork_runtime_default')
62
+ : unknownCapability();
63
+ const onlineSearch = resolveCapability(env[CAPABILITY_ENV.onlineSearch], searchDefault());
64
+ const imageSearch = resolveCapability(env[CAPABILITY_ENV.imageSearch], searchDefault());
65
+ const imageGeneration = resolveCapability(env[CAPABILITY_ENV.imageGeneration], generationDefault);
66
+ const capabilities = [onlineSearch, imageSearch, imageGeneration];
67
+
68
+ return {
69
+ schema_version: 1,
70
+ host: {
71
+ tool: runtime.tool || null,
72
+ runtime: runtime.runtime || 'unknown',
73
+ subtype: runtime.subtype || null,
74
+ class: hostClass,
75
+ },
76
+ online_search: onlineSearch,
77
+ image_search: imageSearch,
78
+ image_generation: imageGeneration,
79
+ requires_host_tool_inventory_check: capabilities.some(item => item.status === 'unknown'),
80
+ declaration_env: { ...CAPABILITY_ENV },
81
+ detection_policy:
82
+ 'Explicit declarations override runtime defaults. QwenWork provides image generation, and verified non-cloud runtimes provide online and image search. Managed-cloud or unknown runtimes use the host tool inventory.',
83
+ };
84
+ }
85
+
86
+ module.exports = {
87
+ CAPABILITY_ENV,
88
+ readDeclaredCapability,
89
+ detectHostClass,
90
+ buildHostAssetCapabilities,
91
+ };
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+
5
+ const MAX_HEADER_BYTES = 128 * 1024;
6
+
7
+ function jpegDimensions(buffer) {
8
+ if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {return null;}
9
+ const sof = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]);
10
+ let offset = 2;
11
+ while (offset + 8 < buffer.length) {
12
+ if (buffer[offset] !== 0xff) {
13
+ offset += 1;
14
+ continue;
15
+ }
16
+ while (buffer[offset] === 0xff) {offset += 1;}
17
+ const marker = buffer[offset++];
18
+ if (marker === 0xd8 || marker === 0xd9) {continue;}
19
+ if (marker === 0xda || offset + 2 > buffer.length) {break;}
20
+ const length = buffer.readUInt16BE(offset);
21
+ if (length < 2 || offset + length > buffer.length) {break;}
22
+ if (sof.has(marker) && length >= 7) {
23
+ return { type: 'jpeg', width: buffer.readUInt16BE(offset + 5), height: buffer.readUInt16BE(offset + 3) };
24
+ }
25
+ offset += length;
26
+ }
27
+ return { type: 'jpeg', width: 0, height: 0 };
28
+ }
29
+
30
+ function webpDimensions(buffer) {
31
+ if (buffer.length < 30 || buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WEBP') {
32
+ return null;
33
+ }
34
+ const chunk = buffer.toString('ascii', 12, 16);
35
+ if (chunk === 'VP8X') {
36
+ return {
37
+ type: 'webp',
38
+ width: 1 + buffer.readUIntLE(24, 3),
39
+ height: 1 + buffer.readUIntLE(27, 3),
40
+ };
41
+ }
42
+ if (chunk === 'VP8L' && buffer[20] === 0x2f) {
43
+ return {
44
+ type: 'webp',
45
+ width: 1 + buffer[21] + ((buffer[22] & 0x3f) << 8),
46
+ height: 1 + ((buffer[22] & 0xc0) >> 6) + (buffer[23] << 2) + ((buffer[24] & 0x0f) << 10),
47
+ };
48
+ }
49
+ if (chunk === 'VP8 ' && buffer[23] === 0x9d && buffer[24] === 0x01 && buffer[25] === 0x2a) {
50
+ return {
51
+ type: 'webp',
52
+ width: buffer.readUInt16LE(26) & 0x3fff,
53
+ height: buffer.readUInt16LE(28) & 0x3fff,
54
+ };
55
+ }
56
+ return { type: 'webp', width: 0, height: 0 };
57
+ }
58
+
59
+ function svgDimensions(buffer) {
60
+ const text = buffer.toString('utf8', 0, Math.min(buffer.length, 8192));
61
+ const root = text.replace(/^\uFEFF/, '').replace(/^\s*(?:<\?xml[\s\S]*?\?>\s*)?(?:<!--[\s\S]*?-->\s*)*/, '')
62
+ .match(/^<svg\b([^>]*)>/i);
63
+ if (!root) {return null;}
64
+ const attributes = root[1];
65
+ const width = Number((attributes.match(/\swidth=["']([\d.]+)(?:px)?["']/i) || [])[1] || 0);
66
+ const height = Number((attributes.match(/\sheight=["']([\d.]+)(?:px)?["']/i) || [])[1] || 0);
67
+ if (width > 0 && height > 0) {return { type: 'svg', width, height };}
68
+ const viewBox = (attributes.match(/\sviewBox=["']([^"']+)["']/i) || [])[1] || '';
69
+ const values = viewBox.trim().split(/[\s,]+/).map(Number);
70
+ return { type: 'svg', width: Number(values[2] || 0), height: Number(values[3] || 0) };
71
+ }
72
+
73
+ /** 读取图片头中的格式和尺寸。 */
74
+ function parseImageMetadata(buffer) {
75
+ if (!Buffer.isBuffer(buffer) || buffer.length < 10) {return { type: '', width: 0, height: 0 };}
76
+ if (buffer.length >= 24 && buffer.toString('hex', 0, 8) === '89504e470d0a1a0a') {
77
+ return { type: 'png', width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
78
+ }
79
+ if (/^GIF8[79]a$/.test(buffer.toString('ascii', 0, 6))) {
80
+ return { type: 'gif', width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
81
+ }
82
+ if (buffer.toString('ascii', 0, 2) === 'BM' && buffer.length >= 26) {
83
+ return { type: 'bmp', width: Math.abs(buffer.readInt32LE(18)), height: Math.abs(buffer.readInt32LE(22)) };
84
+ }
85
+ return jpegDimensions(buffer) || webpDimensions(buffer) || svgDimensions(buffer) || { type: '', width: 0, height: 0 };
86
+ }
87
+
88
+ /** 读取本地图片的格式和尺寸,不加载完整文件。 */
89
+ function readImageMetadata(filePath) {
90
+ const fd = fs.openSync(filePath, 'r');
91
+ try {
92
+ const buffer = Buffer.alloc(MAX_HEADER_BYTES);
93
+ const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
94
+ return parseImageMetadata(buffer.subarray(0, bytesRead));
95
+ } finally {
96
+ fs.closeSync(fd);
97
+ }
98
+ }
99
+
100
+ module.exports = {
101
+ MAX_HEADER_BYTES,
102
+ parseImageMetadata,
103
+ readImageMetadata,
104
+ };
@@ -1,122 +1,93 @@
1
-
2
- /**
3
- * url-verify.js - 轻量公开图库 / 图片 URL 校验
4
- *
5
- * P3 素材工具化:官网/落地页在使用外部公开图片前,必须先校验该 URL 是否
6
- * 真实可达、且确为图片资源,避免把编造或失效的 URL 直接写进页面。
7
- *
8
- * 仅使用 Node 原生 https/http,无第三方依赖。
9
- *
10
- * 用法:
11
- * const { verifyImageUrl, verifyImageUrls } = require('./url-verify');
12
- * const r = await verifyImageUrl('https://example.com/a.png');
13
- * // { url, ok, status, contentType, bytes, reason }
14
- */
15
-
16
1
  'use strict';
17
2
 
18
3
  const https = require('https');
19
4
  const http = require('http');
20
5
  const { URL } = require('url');
6
+ const { MAX_HEADER_BYTES, parseImageMetadata } = require('./image-metadata');
21
7
 
22
8
  const IMAGE_CONTENT_TYPES = [
23
9
  'image/jpeg', 'image/jpg', 'image/png', 'image/gif',
24
10
  'image/webp', 'image/bmp', 'image/svg+xml', 'image/avif',
25
11
  ];
26
- const IMAGE_EXT = /\.(jpe?g|png|gif|webp|bmp|svg|avif)(\?|#|$)/i;
27
-
28
12
  const DEFAULT_TIMEOUT = 8000;
29
13
  const MAX_REDIRECTS = 4;
30
14
 
31
- /**
32
- * 判断 content-type 是否为图片
33
- * @param {string} contentType
34
- * @returns {boolean}
35
- */
36
15
  function isImageContentType(contentType) {
37
16
  if (!contentType) {return false;}
38
17
  const main = String(contentType).split(';')[0].trim().toLowerCase();
39
18
  return IMAGE_CONTENT_TYPES.includes(main);
40
19
  }
41
20
 
42
- /**
43
- * 发起一次请求(HEAD 或 GET),返回 { status, headers, location }
44
- * GET 时在拿到响应头后立即 destroy,避免下载完整图片。
45
- * @param {string} rawUrl
46
- * @param {object} opts
47
- * @returns {Promise<{status:number, headers:object}>}
48
- */
49
- function requestOnce(rawUrl, opts) {
50
- const method = (opts && opts.method) || 'HEAD';
51
- const timeout = (opts && opts.timeout) || DEFAULT_TIMEOUT;
52
-
21
+ function requestOnce(rawUrl, options = {}) {
22
+ const method = options.method || 'GET';
23
+ const timeout = options.timeout || DEFAULT_TIMEOUT;
53
24
  return new Promise((resolve, reject) => {
54
25
  let parsed;
55
26
  try {
56
27
  parsed = new URL(rawUrl);
57
- } catch (err) {
28
+ } catch {
58
29
  reject(new Error('INVALID_URL'));
59
30
  return;
60
31
  }
61
-
62
- if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
32
+ if (!['https:', 'http:'].includes(parsed.protocol)) {
63
33
  reject(new Error('UNSUPPORTED_PROTOCOL'));
64
34
  return;
65
35
  }
66
36
 
67
37
  const client = parsed.protocol === 'https:' ? https : http;
68
- const req = client.request(
69
- parsed,
70
- {
71
- method,
72
- // 部分 CDN 对无 UA 的请求返回 403,带一个常规 UA 更接近真实浏览器访问
73
- headers: {
74
- 'User-Agent': 'Mozilla/5.0 (compatible; OpenYidaAssetVerifier/1.0)',
75
- Accept: 'image/*,*/*;q=0.8',
76
- },
38
+ const req = client.request(parsed, {
39
+ method,
40
+ headers: {
41
+ 'User-Agent': 'Mozilla/5.0 (compatible; OpenYidaAssetVerifier/1.0)',
42
+ Accept: 'image/*,*/*;q=0.8',
43
+ ...(method === 'GET' ? { Range: `bytes=0-${MAX_HEADER_BYTES - 1}` } : {}),
77
44
  },
78
- (res) => {
79
- // GET 请求:拿到响应头即可,无需读 body
80
- if (method === 'GET') {
45
+ }, (res) => {
46
+ if (method !== 'GET') {
47
+ resolve({ status: res.statusCode || 0, headers: res.headers || {}, body: Buffer.alloc(0) });
48
+ return;
49
+ }
50
+ const chunks = [];
51
+ let bytes = 0;
52
+ let settled = false;
53
+ const finish = () => {
54
+ if (settled) {return;}
55
+ settled = true;
56
+ resolve({
57
+ status: res.statusCode || 0,
58
+ headers: res.headers || {},
59
+ body: Buffer.concat(chunks, bytes),
60
+ });
61
+ };
62
+ res.on('data', (chunk) => {
63
+ const remaining = MAX_HEADER_BYTES - bytes;
64
+ if (remaining <= 0) {return;}
65
+ const part = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
66
+ chunks.push(part);
67
+ bytes += part.length;
68
+ if (bytes >= MAX_HEADER_BYTES) {
69
+ finish();
81
70
  res.destroy();
82
71
  }
83
- resolve({ status: res.statusCode || 0, headers: res.headers || {} });
84
- }
85
- );
86
-
87
- req.setTimeout(timeout, () => {
88
- req.destroy(new Error('TIMEOUT'));
72
+ });
73
+ res.on('end', finish);
74
+ res.on('error', reject);
89
75
  });
90
- req.on('error', (err) => reject(err));
76
+ req.setTimeout(timeout, () => req.destroy(new Error('TIMEOUT')));
77
+ req.on('error', reject);
91
78
  req.end();
92
79
  });
93
80
  }
94
81
 
95
- /**
96
- * 跟随重定向发起请求
97
- * @param {string} rawUrl
98
- * @param {object} opts
99
- * @param {number} depth
100
- * @returns {Promise<{status:number, headers:object, finalUrl:string}>}
101
- */
102
- async function requestFollow(rawUrl, opts, depth = 0) {
103
- const res = await requestOnce(rawUrl, opts);
104
- const status = res.status;
105
- if (status >= 300 && status < 400 && res.headers.location && depth < MAX_REDIRECTS) {
106
- const next = new URL(res.headers.location, rawUrl).toString();
107
- return requestFollow(next, opts, depth + 1);
82
+ async function requestFollow(rawUrl, options, depth = 0) {
83
+ const response = await requestOnce(rawUrl, options);
84
+ if (response.status >= 300 && response.status < 400 && response.headers.location && depth < MAX_REDIRECTS) {
85
+ const next = new URL(response.headers.location, rawUrl).toString();
86
+ return requestFollow(next, options, depth + 1);
108
87
  }
109
- return { ...res, finalUrl: rawUrl };
88
+ return { ...response, finalUrl: rawUrl };
110
89
  }
111
90
 
112
- /**
113
- * 校验单个图片 URL
114
- * @param {string} rawUrl
115
- * @param {object} [options]
116
- * @param {number} [options.timeout]
117
- * @param {number} [options.minBytes] 最小字节数(默认 0,不限制)
118
- * @returns {Promise<{url:string, ok:boolean, status:number, contentType:string, bytes:number, reason:string, finalUrl:string}>}
119
- */
120
91
  async function verifyImageUrl(rawUrl, options = {}) {
121
92
  const base = {
122
93
  url: rawUrl,
@@ -124,89 +95,78 @@ async function verifyImageUrl(rawUrl, options = {}) {
124
95
  status: 0,
125
96
  contentType: '',
126
97
  bytes: 0,
98
+ width: 0,
99
+ height: 0,
100
+ imageType: '',
127
101
  reason: '',
128
102
  finalUrl: rawUrl,
129
103
  };
130
-
131
- if (!rawUrl || typeof rawUrl !== 'string') {
132
- return { ...base, reason: 'EMPTY_URL' };
133
- }
104
+ if (!rawUrl || typeof rawUrl !== 'string') {return { ...base, reason: 'EMPTY_URL' };}
134
105
  const trimmed = rawUrl.trim();
135
- if (!/^https?:\/\//i.test(trimmed)) {
136
- return { ...base, url: trimmed, reason: 'NOT_HTTP_URL' };
137
- }
138
-
139
- const minBytes = options.minBytes || 0;
106
+ if (!/^https?:\/\//i.test(trimmed)) {return { ...base, url: trimmed, reason: 'NOT_HTTP_URL' };}
107
+ if (options.online === false) {return { ...base, url: trimmed, reason: 'OFFLINE_EXTERNAL_URL' };}
140
108
 
141
- let res;
109
+ let response;
142
110
  try {
143
- res = await requestFollow(trimmed, { method: 'HEAD', timeout: options.timeout });
144
- // 某些服务器不支持 HEAD(405/501)或返回异常 → 回退 GET
145
- if (res.status === 405 || res.status === 501 || res.status === 0) {
146
- res = await requestFollow(trimmed, { method: 'GET', timeout: options.timeout });
147
- }
148
- } catch (err) {
149
- // HEAD 报错时再尝试一次 GET,避免部分 CDN 对 HEAD 直接断连
150
- try {
151
- res = await requestFollow(trimmed, { method: 'GET', timeout: options.timeout });
152
- } catch (err2) {
153
- return { ...base, url: trimmed, reason: normalizeError(err2) };
154
- }
111
+ response = await requestFollow(trimmed, { method: 'GET', timeout: options.timeout });
112
+ } catch (error) {
113
+ return { ...base, url: trimmed, reason: normalizeError(error) };
155
114
  }
156
115
 
157
- const contentType = String(res.headers['content-type'] || '').split(';')[0].trim();
158
- const bytes = Number(res.headers['content-length'] || 0) || 0;
116
+ const contentType = String(response.headers['content-type'] || '').split(';')[0].trim();
117
+ const contentRange = String(response.headers['content-range'] || '');
118
+ const bytes = Number(contentRange.split('/')[1] || response.headers['content-length'] || 0) || 0;
119
+ const metadata = parseImageMetadata(response.body);
159
120
  const out = {
121
+ ...base,
160
122
  url: trimmed,
161
- ok: false,
162
- status: res.status,
123
+ status: response.status,
163
124
  contentType,
164
125
  bytes,
165
- reason: '',
166
- finalUrl: res.finalUrl || trimmed,
126
+ width: metadata.width,
127
+ height: metadata.height,
128
+ imageType: metadata.type,
129
+ finalUrl: response.finalUrl || trimmed,
167
130
  };
168
-
169
- if (res.status < 200 || res.status >= 400) {
170
- out.reason = 'HTTP_' + res.status;
131
+ if (response.status < 200 || response.status >= 300) {
132
+ out.reason = `HTTP_${response.status}`;
171
133
  return out;
172
134
  }
173
135
 
174
- // content-type 优先;缺失时用扩展名兜底判断
175
- const typeOk = isImageContentType(contentType);
176
- const extOk = IMAGE_EXT.test(trimmed);
177
- if (!typeOk && !(contentType === '' && extOk)) {
178
- out.reason = contentType ? 'NOT_IMAGE_CONTENT_TYPE' : 'UNKNOWN_CONTENT_TYPE';
136
+ if (!metadata.type) {
137
+ out.reason = 'INVALID_IMAGE_CONTENT';
179
138
  return out;
180
139
  }
181
-
182
- if (minBytes > 0 && bytes > 0 && bytes < minBytes) {
140
+ if (options.minBytes > 0 && bytes > 0 && bytes < options.minBytes) {
183
141
  out.reason = 'TOO_SMALL';
184
142
  return out;
185
143
  }
186
-
144
+ if (![metadata.width, metadata.height].every(value => Number.isFinite(value) && value > 0)) {
145
+ out.reason = 'DIMENSIONS_UNAVAILABLE';
146
+ return out;
147
+ }
148
+ if (options.minWidth > 0 && metadata.width < options.minWidth) {
149
+ out.reason = 'WIDTH_TOO_SMALL';
150
+ return out;
151
+ }
152
+ if (options.minHeight > 0 && metadata.height < options.minHeight) {
153
+ out.reason = 'HEIGHT_TOO_SMALL';
154
+ return out;
155
+ }
187
156
  out.ok = true;
188
157
  out.reason = 'OK';
189
158
  return out;
190
159
  }
191
160
 
192
- /**
193
- * 批量校验(并发)
194
- * @param {string[]} urls
195
- * @param {object} [options]
196
- * @returns {Promise<Array>}
197
- */
198
161
  async function verifyImageUrls(urls, options = {}) {
199
162
  const list = Array.isArray(urls) ? urls : [urls];
200
- return Promise.all(list.map((u) => verifyImageUrl(u, options)));
163
+ return Promise.all(list.map(url => verifyImageUrl(url, options)));
201
164
  }
202
165
 
203
- function normalizeError(err) {
204
- const msg = (err && err.message) || String(err);
205
- if (msg === 'TIMEOUT') {return 'TIMEOUT';}
206
- if (msg === 'INVALID_URL') {return 'INVALID_URL';}
207
- if (msg === 'UNSUPPORTED_PROTOCOL') {return 'UNSUPPORTED_PROTOCOL';}
208
- if (err && err.code) {return err.code;} // ENOTFOUND / ECONNREFUSED / CERT_...
209
- return 'REQUEST_FAILED';
166
+ function normalizeError(error) {
167
+ const message = (error && error.message) || String(error);
168
+ if (['TIMEOUT', 'INVALID_URL', 'UNSUPPORTED_PROTOCOL'].includes(message)) {return message;}
169
+ return (error && error.code) || 'REQUEST_FAILED';
210
170
  }
211
171
 
212
172
  module.exports = {