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.
- package/README.md +7 -5
- package/bin/yida.js +1 -0
- package/lib/ai/ai.js +2 -0
- package/lib/app/canvas-compile.js +29 -26
- package/lib/app/compile.js +39 -1
- package/lib/app/services/canvas-page-compiler.js +3 -3
- package/lib/app/update-app.js +17 -2
- package/lib/asset/ai-image.js +37 -65
- package/lib/asset/asset-cmd.js +151 -110
- package/lib/asset/asset-plan.js +105 -0
- package/lib/asset/asset-resolve.js +343 -225
- package/lib/asset/asset-status.js +20 -31
- package/lib/asset/attachment-upload.js +29 -0
- package/lib/asset/host-capabilities.js +91 -0
- package/lib/asset/image-metadata.js +104 -0
- package/lib/asset/url-verify.js +90 -130
- package/lib/core/agent-capabilities.js +11 -0
- package/lib/core/command-manifest.js +25 -11
- package/lib/core/locales/en.js +6 -2
- package/lib/core/locales/zh.js +6 -2
- package/lib/design-plan/materialize.js +7 -9
- package/lib/design-plan/normalize.js +11 -1
- package/lib/process/services/process-actions.js +145 -0
- package/lib/process/services/process-compiler.js +4 -30
- package/lib/process/services/process-view-verifier.js +31 -0
- package/package.json +1 -1
- package/scripts/postinstall.js +1 -1
- package/yida-skills/SKILL.md +3 -2
- package/yida-skills/skills/yida-app/SKILL.md +1 -1
- package/yida-skills/skills/yida-app/workflow/step-2-design.md +11 -1
- package/yida-skills/skills/yida-app/workflow/step-3-create-or-reuse-app.md +21 -1
- package/yida-skills/skills/yida-app/workflow/step-5-seed-records.md +11 -7
- package/yida-skills/skills/yida-app/workflow/step-7-page-code.md +4 -0
- package/yida-skills/skills/yida-canvas-custom-page/SKILL.md +5 -3
- package/yida-skills/skills/yida-canvas-custom-page/references/component-library-guide.md +1 -1
- package/yida-skills/skills/yida-canvas-custom-page/references/page-generation-guide.md +3 -27
- package/yida-skills/skills/yida-canvas-table-form/SKILL.md +1 -1
- package/yida-skills/skills/yida-create-app/SKILL.md +4 -4
- package/yida-skills/skills/yida-create-process/SKILL.md +2 -0
- package/yida-skills/skills/yida-custom-page/references/assets-guide.md +9 -12
- package/yida-skills/skills/yida-design/SKILL.md +1 -0
- package/yida-skills/skills/yida-design/references/ask-human-interaction-contract.md +4 -4
- package/yida-skills/skills/yida-design/references/asset-workflow.md +14 -53
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/references/build-plan-compact-schema.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/references/build-plan-schema.md +21 -4
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/scripts/render_build_plan.py +1 -2
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/scripts/validate_design_themes.py +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/README.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/airy-media-grid.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/airy-modular-clarity.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/airy-structured-clarity.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/ambient-halo-layered.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/dark-focus-layered.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/dark-luminous-modular.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/deep-stage-duotone.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/hairline-runway-clarity.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/high-contrast-modular.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/interlocked-vivid-modules.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/matrix-grid-focus.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/media-rail-inspector.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/mist-layered-signal.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/modular-rail-signal.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/mono-grid-signal.md +2 -2
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/ribbon-ledger-lift.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/segmented-meter-clarity.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/split-rail-precision.md +1 -1
- package/yida-skills/skills/yida-design/sub_skill/yida-design-plan/templates/design-themes/status-framed-media-grid.md +1 -1
- package/yida-skills/skills/yida-design/workflow/output-design.md +4 -0
- package/yida-skills/skills/yida-design/workflow/step-4-wireframe-interaction.md +1 -1
- package/yida-skills/skills/yida-design/workflow/step-5-visual-states.md +2 -1
- package/yida-skills/skills/yida-image-assets/SKILL.md +91 -0
- package/yida-skills/skills/yida-image-assets/references/manifest-contract.md +87 -0
- package/yida-skills/skills/yida-image-assets/references/source-policy.md +19 -0
- package/yida-skills/skills/yida-nav-shell/references/nav-shell-patterns.md +1 -1
- package/yida-skills/skills/yida-prd/workflow/output-prd.md +2 -2
- package/yida-skills/skills/yida-process-rule/SKILL.md +6 -1
- package/yida-skills/skills/yida-process-rule/references/approval-actions.md +48 -0
- package/yida-skills/skills/yida-publish-page/SKILL.md +5 -4
- package/yida-skills/skills/yida-rechart/SKILL.md +1 -1
- package/yida-skills/skills/yida-requirement-analysis/SKILL.md +1 -1
- package/yida-skills/skills/yida-requirement-analysis/workflow/prepare-brief.md +22 -13
- 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
|
|
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 (
|
|
55
|
-
recommendedStrategy = STRATEGY.
|
|
56
|
-
reasons.push('
|
|
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('
|
|
48
|
+
reasons.push('离线模式不校验外链,也不上传图片。');
|
|
64
49
|
}
|
|
65
50
|
|
|
66
|
-
if (
|
|
67
|
-
reasons.push(
|
|
51
|
+
if (generator.available !== true) {
|
|
52
|
+
reasons.push(generator.reason);
|
|
68
53
|
}
|
|
69
54
|
|
|
70
55
|
return {
|
|
71
56
|
cdnConfigured: valid,
|
|
72
|
-
canUpload:
|
|
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
|
+
};
|
package/lib/asset/url-verify.js
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
70
|
-
{
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 { ...
|
|
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
|
-
|
|
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
|
|
109
|
+
let response;
|
|
142
110
|
try {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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(
|
|
158
|
-
const
|
|
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
|
-
|
|
162
|
-
status: res.status,
|
|
123
|
+
status: response.status,
|
|
163
124
|
contentType,
|
|
164
125
|
bytes,
|
|
165
|
-
|
|
166
|
-
|
|
126
|
+
width: metadata.width,
|
|
127
|
+
height: metadata.height,
|
|
128
|
+
imageType: metadata.type,
|
|
129
|
+
finalUrl: response.finalUrl || trimmed,
|
|
167
130
|
};
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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(
|
|
163
|
+
return Promise.all(list.map(url => verifyImageUrl(url, options)));
|
|
201
164
|
}
|
|
202
165
|
|
|
203
|
-
function normalizeError(
|
|
204
|
-
const
|
|
205
|
-
if (
|
|
206
|
-
|
|
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 = {
|