Initial
This commit is contained in:
commit
c552956516
23 files changed
+5193
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.`
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user