Files
dashback/src/api/routes/serper.js
2026-10-28 01:46:51 +01:00

472 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { optimizeImage } from 'wasm-image-optimization';
import * as dbCompanySetting from '../../db/companysetting.js';
import { crawlAndProcessImage } from '../../services/imageCrop.js';
// #region Helpers
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 meta = await optimizeImage({ image: buffer, format: 'none' });
const srcW = meta.originalWidth || 0;
const srcH = meta.originalHeight || 0;
const needsResize = srcW > 200 || srcH > 200;
const scale = needsResize ? Math.min(200 / srcW, 200 / srcH) : 1;
const thumb = await optimizeImage({
image: buffer,
width: needsResize ? Math.max(1, Math.round(srcW * scale)) : undefined,
height: needsResize ? Math.max(1, Math.round(srcH * scale)) : undefined,
fit: 'fill',
format: 'webp',
quality: 80,
});
const thumbBuffer = Buffer.from(thumb.data);
const dataUrl = `data:image/webp;base64,${thumbBuffer.toString('base64')}`;
return {
dataUrl,
width: thumb.width,
height: thumb.height,
size: thumbBuffer.length,
};
} catch (err) {
lastError = err;
}
}
throw lastError || new Error('Failed to load thumbnail');
}
// #endregion
/**
* 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`, 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'
*/
// #region POST /serper/images
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?.trim() || null;
} catch (err) {
console.warn('Serper images: failed to read company setting keys.serper:', err?.message || err);
}
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();
}
};
// #endregion
/**
* @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'
*/
// #region POST /serper/image
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 });
}
};
// #endregion
// #region POST /serper/images (registration)
app.post(`${basePath}/serper/images`, handleImagesSearch);
// #endregion
// #region POST /serper/image (registration)
app.post(`${basePath}/serper/image`, handleImageCrop);
// #endregion
}