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; function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) { return { error: 'No company selected' }; } return { companyId }; } /** * Downloads an image (trying thumbnailUrl then imageUrl, or vice versa) and * scales it to max 200×200 WebP base64 data URL. * * @param {object} img * @param {string} [img.imageUrl] * @param {string} [img.thumbnailUrl] * @param {object} options * @param {AbortSignal} [options.signal] * @param {boolean} [options.preferSource=false] * @returns {Promise<{ dataUrl: string, width: number, height: number, size: number }>} */ async function generateWebpThumbnail(img, { signal, preferSource = false } = {}) { const urls = preferSource ? [img.imageUrl, img.thumbnailUrl].filter(Boolean) : [img.thumbnailUrl, img.imageUrl].filter(Boolean); if (urls.length === 0) { throw new Error('No image URL available'); } let lastError = null; for (const url of urls) { if (signal?.aborted) throw new Error('Aborted'); try { const fetchSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]) : AbortSignal.timeout(FETCH_TIMEOUT_MS); const response = await fetch(url, { 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', }, signal: fetchSignal, }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); const thumb = await sharp(buffer) .rotate() .resize(200, 200, { fit: 'inside', withoutEnlargement: true, }) .webp({ quality: 80 }) .toBuffer({ resolveWithObject: true }); const dataUrl = `data:image/webp;base64,${thumb.data.toString('base64')}`; return { dataUrl, width: thumb.info.width, height: thumb.info.height, size: thumb.info.size, }; } catch (err) { lastError = err; } } throw lastError || new Error('Failed to load thumbnail'); } /** * Registers Serper image search routes. * @param {import('express').Application} app * @param {string} [basePath=''] */ export function register(app, basePath = '') { /** * @openapi * /serper/images: * post: * tags: [Serper] * summary: Search images via Serper.dev and stream WebP thumbnails (SSE) * description: | * Queries Google Image search via `https://google.serper.dev/images` using the * company setting `keys.serper` (or server `SERPER_API_KEY`), and streams the * results via Server-Sent Events (`text/event-stream`). * * 1. First emits an `images` event with the full search result list (including `imageUrl`). * 2. Then streams `thumb` events with WebP base64 thumbnails (max 200×200) as they are generated. * 3. Emits `done` when all thumbnails have completed (or `error` on failure). * security: * - cookieAuth: [] * x-markdown: serper/images.md * x-sse-events: * - name: images * description: First event; list of search result images with id, url, width, and height * payload: '{ "images": [ { "id": 0, "url": "…", "width": 1000, "height": 800 } ] }' * - name: thumb * description: Emitted for each image as its 200×200 max WebP thumbnail is generated * payload: '{ "id": 0, "dataUrl": "data:image/webp;base64,…" }' * - name: done * description: Emitted after all thumbnails have finished streaming; closes the SSE stream * payload: '{ "total": 100 }' * - name: error * description: Emitted when an error occurs during Serper request or processing * payload: '{ "error": "…" }' * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [q] * properties: * q: * type: string * description: Image search query (e.g. "Purize logo") * gl: * type: string * default: de * description: Country code * hl: * type: string * default: de * description: Language code * num: * type: integer * default: 100 * minimum: 1 * maximum: 100 * description: Number of search results to fetch * preferSource: * type: boolean * default: false * description: Whether to prefer downloading from the full imageUrl before falling back to Google thumbnailUrl * example: * q: "Purize logo" * gl: "de" * hl: "de" * num: 100 * responses: * 200: * description: | * `text/event-stream` — emits `images`, `thumb`, `done` (and `error` on failure). * content: * text/event-stream: * schema: * type: string * 400: * description: Query missing, invalid input, or Serper API key not configured * 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' * 404: * description: Company not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ const handleImagesSearch = async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const query = typeof req.body?.q === 'string' ? req.body.q : (typeof req.body?.query === 'string' ? req.body.query : ''); if (!query.trim()) { return res.status(400).json({ error: 'q is required' }); } let apiKey = null; try { const setting = await dbCompanySetting.getByKey(req.user.id, 'keys.serper', { maskWriteonly: false }); apiKey = setting?.value || env.serperApiKey; } catch (err) { console.warn('Serper images: failed to read company setting keys.serper:', err?.message || err); apiKey = env.serperApiKey; } if (!apiKey) { return res.status(400).json({ error: 'Serper API key not configured (companysetting: keys.serper)', }); } const gl = typeof req.body?.gl === 'string' && req.body.gl.trim() ? req.body.gl.trim() : 'de'; const hl = typeof req.body?.hl === 'string' && req.body.hl.trim() ? req.body.hl.trim() : 'de'; let num = Number(req.body?.num); if (!Number.isFinite(num) || num <= 0) num = 100; num = Math.min(100, Math.max(1, Math.floor(num))); const preferSource = Boolean(req.body?.preferSource); // ── Open SSE Stream ────────────────────────────────────────────── res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); if (typeof res.flushHeaders === 'function') { res.flushHeaders(); } const abortController = new AbortController(); const onClose = () => { abortController.abort(); }; req.on('close', onClose); res.on('close', onClose); const sseWrite = (event, data) => { if (abortController.signal.aborted || res.writableEnded || res.destroyed) return; res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); if (typeof res.flush === 'function') res.flush(); }; // ── Fetch from Serper ──────────────────────────────────────────── let serperData; try { const raw = JSON.stringify({ q: query.trim(), gl, hl, num, }); const serperResponse = await fetch('https://google.serper.dev/images', { method: 'POST', headers: { 'X-API-KEY': apiKey, 'Content-Type': 'application/json', }, body: raw, signal: abortController.signal, }); if (!serperResponse.ok) { let errMessage = `${serperResponse.status} ${serperResponse.statusText}`; try { const errBody = await serperResponse.json(); if (errBody?.message) errMessage = errBody.message; } catch { /* ignore json parse failure */ } sseWrite('error', { error: `Serper API error: ${errMessage}` }); return res.end(); } serperData = await serperResponse.json(); } catch (err) { if (abortController.signal.aborted) return; sseWrite('error', { error: `Failed to fetch images from Serper: ${err.message}` }); return res.end(); } const rawImages = Array.isArray(serperData?.images) ? serperData.images : []; // ── 1. Emit list of search results (id, url, width, height) ───── const images = rawImages.map((img, id) => ({ id, url: img.imageUrl, width: img.imageWidth, height: img.imageHeight, })); sseWrite('images', { images }); if (images.length === 0) { sseWrite('done', { total: 0 }); return res.end(); } // ── 2. Stream WebP thumbnails (id, dataUrl) ────────────────────── let cursor = 0; async function worker() { while (cursor < rawImages.length) { if (abortController.signal.aborted) break; const id = cursor++; const img = rawImages[id]; try { const thumb = await generateWebpThumbnail(img, { signal: abortController.signal, preferSource, }); sseWrite('thumb', { id, dataUrl: thumb.dataUrl, }); } catch { if (abortController.signal.aborted) break; sseWrite('thumb', { id, dataUrl: null, }); } } } const workers = Array.from( { length: Math.min(CONCURRENCY, rawImages.length) }, () => worker() ); await Promise.all(workers); if (!abortController.signal.aborted && !res.writableEnded) { sseWrite('done', { total: rawImages.length }); res.end(); } }; /** * @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' }); } try { const result = await crawlAndProcessImage(rawUrl); 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) { 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}/serper/image`, handleImageCrop); }