@layerporter/image-optimizer-mcp 0.1.0
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/LICENSE +45 -0
- package/README.md +124 -0
- package/package.json +52 -0
- package/server.json +23 -0
- package/src/artifact-result.js +90 -0
- package/src/artifact-store.js +153 -0
- package/src/image-core/index.js +168 -0
- package/src/image-core/page-analysis.js +204 -0
- package/src/image-core/policy.js +49 -0
- package/src/image-core/safe-fix.js +91 -0
- package/src/secure-fetch.js +193 -0
- package/src/server.js +178 -0
- package/src/text-result.js +49 -0
- package/src/tools.js +81 -0
- package/src/url-ingestion.js +228 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
|
|
3
|
+
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
|
4
|
+
import * as z from 'zod/v4';
|
|
5
|
+
import { ArtifactStore } from './artifact-store.js';
|
|
6
|
+
import { artifactToolResult } from './artifact-result.js';
|
|
7
|
+
import { TOOL_HANDLERS } from './tools.js';
|
|
8
|
+
import { textResult } from './text-result.js';
|
|
9
|
+
|
|
10
|
+
const CLOSED_TRANSFORM_ANNOTATIONS = Object.freeze({
|
|
11
|
+
readOnlyHint: true,
|
|
12
|
+
destructiveHint: false,
|
|
13
|
+
idempotentHint: true,
|
|
14
|
+
openWorldHint: false,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const OPEN_WORLD_READ_ANNOTATIONS = Object.freeze({
|
|
18
|
+
readOnlyHint: true,
|
|
19
|
+
destructiveHint: false,
|
|
20
|
+
idempotentHint: true,
|
|
21
|
+
openWorldHint: true,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export function createServer({ artifactStore = new ArtifactStore() } = {}) {
|
|
25
|
+
const server = new McpServer({
|
|
26
|
+
name: 'layerporter-image-optimizer',
|
|
27
|
+
version: '0.1.0',
|
|
28
|
+
title: 'LayerPorter Website Image Optimizer',
|
|
29
|
+
websiteUrl: 'https://layerporter.com/mcp/website-image-optimizer/',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
server.registerResource(
|
|
33
|
+
'optimized-image-artifact',
|
|
34
|
+
new ResourceTemplate('layerporter-artifact://artifact/{id}', { list: undefined }),
|
|
35
|
+
{
|
|
36
|
+
title: 'Optimized image artifact',
|
|
37
|
+
description: 'Ephemeral verified binary output created by LayerPorter image optimization.',
|
|
38
|
+
mimeType: 'application/octet-stream',
|
|
39
|
+
},
|
|
40
|
+
async (uri, { id }) => {
|
|
41
|
+
const artifactId = Array.isArray(id) ? id[0] : String(id);
|
|
42
|
+
const artifact = await artifactStore.read(artifactId);
|
|
43
|
+
return {
|
|
44
|
+
contents: [{
|
|
45
|
+
uri: uri.href,
|
|
46
|
+
blob: artifact.buffer.toString('base64'),
|
|
47
|
+
mimeType: artifact.mimeType,
|
|
48
|
+
_meta: {
|
|
49
|
+
sha256: artifact.sha256,
|
|
50
|
+
size: artifact.size,
|
|
51
|
+
expiresAt: artifact.expiresAt,
|
|
52
|
+
},
|
|
53
|
+
}],
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
server.registerTool(
|
|
59
|
+
'analyze_page_images',
|
|
60
|
+
{
|
|
61
|
+
description: 'Analyze normalized browser/page image facts and return deterministic image findings.',
|
|
62
|
+
inputSchema: z.object({ snapshot: z.record(z.string(), z.unknown()) }),
|
|
63
|
+
annotations: CLOSED_TRANSFORM_ANNOTATIONS,
|
|
64
|
+
},
|
|
65
|
+
async ({ snapshot }) => textResult(await TOOL_HANDLERS.analyze_page_images(snapshot)),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
server.registerTool(
|
|
69
|
+
'optimize_image',
|
|
70
|
+
{
|
|
71
|
+
description: 'Optimize one caller-provided image with bounded format, resize and no-regression guards. Accepted output is returned as a temporary MCP resource link, not embedded in model text.',
|
|
72
|
+
inputSchema: z.object({
|
|
73
|
+
imageBase64: z.string().min(1),
|
|
74
|
+
target: z.object({ width: z.number().int().positive().optional(), height: z.number().int().positive().optional() }).optional(),
|
|
75
|
+
policy: z.record(z.string(), z.unknown()).optional(),
|
|
76
|
+
}),
|
|
77
|
+
annotations: CLOSED_TRANSFORM_ANNOTATIONS,
|
|
78
|
+
},
|
|
79
|
+
async ({ imageBase64, target, policy }) => artifactToolResult(
|
|
80
|
+
await TOOL_HANDLERS.optimize_image({ buffer: Buffer.from(imageBase64, 'base64'), target, policy }),
|
|
81
|
+
artifactStore,
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
server.registerTool(
|
|
86
|
+
'generate_responsive_variants',
|
|
87
|
+
{
|
|
88
|
+
description: 'Generate a bounded set of responsive image variants without upscaling. Accepted variants are exposed as temporary MCP resource links.',
|
|
89
|
+
inputSchema: z.object({
|
|
90
|
+
imageBase64: z.string().min(1),
|
|
91
|
+
widths: z.array(z.number().int().positive()).min(1).max(6),
|
|
92
|
+
maxVariants: z.number().int().min(1).max(6).optional(),
|
|
93
|
+
policy: z.record(z.string(), z.unknown()).optional(),
|
|
94
|
+
}),
|
|
95
|
+
annotations: CLOSED_TRANSFORM_ANNOTATIONS,
|
|
96
|
+
},
|
|
97
|
+
async ({ imageBase64, widths, maxVariants, policy }) => artifactToolResult(
|
|
98
|
+
await TOOL_HANDLERS.generate_responsive_variants({ buffer: Buffer.from(imageBase64, 'base64'), widths, maxVariants, policy }),
|
|
99
|
+
artifactStore,
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
server.registerTool(
|
|
104
|
+
'compare_image_versions',
|
|
105
|
+
{
|
|
106
|
+
description: 'Compare original and candidate image versions for byte and guard regressions.',
|
|
107
|
+
inputSchema: z.object({
|
|
108
|
+
originalBase64: z.string().min(1),
|
|
109
|
+
candidateBase64: z.string().min(1),
|
|
110
|
+
policy: z.record(z.string(), z.unknown()).optional(),
|
|
111
|
+
}),
|
|
112
|
+
annotations: CLOSED_TRANSFORM_ANNOTATIONS,
|
|
113
|
+
},
|
|
114
|
+
async ({ originalBase64, candidateBase64, policy }) => textResult(await TOOL_HANDLERS.compare_image_versions({ original: Buffer.from(originalBase64, 'base64'), candidate: Buffer.from(candidateBase64, 'base64'), policy })),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
server.registerTool(
|
|
118
|
+
'optimize_page_images',
|
|
119
|
+
{
|
|
120
|
+
description: 'Optimize a bounded batch of already-selected SAFE image items. Accepted outputs are temporary MCP resource links. Never writes to production.',
|
|
121
|
+
inputSchema: z.object({
|
|
122
|
+
items: z.array(z.object({
|
|
123
|
+
id: z.string().optional(),
|
|
124
|
+
imageBase64: z.string().min(1),
|
|
125
|
+
target: z.object({ width: z.number().int().positive().optional(), height: z.number().int().positive().optional() }).optional(),
|
|
126
|
+
policy: z.record(z.string(), z.unknown()).optional(),
|
|
127
|
+
})).max(20),
|
|
128
|
+
maxItems: z.number().int().min(1).max(20).optional(),
|
|
129
|
+
policy: z.record(z.string(), z.unknown()).optional(),
|
|
130
|
+
}),
|
|
131
|
+
annotations: CLOSED_TRANSFORM_ANNOTATIONS,
|
|
132
|
+
},
|
|
133
|
+
async ({ items, maxItems, policy }) => artifactToolResult(
|
|
134
|
+
await TOOL_HANDLERS.optimize_page_images({ items: items.map((item) => ({ ...item, buffer: Buffer.from(item.imageBase64, 'base64') })), maxItems, policy }),
|
|
135
|
+
artifactStore,
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
server.registerTool(
|
|
140
|
+
'analyze_url_images',
|
|
141
|
+
{
|
|
142
|
+
description: 'Safely fetch a public HTTP(S) page and inspect a bounded set of image sources. HTTP fast mode does not claim browser rendered size, currentSrc or LCP.',
|
|
143
|
+
inputSchema: z.object({
|
|
144
|
+
url: z.string().min(1).max(2048),
|
|
145
|
+
maxImages: z.number().int().min(1).max(10).optional(),
|
|
146
|
+
policy: z.record(z.string(), z.unknown()).optional(),
|
|
147
|
+
}),
|
|
148
|
+
annotations: OPEN_WORLD_READ_ANNOTATIONS,
|
|
149
|
+
},
|
|
150
|
+
async ({ url, maxImages, policy }) => textResult(await TOOL_HANDLERS.analyze_url_images({ url, maxImages, policy })),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
server.registerTool(
|
|
154
|
+
'optimize_url_images',
|
|
155
|
+
{
|
|
156
|
+
description: 'Safely fetch a public HTTP(S) page, recompress a bounded set of image sources at their original dimensions, verify byte savings, and return accepted outputs as temporary MCP resource links. Never writes to the website.',
|
|
157
|
+
inputSchema: z.object({
|
|
158
|
+
url: z.string().min(1).max(2048),
|
|
159
|
+
maxImages: z.number().int().min(1).max(10).optional(),
|
|
160
|
+
policy: z.record(z.string(), z.unknown()).optional(),
|
|
161
|
+
}),
|
|
162
|
+
annotations: OPEN_WORLD_READ_ANNOTATIONS,
|
|
163
|
+
},
|
|
164
|
+
async ({ url, maxImages, policy }) => artifactToolResult(
|
|
165
|
+
await TOOL_HANDLERS.optimize_url_images({ url, maxImages, policy }),
|
|
166
|
+
artifactStore,
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return server;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const artifactStore = new ArtifactStore();
|
|
174
|
+
try {
|
|
175
|
+
await serveStdio(() => createServer({ artifactStore }));
|
|
176
|
+
} finally {
|
|
177
|
+
await artifactStore.dispose();
|
|
178
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
function binarySummary(value) {
|
|
2
|
+
return {
|
|
3
|
+
type: 'binary',
|
|
4
|
+
byteLength: value.byteLength,
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function sanitizeForText(value, seen = new WeakSet()) {
|
|
9
|
+
if (Buffer.isBuffer(value)) return binarySummary(value);
|
|
10
|
+
|
|
11
|
+
if (ArrayBuffer.isView(value)) {
|
|
12
|
+
return binarySummary(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (value instanceof ArrayBuffer) {
|
|
16
|
+
return { type: 'binary', byteLength: value.byteLength };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (Array.isArray(value)) {
|
|
20
|
+
return value.map((item) => sanitizeForText(item, seen));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!value || typeof value !== 'object') return value;
|
|
24
|
+
|
|
25
|
+
// Defend against objects that already came through Buffer.toJSON().
|
|
26
|
+
if (value.type === 'Buffer' && Array.isArray(value.data)) {
|
|
27
|
+
return { type: 'binary', byteLength: value.data.length };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (seen.has(value)) return '[Circular]';
|
|
31
|
+
seen.add(value);
|
|
32
|
+
|
|
33
|
+
const result = {};
|
|
34
|
+
for (const [key, item] of Object.entries(value)) {
|
|
35
|
+
result[key] = sanitizeForText(item, seen);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
seen.delete(value);
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function textResult(value) {
|
|
43
|
+
return {
|
|
44
|
+
content: [{
|
|
45
|
+
type: 'text',
|
|
46
|
+
text: JSON.stringify(sanitizeForText(value)),
|
|
47
|
+
}],
|
|
48
|
+
};
|
|
49
|
+
}
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { analyzePageImages, inspectImage, optimizeImage } from './image-core/index.js';
|
|
2
|
+
import { analyzeRemotePageImages, optimizeRemotePageImages } from './url-ingestion.js';
|
|
3
|
+
|
|
4
|
+
function requireBuffer(value, name) {
|
|
5
|
+
if (!Buffer.isBuffer(value) || value.length === 0) throw new TypeError(`${name} must be a non-empty Buffer`);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function analyzePageImagesTool(input) {
|
|
9
|
+
return analyzePageImages(input);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function optimizeImageTool(input) {
|
|
13
|
+
requireBuffer(input.buffer, 'buffer');
|
|
14
|
+
return optimizeImage(input.buffer, { target: input.target, policy: input.policy });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function generateResponsiveVariantsTool(input) {
|
|
18
|
+
requireBuffer(input.buffer, 'buffer');
|
|
19
|
+
const original = await inspectImage(input.buffer, { policy: input.policy });
|
|
20
|
+
const widths = [...new Set((input.widths ?? []).filter((w) => Number.isInteger(w) && w > 0 && w <= original.width))]
|
|
21
|
+
.sort((a, b) => a - b)
|
|
22
|
+
.slice(0, Math.max(1, Math.min(input.maxVariants ?? 4, 6)));
|
|
23
|
+
const variants = [];
|
|
24
|
+
for (const width of widths) {
|
|
25
|
+
const result = await optimizeImage(input.buffer, { target: { width }, policy: input.policy });
|
|
26
|
+
variants.push({ width, ...result });
|
|
27
|
+
}
|
|
28
|
+
return { status: variants.some((v) => v.status === 'ACCEPT') ? 'ACCEPT' : 'REJECT', original, variants };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function compareImageVersionsTool(input) {
|
|
32
|
+
requireBuffer(input.original, 'original');
|
|
33
|
+
requireBuffer(input.candidate, 'candidate');
|
|
34
|
+
const original = await inspectImage(input.original, { policy: input.policy });
|
|
35
|
+
const candidate = await inspectImage(input.candidate, { policy: input.policy });
|
|
36
|
+
const savingsBytes = original.bytes - candidate.bytes;
|
|
37
|
+
const alphaRegression = original.hasAlpha && !candidate.hasAlpha;
|
|
38
|
+
const dimensionRegression = candidate.width > original.width || candidate.height > original.height;
|
|
39
|
+
return {
|
|
40
|
+
status: savingsBytes > 0 && !alphaRegression && !dimensionRegression ? 'ACCEPT' : 'REJECT',
|
|
41
|
+
original,
|
|
42
|
+
candidate,
|
|
43
|
+
savingsBytes,
|
|
44
|
+
savingsPercent: Number(((savingsBytes / original.bytes) * 100).toFixed(2)),
|
|
45
|
+
checks: { alphaRegression, dimensionRegression },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function optimizePageImagesTool(input) {
|
|
50
|
+
if (!Array.isArray(input.items)) throw new TypeError('items must be an array');
|
|
51
|
+
const items = input.items.slice(0, Math.max(1, Math.min(input.maxItems ?? 10, 20)));
|
|
52
|
+
const results = [];
|
|
53
|
+
for (const item of items) {
|
|
54
|
+
requireBuffer(item.buffer, 'item.buffer');
|
|
55
|
+
results.push({ id: item.id ?? null, ...(await optimizeImage(item.buffer, { target: item.target, policy: item.policy ?? input.policy })) });
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
status: results.some((r) => r.status === 'ACCEPT') ? 'ACCEPT' : 'REJECT',
|
|
59
|
+
processed: results.length,
|
|
60
|
+
accepted: results.filter((r) => r.status === 'ACCEPT').length,
|
|
61
|
+
results,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function analyzeUrlImagesTool(input, options = {}) {
|
|
66
|
+
return analyzeRemotePageImages(input, options);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function optimizeUrlImagesTool(input, options = {}) {
|
|
70
|
+
return optimizeRemotePageImages(input, options);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const TOOL_HANDLERS = {
|
|
74
|
+
analyze_page_images: analyzePageImagesTool,
|
|
75
|
+
optimize_image: optimizeImageTool,
|
|
76
|
+
generate_responsive_variants: generateResponsiveVariantsTool,
|
|
77
|
+
compare_image_versions: compareImageVersionsTool,
|
|
78
|
+
optimize_page_images: optimizePageImagesTool,
|
|
79
|
+
analyze_url_images: analyzeUrlImagesTool,
|
|
80
|
+
optimize_url_images: optimizeUrlImagesTool,
|
|
81
|
+
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { inspectImage, optimizeImage } from './image-core/index.js';
|
|
2
|
+
import { secureFetch } from './secure-fetch.js';
|
|
3
|
+
|
|
4
|
+
const DEFAULTS = Object.freeze({
|
|
5
|
+
maxImages: 4,
|
|
6
|
+
maxPageBytes: 2 * 1024 * 1024,
|
|
7
|
+
maxImageBytes: 8 * 1024 * 1024,
|
|
8
|
+
maxTotalImageBytes: 24 * 1024 * 1024,
|
|
9
|
+
timeoutMs: 8000,
|
|
10
|
+
maxRedirects: 3,
|
|
11
|
+
concurrency: 2,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function decodeHtmlEntities(value) {
|
|
15
|
+
return value
|
|
16
|
+
.replace(/&/gi, '&')
|
|
17
|
+
.replace(/"/gi, '"')
|
|
18
|
+
.replace(/'|'/gi, "'")
|
|
19
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
|
|
20
|
+
.replace(/&#([0-9]+);/g, (_, dec) => String.fromCodePoint(Number.parseInt(dec, 10)));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseAttributes(tag) {
|
|
24
|
+
const attrs = {};
|
|
25
|
+
const re = /\b([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
|
|
26
|
+
for (const match of tag.matchAll(re)) attrs[match[1].toLowerCase()] = decodeHtmlEntities(match[2] ?? match[3] ?? match[4] ?? '');
|
|
27
|
+
return attrs;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function firstSrcsetUrl(srcset) {
|
|
31
|
+
if (typeof srcset !== 'string' || !srcset.trim()) return null;
|
|
32
|
+
const first = srcset.split(',')[0]?.trim();
|
|
33
|
+
if (!first) return null;
|
|
34
|
+
return first.split(/\s+/)[0] || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function positiveInt(value) {
|
|
38
|
+
const number = Number.parseInt(value ?? '', 10);
|
|
39
|
+
return Number.isInteger(number) && number > 0 ? number : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function extractImageRefs(html, pageUrl, maxImages = DEFAULTS.maxImages) {
|
|
43
|
+
if (typeof html !== 'string') throw new TypeError('html must be a string');
|
|
44
|
+
const limit = Math.max(1, Math.min(maxImages, 10));
|
|
45
|
+
const refs = [];
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
let imgTagCount = 0;
|
|
48
|
+
|
|
49
|
+
for (const match of html.matchAll(/<img\b[^>]*>/gi)) {
|
|
50
|
+
imgTagCount += 1;
|
|
51
|
+
if (refs.length >= limit) continue;
|
|
52
|
+
const attrs = parseAttributes(match[0]);
|
|
53
|
+
const rawSrc = attrs.src || firstSrcsetUrl(attrs.srcset);
|
|
54
|
+
if (!rawSrc) continue;
|
|
55
|
+
|
|
56
|
+
let url;
|
|
57
|
+
try {
|
|
58
|
+
url = new URL(rawSrc, pageUrl);
|
|
59
|
+
} catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!['http:', 'https:'].includes(url.protocol) || seen.has(url.href)) continue;
|
|
63
|
+
seen.add(url.href);
|
|
64
|
+
refs.push({
|
|
65
|
+
url: url.href,
|
|
66
|
+
src: attrs.src ?? null,
|
|
67
|
+
srcset: attrs.srcset ?? null,
|
|
68
|
+
sizes: attrs.sizes ?? null,
|
|
69
|
+
widthAttr: positiveInt(attrs.width),
|
|
70
|
+
heightAttr: positiveInt(attrs.height),
|
|
71
|
+
loading: attrs.loading ?? null,
|
|
72
|
+
fetchPriority: attrs.fetchpriority ?? null,
|
|
73
|
+
altPresent: Object.prototype.hasOwnProperty.call(attrs, 'alt'),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { imgTagCount, refs };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function mapBounded(items, concurrency, worker) {
|
|
81
|
+
const out = new Array(items.length);
|
|
82
|
+
let cursor = 0;
|
|
83
|
+
const workers = Array.from({ length: Math.max(1, Math.min(concurrency, 3)) }, async () => {
|
|
84
|
+
while (true) {
|
|
85
|
+
const index = cursor;
|
|
86
|
+
cursor += 1;
|
|
87
|
+
if (index >= items.length) return;
|
|
88
|
+
out[index] = await worker(items[index], index);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
await Promise.all(workers);
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function contentType(headers) {
|
|
96
|
+
const value = headers?.['content-type'];
|
|
97
|
+
return Array.isArray(value) ? value[0] ?? '' : value ?? '';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function staticObservations(ref) {
|
|
101
|
+
const observations = [];
|
|
102
|
+
if (!(ref.widthAttr && ref.heightAttr)) observations.push('missing_width_height_attributes');
|
|
103
|
+
if (!ref.srcset) observations.push('missing_srcset');
|
|
104
|
+
if (ref.srcset && !ref.sizes) observations.push('missing_sizes');
|
|
105
|
+
return observations;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function loadUrlImages(input, options = {}) {
|
|
109
|
+
const cfg = { ...DEFAULTS, ...(options.limits ?? {}) };
|
|
110
|
+
const maxImages = Math.max(1, Math.min(input.maxImages ?? cfg.maxImages, 10));
|
|
111
|
+
const fetchImpl = options.fetchImpl ?? secureFetch;
|
|
112
|
+
|
|
113
|
+
const page = await fetchImpl(input.url, {
|
|
114
|
+
maxBytes: cfg.maxPageBytes,
|
|
115
|
+
timeoutMs: cfg.timeoutMs,
|
|
116
|
+
maxRedirects: cfg.maxRedirects,
|
|
117
|
+
accept: 'text/html,application/xhtml+xml',
|
|
118
|
+
});
|
|
119
|
+
if (page.status < 200 || page.status >= 300) throw new Error(`page_http_${page.status}`);
|
|
120
|
+
if (!/^(text\/html|application\/xhtml\+xml)\b/i.test(contentType(page.headers))) throw new Error('page_content_type_not_html');
|
|
121
|
+
|
|
122
|
+
const html = new TextDecoder('utf-8', { fatal: false }).decode(page.body);
|
|
123
|
+
const extracted = extractImageRefs(html, page.url, maxImages);
|
|
124
|
+
let acceptedImageBytes = 0;
|
|
125
|
+
|
|
126
|
+
const results = await mapBounded(extracted.refs, cfg.concurrency, async (ref) => {
|
|
127
|
+
try {
|
|
128
|
+
const response = await fetchImpl(ref.url, {
|
|
129
|
+
maxBytes: cfg.maxImageBytes,
|
|
130
|
+
timeoutMs: cfg.timeoutMs,
|
|
131
|
+
maxRedirects: cfg.maxRedirects,
|
|
132
|
+
accept: 'image/*',
|
|
133
|
+
});
|
|
134
|
+
if (response.status < 200 || response.status >= 300) return { status: 'SKIP', sourceUrl: ref.url, reason: `image_http_${response.status}`, html: ref };
|
|
135
|
+
const mimeType = contentType(response.headers).split(';')[0].trim().toLowerCase();
|
|
136
|
+
if (!mimeType.startsWith('image/')) return { status: 'SKIP', sourceUrl: ref.url, reason: 'image_content_type_invalid', html: ref };
|
|
137
|
+
|
|
138
|
+
const metadata = await inspectImage(response.body, { policy: input.policy });
|
|
139
|
+
const responseBytes = response.body.length;
|
|
140
|
+
if (acceptedImageBytes + responseBytes > cfg.maxTotalImageBytes) return { status: 'SKIP', sourceUrl: ref.url, reason: 'total_image_byte_budget', html: ref };
|
|
141
|
+
acceptedImageBytes += responseBytes;
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
status: 'FETCHED',
|
|
145
|
+
sourceUrl: response.url,
|
|
146
|
+
mimeType,
|
|
147
|
+
redirects: response.redirects.length,
|
|
148
|
+
html: ref,
|
|
149
|
+
observations: staticObservations(ref),
|
|
150
|
+
image: metadata,
|
|
151
|
+
buffer: response.body,
|
|
152
|
+
};
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return { status: 'SKIP', sourceUrl: ref.url, reason: error?.code ?? error?.message ?? 'image_fetch_failed', html: ref };
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
mode: 'http_fast',
|
|
160
|
+
browserMetrics: false,
|
|
161
|
+
page: {
|
|
162
|
+
requestedUrl: input.url,
|
|
163
|
+
finalUrl: page.url,
|
|
164
|
+
httpStatus: page.status,
|
|
165
|
+
bytes: page.body.length,
|
|
166
|
+
redirects: page.redirects.length,
|
|
167
|
+
imgTagCount: extracted.imgTagCount,
|
|
168
|
+
selectedImageCount: extracted.refs.length,
|
|
169
|
+
},
|
|
170
|
+
images: results,
|
|
171
|
+
fetched: results.filter((item) => item.status === 'FETCHED').length,
|
|
172
|
+
skipped: results.filter((item) => item.status === 'SKIP').length,
|
|
173
|
+
fetchedImageBytes: acceptedImageBytes,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function withoutBuffers(value) {
|
|
178
|
+
if (Array.isArray(value)) return value.map(withoutBuffers);
|
|
179
|
+
if (!value || typeof value !== 'object') return value;
|
|
180
|
+
const out = {};
|
|
181
|
+
for (const [key, item] of Object.entries(value)) {
|
|
182
|
+
if (key === 'buffer' && Buffer.isBuffer(item)) continue;
|
|
183
|
+
out[key] = withoutBuffers(item);
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function analyzeRemotePageImages(input, options = {}) {
|
|
189
|
+
const loaded = await loadUrlImages(input, options);
|
|
190
|
+
return {
|
|
191
|
+
status: loaded.fetched > 0 ? 'OK' : 'NO_IMAGES',
|
|
192
|
+
...withoutBuffers(loaded),
|
|
193
|
+
safetyNote: 'HTTP fast mode reports static image facts only; it does not claim rendered size, currentSrc, LCP, or browser-selected candidates.',
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function optimizeRemotePageImages(input, options = {}) {
|
|
198
|
+
const loaded = await loadUrlImages(input, options);
|
|
199
|
+
const results = [];
|
|
200
|
+
|
|
201
|
+
for (const item of loaded.images) {
|
|
202
|
+
if (item.status !== 'FETCHED') {
|
|
203
|
+
results.push(withoutBuffers(item));
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const optimization = await optimizeImage(item.buffer, { policy: input.policy });
|
|
207
|
+
results.push({
|
|
208
|
+
sourceUrl: item.sourceUrl,
|
|
209
|
+
mimeType: item.mimeType,
|
|
210
|
+
html: item.html,
|
|
211
|
+
observations: item.observations,
|
|
212
|
+
original: item.image,
|
|
213
|
+
optimization,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const accepted = results.filter((item) => item.optimization?.status === 'ACCEPT').length;
|
|
218
|
+
return {
|
|
219
|
+
status: accepted > 0 ? 'ACCEPT' : 'REJECT',
|
|
220
|
+
mode: 'http_fast_same_dimensions',
|
|
221
|
+
browserMetrics: false,
|
|
222
|
+
page: loaded.page,
|
|
223
|
+
processed: results.filter((item) => item.optimization).length,
|
|
224
|
+
accepted,
|
|
225
|
+
results,
|
|
226
|
+
safetyNote: 'URL mode never auto-resizes from HTML layout hints. It only recompresses at the original dimensions; browser-aware resize requires measured rendered facts or an explicit target.',
|
|
227
|
+
};
|
|
228
|
+
}
|