Files
CRM-LeadRadar/apps/api/src/showcase/media.ts
T
yuriandClaude Opus 5.5 9749da5ae7 Lead Radar: исходный код
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:11:04 +04:00

48 lines
2.6 KiB
TypeScript

import { createHash } from 'node:crypto';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import sharp from 'sharp';
import type { Config } from '../lib/config.ts';
// Фото для страницы клиента (логика из бота list-am-bot, image/crop.py): фото скачивается,
// сверху срезается полоса в процентах от высоты, результат отдаётся в JPEG и кладётся на диск,
// чтобы второй раз не скачивать и не обрезать.
export type ImageFetcher = (url: string) => Promise<Buffer>;
export const fetchImage: ImageFetcher = async (url) => {
const res = await fetch(url, {
headers: { referer: 'https://www.list.am/', 'user-agent': 'Mozilla/5.0 (compatible; LeadRadar/1.0)' },
signal: AbortSignal.timeout(20_000),
});
if (!res.ok) throw new Error(`Фото не скачалось: ${res.status}`);
const data = Buffer.from(await res.arrayBuffer());
if (data.length > 15 * 1024 * 1024) throw new Error('Фото слишком большое');
return data;
};
/** Срезать сверху `percent` % высоты и вернуть JPEG. */
export async function cropTop(data: Buffer, percent: number): Promise<Buffer> {
const image = sharp(data).rotate();
const { width, height } = await image.metadata();
const cut = width && height ? Math.floor((height * percent) / 100) : 0;
const cropped = cut > 0 && cut < height! ? image.extract({ left: 0, top: cut, width: width!, height: height! - cut }) : image;
return cropped.jpeg({ quality: 88, mozjpeg: true }).toBuffer();
}
/** Обработанное фото: с диска, если уже готово, иначе скачать, обрезать и сохранить. */
export async function croppedPhoto(config: Config, url: string, fetcher: ImageFetcher = fetchImage): Promise<Buffer> {
const name = `${createHash('sha256').update(`${url}|${config.cropTopPercent}`).digest('hex').slice(0, 32)}.jpg`;
const dir = join(config.mediaDir, name.slice(0, 2));
const file = join(dir, name);
const cached = await readFile(file).catch(() => null);
if (cached) return cached;
const result = await cropTop(await fetcher(url), config.cropTopPercent);
await mkdir(dir, { recursive: true });
// Пишем во временный файл и переименовываем: параллельный запрос не увидит половину файла.
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tmp, result);
await rename(tmp, file);
return result;
}