This commit is contained in:
seb
2026-09-27 18:01:08 +02:00
parent 9f97e3f3c6
commit 65b4ce5098
6 changed files with 516 additions and 1 deletions

View File

@@ -132,6 +132,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/quote/delete.md`](routes/quote/delete.md) | `DELETE /quote/{id}` |
| [`routes/quote/get.md`](routes/quote/get.md) | `GET /quote/{id}` |
| [`routes/quote/update.md`](routes/quote/update.md) | `PATCH /quote/{id}` |
| [`routes/serper/image.md`](routes/serper/image.md) | `POST /serper/image` |
| [`routes/serper/images.md`](routes/serper/images.md) | `POST /serper/images` |
| [`routes/stock/adjust.md`](routes/stock/adjust.md) | `POST /stock/adjust` |
| [`routes/supplier/list.md`](routes/supplier/list.md) | `GET /supplier` |

View File

@@ -1126,6 +1126,114 @@
}
}
},
"/serper/image": {
"post": {
"tags": [
"Serper"
],
"summary": "Crawl image from URL, crop white/transparency borders, and return AVIF",
"description": "Fetches the image at the provided `url`, crops surrounding white and\ntransparent border margins, resizes to max 1000px, and returns the AVIF image.\n",
"security": [
{
"cookieAuth": []
}
],
"x-markdown": "serper/image.md",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"url"
],
"properties": {
"url": {
"type": "string",
"description": "Absolute HTTP/HTTPS URL of the image to crawl",
"example": "https://example.com/logo.png"
}
}
}
}
}
},
"responses": {
"200": {
"description": "AVIF image bytes",
"headers": {
"Content-Type": {
"schema": {
"type": "string",
"example": "image/avif"
}
},
"X-Image-Width": {
"schema": {
"type": "integer",
"example": 540
}
},
"X-Image-Height": {
"schema": {
"type": "integer",
"example": 179
}
}
},
"content": {
"image/avif": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"400": {
"description": "URL missing, invalid input, or image processing error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Not authorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"502": {
"description": "Bad Gateway (crawling the upstream image URL failed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/quote": {
"get": {
"tags": [

View File

@@ -0,0 +1,34 @@
# `POST /serper/image`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Crawl image from URL, crop white/transparency borders, and return AVIF
Fetches the image at the provided `url`, crops surrounding white and
transparent border margins, resizes to max 1000px, and returns the AVIF image.
## Body (JSON)
```json
{
"url": "https://example.com/logo.png"
}
```
## Response `200`
AVIF image bytes
## Errors
| Status | Description |
|--------|-------------|
| 400 | URL missing, invalid input, or image processing error — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 502 | Bad Gateway (crawling the upstream image URL failed) — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

89
scripts/testImageCrop.js Normal file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env node
import sharp from 'sharp';
import { cropAndConvertToAvif, crawlAndProcessImage } from '../src/services/imageCrop.js';
async function run() {
console.log('--- 1. Testing synthetic images ---');
// Synthetic test 1: White border
const whiteImg = await sharp({
create: { width: 300, height: 300, channels: 4, background: { r: 255, g: 255, b: 255, alpha: 1 } },
})
.composite([
{
input: Buffer.from('<svg width="100" height="100"><rect width="100" height="100" fill="purple"/></svg>'),
top: 100,
left: 100,
},
])
.png()
.toBuffer();
const res1 = await cropAndConvertToAvif(whiteImg);
console.log(`White border: 300x300 -> ${res1.width}x${res1.height}, ${res1.size} bytes`);
if (res1.width !== 100 || res1.height !== 100) {
throw new Error(`Expected 100x100, got ${res1.width}x${res1.height}`);
}
// Synthetic test 2: Transparent border
const transImg = await sharp({
create: { width: 300, height: 300, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
})
.composite([
{
input: Buffer.from('<svg width="120" height="80"><rect width="120" height="80" fill="green"/></svg>'),
top: 110,
left: 90,
},
])
.png()
.toBuffer();
const res2 = await cropAndConvertToAvif(transImg);
console.log(`Transparent border: 300x300 -> ${res2.width}x${res2.height}, ${res2.size} bytes`);
if (res2.width !== 120 || res2.height !== 80) {
throw new Error(`Expected 120x80, got ${res2.width}x${res2.height}`);
}
// Synthetic test 3: Outer transparent, inner white border
const mixedImg = await sharp({
create: { width: 400, height: 400, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
})
.composite([
{
input: Buffer.from('<svg width="200" height="200"><rect width="200" height="200" fill="white"/></svg>'),
top: 100,
left: 100,
},
{
input: Buffer.from('<svg width="100" height="60"><rect width="100" height="60" fill="blue"/></svg>'),
top: 170,
left: 150,
},
])
.png()
.toBuffer();
const res3 = await cropAndConvertToAvif(mixedImg);
console.log(`Mixed border: 400x400 -> ${res3.width}x${res3.height}, ${res3.size} bytes`);
if (res3.width !== 100 || res3.height !== 60) {
throw new Error(`Expected 100x60, got ${res3.width}x${res3.height}`);
}
console.log('\nAll synthetic tests PASSED!');
// CLI URL test if provided as argument
const testUrl = process.argv[2];
if (testUrl) {
console.log(`\n--- 2. Crawling URL: ${testUrl} ---`);
const urlRes = await crawlAndProcessImage(testUrl);
console.log(`Crawled & processed: ${urlRes.width}x${urlRes.height}, ${urlRes.size} bytes`);
}
process.exit(0);
}
run().catch((err) => {
console.error('Test failed:', err);
process.exit(1);
});

View File

@@ -1,6 +1,7 @@
import sharp from 'sharp';
import * as dbCompanySetting from '../../db/companysetting.js';
import env from '../../config/env.js';
import { crawlAndProcessImage } from '../../services/imageCrop.js';
const CONCURRENCY = 6;
const FETCH_TIMEOUT_MS = 6000;
@@ -342,6 +343,113 @@ export function register(app, basePath = '') {
}
};
/**
* @openapi
* /serper/image:
* post:
* tags: [Serper]
* summary: Crawl image from URL, crop white/transparency borders, and return AVIF
* description: |
* Fetches the image at the provided `url`, crops surrounding white and
* transparent border margins, resizes to max 1000px, and returns the AVIF image.
* security:
* - cookieAuth: []
* x-markdown: serper/image.md
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [url]
* properties:
* url:
* type: string
* description: Absolute HTTP/HTTPS URL of the image to crawl
* example: "https://example.com/logo.png"
* responses:
* 200:
* description: AVIF image bytes
* headers:
* Content-Type:
* schema:
* type: string
* example: image/avif
* X-Image-Width:
* schema:
* type: integer
* example: 540
* X-Image-Height:
* schema:
* type: integer
* example: 179
* content:
* image/avif:
* schema:
* type: string
* format: binary
* 400:
* description: URL missing, invalid input, or image processing error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 502:
* description: Bad Gateway (crawling the upstream image URL failed)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
const handleImageCrop = async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const rawUrl = typeof req.body?.url === 'string' ? req.body.url.trim() : '';
if (!rawUrl) {
return res.status(400).json({ error: 'url is required' });
}
const abortController = new AbortController();
const onClose = () => abortController.abort();
req.on('close', onClose);
res.on('close', onClose);
try {
const result = await crawlAndProcessImage(rawUrl, {
signal: abortController.signal,
});
if (res.writableEnded || res.destroyed) return;
res.setHeader('Content-Type', 'image/avif');
res.setHeader('Content-Length', String(result.buffer.length));
res.setHeader('X-Image-Width', String(result.width));
res.setHeader('X-Image-Height', String(result.height));
return res.status(200).send(result.buffer);
} catch (err) {
if (abortController.signal.aborted || res.writableEnded || res.destroyed) return;
const message = err?.message || 'Failed to process image';
const isUpstream = message.startsWith('Failed to crawl image:');
const status = isUpstream ? 502 : 400;
return res.status(status).json({ error: message });
}
};
app.post(`${basePath}/serper/images`, handleImagesSearch);
app.post(`${basePath}/search/images`, handleImagesSearch);
app.post(`${basePath}/serper/image`, handleImageCrop);
}

175
src/services/imageCrop.js Normal file
View File

@@ -0,0 +1,175 @@
import sharp from 'sharp';
const FETCH_TIMEOUT_MS = 15000;
const MAX_BYTES = 50 * 1024 * 1024; // 50MB
const MAX_EDGE = 1000;
const TRIM_THRESHOLD = 10;
const AVIF_QUALITY = 80;
const AVIF_EFFORT = 4;
/**
* Validates and normalizes an image URL.
* @param {string} url
* @returns {URL}
*/
export function validateImageUrl(url) {
if (typeof url !== 'string' || !url.trim()) {
throw new Error('url is required');
}
let parsed;
try {
parsed = new URL(url.trim());
} catch {
throw new Error('Invalid URL');
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`Unsupported URL protocol: ${parsed.protocol}. Only http: and https: are allowed.`);
}
return parsed;
}
/**
* Crawls/downloads an image from a URL.
*
* @param {string} url
* @param {object} [options]
* @param {AbortSignal} [options.signal]
* @returns {Promise<Buffer>}
*/
export async function crawlImage(url, { signal } = {}) {
const parsedUrl = validateImageUrl(url);
const fetchSignal = signal
? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
: AbortSignal.timeout(FETCH_TIMEOUT_MS);
const response = await fetch(parsedUrl.href, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9,de;q=0.8',
'Referer': parsedUrl.origin + '/',
},
signal: fetchSignal,
redirect: 'follow',
});
if (!response.ok) {
throw new Error(`Failed to crawl image: HTTP ${response.status} ${response.statusText}`);
}
const contentLength = Number(response.headers.get('content-length'));
if (Number.isFinite(contentLength) && contentLength > MAX_BYTES) {
throw new Error(`Image size exceeds limit of ${MAX_BYTES} bytes`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
if (buffer.length === 0) {
throw new Error('Crawled image is empty (0 bytes)');
}
if (buffer.length > MAX_BYTES) {
throw new Error(`Image size (${buffer.length} bytes) exceeds limit of ${MAX_BYTES} bytes`);
}
return buffer;
}
/**
* Trims transparent and white borders from an image buffer, resizes to max 1000px, and converts to AVIF.
*
* @param {Buffer} inputBuffer - Raw image buffer
* @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>}
*/
export async function cropAndConvertToAvif(inputBuffer) {
// 1. Initial metadata and orientation correction
const baseSharp = sharp(inputBuffer, { failOn: 'none' }).rotate();
const initialMeta = await baseSharp.metadata();
// Use PNG intermediate for trimming passes to preserve alpha
let currentBuffer = await baseSharp.png().toBuffer();
let currentMeta = initialMeta;
// 2. Step A: Trim transparent borders if alpha is present
if (currentMeta.hasAlpha) {
try {
const trimmed = await sharp(currentBuffer)
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: TRIM_THRESHOLD })
.png()
.toBuffer({ resolveWithObject: true });
if (trimmed.info.width > 0 && trimmed.info.height > 0) {
currentBuffer = trimmed.data;
currentMeta = trimmed.info;
}
} catch {
// Keep currentBuffer
}
}
// 3. Step B: Trim white borders
try {
const trimmed = await sharp(currentBuffer)
.trim({ background: '#ffffff', threshold: TRIM_THRESHOLD })
.png()
.toBuffer({ resolveWithObject: true });
if (trimmed.info.width > 0 && trimmed.info.height > 0) {
currentBuffer = trimmed.data;
currentMeta = trimmed.info;
}
} catch {
// Keep currentBuffer
}
// 4. Step C: Trim transparent borders again (handles white border containing transparent padding)
if (currentMeta.hasAlpha) {
try {
const trimmed = await sharp(currentBuffer)
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: TRIM_THRESHOLD })
.png()
.toBuffer({ resolveWithObject: true });
if (trimmed.info.width > 0 && trimmed.info.height > 0) {
currentBuffer = trimmed.data;
currentMeta = trimmed.info;
}
} catch {
// Keep currentBuffer
}
}
// 5. Resize to max 1000px without enlargement
let finalPipeline = sharp(currentBuffer);
if (currentMeta.width > MAX_EDGE || currentMeta.height > MAX_EDGE) {
finalPipeline = finalPipeline.resize(MAX_EDGE, MAX_EDGE, {
fit: 'inside',
withoutEnlargement: true,
});
}
// 6. Convert to AVIF
const avifResult = await finalPipeline
.avif({ quality: AVIF_QUALITY, effort: AVIF_EFFORT })
.toBuffer({ resolveWithObject: true });
return {
buffer: avifResult.data,
width: avifResult.info.width,
height: avifResult.info.height,
size: avifResult.info.size,
};
}
/**
* Crawls a URL, crops white & transparency borders, resizes to max 1000px, and returns AVIF.
*
* @param {string} url - Image URL to crawl
* @param {object} [options]
* @param {AbortSignal} [options.signal] - Abort signal
* @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>}
*/
export async function crawlAndProcessImage(url, { signal } = {}) {
const rawBuffer = await crawlImage(url, { signal });
return cropAndConvertToAvif(rawBuffer);
}