This commit is contained in:
jjbrunton committed 2026-01-04 19:16:31 +00:00
commit c552956516
23 files changed
+5193

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
import axios from 'axios';
export interface DownloadedImage {
buffer: Buffer;
mimeType: string;
}
export async function downloadImage(url: string, userAgent: string): Promise<DownloadedImage> {
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 30000,
headers: {
'User-Agent': userAgent,
Accept: 'image/*',
},
maxRedirects: 5,
});
const contentType = response.headers['content-type'] || 'image/jpeg';
return {
buffer: Buffer.from(response.data),
mimeType: contentType.split(';')[0].trim(),
};
}
export function bufferToBase64(buffer: Buffer): string {
return buffer.toString('base64');
}
export function isValidImageMimeType(mimeType: string): boolean {
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
return validTypes.includes(mimeType);
}
+32
View File
@@ -0,0 +1,32 @@
import winston from 'winston';
export type Logger = winston.Logger;
export function createLogger(): Logger {
return winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ level, message, timestamp, ...meta }) => {
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
return `${timestamp} [${level}]: ${message}${metaStr}`;
})
),
}),
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
}),
new winston.transports.File({
filename: 'logs/combined.log',
}),
],
});
}
+66
View File
@@ -0,0 +1,66 @@
import PQueue from 'p-queue';
import type { Config } from '../config/index.js';
import type { Logger } from './logger.js';
export class RateLimiter {
private queue: PQueue;
private lastMessageTime: number = 0;
private messagesThisHour: number = 0;
private hourStartTime: number = Date.now();
constructor(
private config: Config,
private logger: Logger
) {
this.queue = new PQueue({ concurrency: 1 });
}
async throttle<T>(fn: () => Promise<T>): Promise<T> {
return this.queue.add(async () => {
const now = Date.now();
// Reset hourly counter if needed
if (now - this.hourStartTime > 3600000) {
this.messagesThisHour = 0;
this.hourStartTime = now;
this.logger.debug('Hourly rate limit counter reset');
}
// Check hourly limit
if (this.messagesThisHour >= this.config.rateLimit.maxMessagesPerHour) {
const waitTime = 3600000 - (now - this.hourStartTime);
this.logger.info(`Hourly limit reached, waiting ${Math.round(waitTime / 60000)} minutes`);
await this.sleep(waitTime);
this.messagesThisHour = 0;
this.hourStartTime = Date.now();
}
// Enforce minimum delay with jitter
const timeSinceLastMessage = now - this.lastMessageTime;
const minDelay = this.config.rateLimit.minDelayBetweenMessagesMs;
const jitter = Math.random() * this.config.rateLimit.jitterMs;
if (this.lastMessageTime > 0 && timeSinceLastMessage < minDelay) {
const waitTime = minDelay - timeSinceLastMessage + jitter;
this.logger.debug(`Rate limiting: waiting ${Math.round(waitTime / 1000)} seconds`);
await this.sleep(waitTime);
}
this.lastMessageTime = Date.now();
this.messagesThisHour++;
return fn();
}) as Promise<T>;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
getStats(): { messagesThisHour: number; minutesSinceLastMessage: number } {
return {
messagesThisHour: this.messagesThisHour,
minutesSinceLastMessage: Math.round((Date.now() - this.lastMessageTime) / 60000),
};
}
}
+28
View File
@@ -0,0 +1,28 @@
import pRetry from 'p-retry';
import type { Logger } from './logger.js';
export async function withRetry<T>(
fn: () => Promise<T>,
options: {
retries?: number;
logger?: Logger;
operationName?: string;
} = {}
): Promise<T> {
const { retries = 3, logger, operationName = 'operation' } = options;
return pRetry(fn, {
retries,
minTimeout: 5000,
maxTimeout: 60000,
factor: 2,
randomize: true,
onFailedAttempt: (context) => {
const errorMessage = context.error instanceof Error ? context.error.message : String(context.error);
logger?.warn(
`${operationName} attempt ${context.attemptNumber} failed: ${errorMessage}. ` +
`${context.retriesLeft} retries left.`
);
},
});
}