Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
48 lines
2.6 KiB
TypeScript
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;
|
|
}
|