src / cache / WebpageCache.ts
src / cache / WebpageCache.ts
import { Logger } from "../utils/Logger";
export const DEFAULT_CACHE_MAX_MB = 30;
export const DEFAULT_CACHE_TTL_MINUTES = 30;
export interface ExtractedWebpageMetadata {
site?: string;
title?: string;
author?: string;
publishedDate?: string;
language?: string;
excerpt?: string;
}
export const WEBPAGE_CACHE_VERSION = "1.1.0";
export interface ImageMarker {
src: string;
alt?: string;
caption?: string;
insertionIndex: number;
}
export interface ExtractedWebpage {
url: string;
metadata: ExtractedWebpageMetadata;
fullMarkdown: string;
cleanMarkdown?: string;
totalChars: number;
isSparse?: boolean;
rssFeedUrl?: string;
rawHtmlLength?: number;
readabilityLength?: number;
usedJsonLd?: boolean;
usedOg?: boolean;
usedRss?: boolean;
tablesDetected?: boolean;
pdfPageCount?: number;
imageMarkers?: ImageMarker[];
imagesFoundCount?: number;
imagesFilteredCount?: number;
imagesDownloadedCount?: number;
downloadedFilenames?: string[];
cacheVersion?: string;
}
export interface CachedEntry {
url: string;
canonicalUrl: string;
data: ExtractedWebpage;
sizeBytes: number;
createdAt: number;
expiresAt: number;
}
export interface CacheOptions {
maxSizeMb?: number;
ttlMinutes?: number;
enabled?: boolean;
}
export interface CacheStats {
entries: number;
size_mb: number;
max_size_mb: number;
hit_count: number;
miss_count: number;
evictions: number;
}
/**
* Canonicalizes a URL by parsing it and stripping common tracking query parameters.
*/
export function canonicalizeUrl(urlStr: string): string {
try {
const urlObj = new URL(urlStr);
const trackingParams = [
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content",
"fbclid",
"gclid",
"msclkid",
"ref",
];
for (const param of trackingParams) {
urlObj.searchParams.delete(param);
}
// Sort remaining query params for consistent canonicalization
urlObj.searchParams.sort();
return urlObj.toString();
} catch {
return urlStr;
}
}
/**
* Calculates exact UTF-8 byte size of an extracted webpage payload.
*/
export function calculateEntrySizeBytes(data: ExtractedWebpage): number {
const markdownBytes = Buffer.byteLength(data.fullMarkdown || "", "utf-8");
const metadataBytes = Buffer.byteLength(JSON.stringify(data.metadata || {}), "utf-8");
const urlBytes = Buffer.byteLength(data.url || "", "utf-8");
return markdownBytes + metadataBytes + urlBytes;
}
/**
* In-memory Byte-Bounded LRU Cache for extracted webpages.
*/
export class WebpageCache {
private map: Map<string, CachedEntry> = new Map();
private currentSizeBytes: number = 0;
public readonly maxSizeBytes: number;
public readonly ttlMs: number;
public readonly enabled: boolean;
private hitCount: number = 0;
private missCount: number = 0;
private evictionCount: number = 0;
constructor(options: CacheOptions = {}) {
const maxMb = options.maxSizeMb ?? DEFAULT_CACHE_MAX_MB;
const ttlMin = options.ttlMinutes ?? DEFAULT_CACHE_TTL_MINUTES;
this.maxSizeBytes = maxMb * 1024 * 1024;
this.ttlMs = ttlMin * 60 * 1000;
this.enabled = options.enabled ?? true;
}
/**
* Retrieves cached webpage by URL. Refreshes LRU position on hit.
*/
public get(url: string): ExtractedWebpage | null {
if (!this.enabled) {
return null;
}
const key = canonicalizeUrl(url);
const entry = this.map.get(key);
if (!entry) {
this.missCount++;
return null;
}
// Check expiration or cache version mismatch
if (Date.now() > entry.expiresAt || entry.data.cacheVersion !== WEBPAGE_CACHE_VERSION) {
this.deleteEntry(key);
this.missCount++;
return null;
}
// Refresh LRU insertion order
this.map.delete(key);
this.map.set(key, entry);
this.hitCount++;
return entry.data;
}
/**
* Stores extracted webpage in cache, enforcing memory limits and LRU eviction.
*/
public set(url: string, data: ExtractedWebpage): void {
if (!this.enabled) {
return;
}
data.cacheVersion = WEBPAGE_CACHE_VERSION;
const canonicalUrl = canonicalizeUrl(url);
const entrySizeBytes = calculateEntrySizeBytes(data);
// Safeguard: Do not cache single item exceeding total cache capacity
if (entrySizeBytes > this.maxSizeBytes) {
Logger.debug(
"[WebpageReader]",
`Skipping cache for '${url}': entry size (${(entrySizeBytes / 1024 / 1024).toFixed(
2
)} MB) exceeds maximum cache capacity (${(this.maxSizeBytes / 1024 / 1024).toFixed(2)} MB)`
);
return;
}
// Purge expired entries first
this.purgeExpired();
// If item already exists, subtract its size before updating
if (this.map.has(canonicalUrl)) {
this.deleteEntry(canonicalUrl);
}
// Evict LRU entries until space is available
while (this.currentSizeBytes + entrySizeBytes > this.maxSizeBytes && this.map.size > 0) {
const oldestKey = this.map.keys().next().value;
if (oldestKey) {
this.deleteEntry(oldestKey);
this.evictionCount++;
} else {
break;
}
}
const now = Date.now();
const entry: CachedEntry = {
url,
canonicalUrl,
data,
sizeBytes: entrySizeBytes,
createdAt: now,
expiresAt: now + this.ttlMs,
};
this.map.set(canonicalUrl, entry);
this.currentSizeBytes += entrySizeBytes;
}
/**
* Returns current cache performance metrics and resource usage statistics.
*/
public stats(): CacheStats {
this.purgeExpired();
return {
entries: this.map.size,
size_mb: Number((this.currentSizeBytes / (1024 * 1024)).toFixed(2)),
max_size_mb: Number((this.maxSizeBytes / (1024 * 1024)).toFixed(2)),
hit_count: this.hitCount,
miss_count: this.missCount,
evictions: this.evictionCount,
};
}
/**
* Clears all entries from the cache.
*/
public clear(): void {
this.map.clear();
this.currentSizeBytes = 0;
}
private deleteEntry(key: string): void {
const entry = this.map.get(key);
if (entry) {
this.currentSizeBytes -= entry.sizeBytes;
if (this.currentSizeBytes < 0) this.currentSizeBytes = 0;
this.map.delete(key);
}
}
private purgeExpired(): void {
const now = Date.now();
for (const [key, entry] of this.map.entries()) {
if (now > entry.expiresAt) {
this.deleteEntry(key);
}
}
}
}
export const defaultWebpageCache = new WebpageCache();
import { Logger } from "../utils/Logger";
export const DEFAULT_CACHE_MAX_MB = 30;
export const DEFAULT_CACHE_TTL_MINUTES = 30;
export interface ExtractedWebpageMetadata {
site?: string;
title?: string;
author?: string;
publishedDate?: string;
language?: string;
excerpt?: string;
}
export const WEBPAGE_CACHE_VERSION = "1.1.0";
export interface ImageMarker {
src: string;
alt?: string;
caption?: string;
insertionIndex: number;
}
export interface ExtractedWebpage {
url: string;
metadata: ExtractedWebpageMetadata;
fullMarkdown: string;
cleanMarkdown?: string;
totalChars: number;
isSparse?: boolean;
rssFeedUrl?: string;
rawHtmlLength?: number;
readabilityLength?: number;
usedJsonLd?: boolean;
usedOg?: boolean;
usedRss?: boolean;
tablesDetected?: boolean;
pdfPageCount?: number;
imageMarkers?: ImageMarker[];
imagesFoundCount?: number;
imagesFilteredCount?: number;
imagesDownloadedCount?: number;
downloadedFilenames?: string[];
cacheVersion?: string;
}
export interface CachedEntry {
url: string;
canonicalUrl: string;
data: ExtractedWebpage;
sizeBytes: number;
createdAt: number;
expiresAt: number;
}
export interface CacheOptions {
maxSizeMb?: number;
ttlMinutes?: number;
enabled?: boolean;
}
export interface CacheStats {
entries: number;
size_mb: number;
max_size_mb: number;
hit_count: number;
miss_count: number;
evictions: number;
}
/**
* Canonicalizes a URL by parsing it and stripping common tracking query parameters.
*/
export function canonicalizeUrl(urlStr: string): string {
try {
const urlObj = new URL(urlStr);
const trackingParams = [
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content",
"fbclid",
"gclid",
"msclkid",
"ref",
];
for (const param of trackingParams) {
urlObj.searchParams.delete(param);
}
// Sort remaining query params for consistent canonicalization
urlObj.searchParams.sort();
return urlObj.toString();
} catch {
return urlStr;
}
}
/**
* Calculates exact UTF-8 byte size of an extracted webpage payload.
*/
export function calculateEntrySizeBytes(data: ExtractedWebpage): number {
const markdownBytes = Buffer.byteLength(data.fullMarkdown || "", "utf-8");
const metadataBytes = Buffer.byteLength(JSON.stringify(data.metadata || {}), "utf-8");
const urlBytes = Buffer.byteLength(data.url || "", "utf-8");
return markdownBytes + metadataBytes + urlBytes;
}
/**
* In-memory Byte-Bounded LRU Cache for extracted webpages.
*/
export class WebpageCache {
private map: Map<string, CachedEntry> = new Map();
private currentSizeBytes: number = 0;
public readonly maxSizeBytes: number;
public readonly ttlMs: number;
public readonly enabled: boolean;
private hitCount: number = 0;
private missCount: number = 0;
private evictionCount: number = 0;
constructor(options: CacheOptions = {}) {
const maxMb = options.maxSizeMb ?? DEFAULT_CACHE_MAX_MB;
const ttlMin = options.ttlMinutes ?? DEFAULT_CACHE_TTL_MINUTES;
this.maxSizeBytes = maxMb * 1024 * 1024;
this.ttlMs = ttlMin * 60 * 1000;
this.enabled = options.enabled ?? true;
}
/**
* Retrieves cached webpage by URL. Refreshes LRU position on hit.
*/
public get(url: string): ExtractedWebpage | null {
if (!this.enabled) {
return null;
}
const key = canonicalizeUrl(url);
const entry = this.map.get(key);
if (!entry) {
this.missCount++;
return null;
}
// Check expiration or cache version mismatch
if (Date.now() > entry.expiresAt || entry.data.cacheVersion !== WEBPAGE_CACHE_VERSION) {
this.deleteEntry(key);
this.missCount++;
return null;
}
// Refresh LRU insertion order
this.map.delete(key);
this.map.set(key, entry);
this.hitCount++;
return entry.data;
}
/**
* Stores extracted webpage in cache, enforcing memory limits and LRU eviction.
*/
public set(url: string, data: ExtractedWebpage): void {
if (!this.enabled) {
return;
}
data.cacheVersion = WEBPAGE_CACHE_VERSION;
const canonicalUrl = canonicalizeUrl(url);
const entrySizeBytes = calculateEntrySizeBytes(data);
// Safeguard: Do not cache single item exceeding total cache capacity
if (entrySizeBytes > this.maxSizeBytes) {
Logger.debug(
"[WebpageReader]",
`Skipping cache for '${url}': entry size (${(entrySizeBytes / 1024 / 1024).toFixed(
2
)} MB) exceeds maximum cache capacity (${(this.maxSizeBytes / 1024 / 1024).toFixed(2)} MB)`
);
return;
}
// Purge expired entries first
this.purgeExpired();
// If item already exists, subtract its size before updating
if (this.map.has(canonicalUrl)) {
this.deleteEntry(canonicalUrl);
}
// Evict LRU entries until space is available
while (this.currentSizeBytes + entrySizeBytes > this.maxSizeBytes && this.map.size > 0) {
const oldestKey = this.map.keys().next().value;
if (oldestKey) {
this.deleteEntry(oldestKey);
this.evictionCount++;
} else {
break;
}
}
const now = Date.now();
const entry: CachedEntry = {
url,
canonicalUrl,
data,
sizeBytes: entrySizeBytes,
createdAt: now,
expiresAt: now + this.ttlMs,
};
this.map.set(canonicalUrl, entry);
this.currentSizeBytes += entrySizeBytes;
}
/**
* Returns current cache performance metrics and resource usage statistics.
*/
public stats(): CacheStats {
this.purgeExpired();
return {
entries: this.map.size,
size_mb: Number((this.currentSizeBytes / (1024 * 1024)).toFixed(2)),
max_size_mb: Number((this.maxSizeBytes / (1024 * 1024)).toFixed(2)),
hit_count: this.hitCount,
miss_count: this.missCount,
evictions: this.evictionCount,
};
}
/**
* Clears all entries from the cache.
*/
public clear(): void {
this.map.clear();
this.currentSizeBytes = 0;
}
private deleteEntry(key: string): void {
const entry = this.map.get(key);
if (entry) {
this.currentSizeBytes -= entry.sizeBytes;
if (this.currentSizeBytes < 0) this.currentSizeBytes = 0;
this.map.delete(key);
}
}
private purgeExpired(): void {
const now = Date.now();
for (const [key, entry] of this.map.entries()) {
if (now > entry.expiresAt) {
this.deleteEntry(key);
}
}
}
}
export const defaultWebpageCache = new WebpageCache();