From 65b4ce5098ee469e8d21044e4b288fb7095cd4cc Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 27 Sep 2026 18:01:08 +0200 Subject: [PATCH] u --- docs/api/README.md | 1 + docs/api/openapi.json | 108 ++++++++++++++++++++ docs/api/routes/serper/image.md | 34 +++++++ scripts/testImageCrop.js | 89 ++++++++++++++++ src/api/routes/serper.js | 110 +++++++++++++++++++- src/services/imageCrop.js | 175 ++++++++++++++++++++++++++++++++ 6 files changed, 516 insertions(+), 1 deletion(-) create mode 100644 docs/api/routes/serper/image.md create mode 100644 scripts/testImageCrop.js create mode 100644 src/services/imageCrop.js diff --git a/docs/api/README.md b/docs/api/README.md index c243862..84e2cb0 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -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` | diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 8943643..9d96d10 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -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": [ diff --git a/docs/api/routes/serper/image.md b/docs/api/routes/serper/image.md new file mode 100644 index 0000000..edaa703 --- /dev/null +++ b/docs/api/routes/serper/image.md @@ -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`._ diff --git a/scripts/testImageCrop.js b/scripts/testImageCrop.js new file mode 100644 index 0000000..d697200 --- /dev/null +++ b/scripts/testImageCrop.js @@ -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(''), + 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(''), + 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(''), + top: 100, + left: 100, + }, + { + input: Buffer.from(''), + 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); +}); diff --git a/src/api/routes/serper.js b/src/api/routes/serper.js index 436010c..e5d2f36 100644 --- a/src/api/routes/serper.js +++ b/src/api/routes/serper.js @@ -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); } diff --git a/src/services/imageCrop.js b/src/services/imageCrop.js new file mode 100644 index 0000000..392268c --- /dev/null +++ b/src/services/imageCrop.js @@ -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} + */ +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); +}