/** * WorthDoing.ai * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: src/lib/firecrawl/cache.ts * Description: Source/content cache — canonical URLs, content hashing, freshness window, dedup. */ import { and, desc, eq, gt } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { sources, sourceContents } from "@/lib/db/schema"; import { scrapePage, type ScrapeResult } from "./client"; import { canonicalizeUrl, sha256 } from "./url"; export { canonicalizeUrl, sha256 }; const FRESHNESS_MS = 24 * 60 * 60 * 1000; // 24h cache window export type CachedPage = { sourceId: string; contentId: string; url: string; canonicalUrl: string; title: string | null; markdown: string; contentHash: string; wordCount: number; fromCache: boolean; }; /** Upsert a source row for a URL (no content). Returns the source id. */ export async function upsertSource( url: string, meta: { title?: string | null; description?: string | null; investigationId?: string }, ): Promise<{ id: string; canonicalUrl: string }> { const canonicalUrl = canonicalizeUrl(url); const domain = new URL(canonicalUrl).hostname; const inserted = await db .insert(sources) .values({ url, canonicalUrl, domain, title: meta.title ?? null, description: meta.description ?? null, firstSeenInvestigationId: meta.investigationId ?? null, }) .onConflictDoUpdate({ target: sources.canonicalUrl, set: { title: meta.title ?? undefined, description: meta.description ?? undefined }, }) .returning({ id: sources.id }); return { id: inserted[0].id, canonicalUrl }; } /** * Scrape a page through the cache: return fresh cached content if available, * otherwise scrape via Firecrawl and persist. */ export async function scrapeWithCache(url: string, investigationId: string): Promise { const canonicalUrl = canonicalizeUrl(url); const existing = await db .select({ sourceId: sources.id, title: sources.title, contentId: sourceContents.id, markdown: sourceContents.markdown, contentHash: sourceContents.contentHash, wordCount: sourceContents.wordCount, }) .from(sources) .innerJoin(sourceContents, eq(sourceContents.sourceId, sources.id)) .where( and( eq(sources.canonicalUrl, canonicalUrl), gt(sourceContents.retrievedAt, new Date(Date.now() - FRESHNESS_MS)), ), ) .orderBy(desc(sourceContents.retrievedAt)) .limit(1); if (existing.length > 0) { const e = existing[0]; return { sourceId: e.sourceId, contentId: e.contentId, url, canonicalUrl, title: e.title, markdown: e.markdown, contentHash: e.contentHash, wordCount: e.wordCount, fromCache: true, }; } const scraped: ScrapeResult = await scrapePage(url); return persistScrapedPage(url, scraped, investigationId); } /** Persist an already-scraped page (used by scrape and crawl paths). */ export async function persistScrapedPage( url: string, scraped: ScrapeResult, investigationId: string, ): Promise { const { id: sourceId, canonicalUrl } = await upsertSource(scraped.sourceUrl || url, { title: scraped.title, description: scraped.description, investigationId, }); const contentHash = sha256(scraped.markdown); const wordCount = scraped.markdown.split(/\s+/).filter(Boolean).length; const content = await db .insert(sourceContents) .values({ sourceId, contentHash, markdown: scraped.markdown, httpStatus: scraped.statusCode, wordCount, }) .returning({ id: sourceContents.id }); return { sourceId, contentId: content[0].id, url, canonicalUrl, title: scraped.title, markdown: scraped.markdown, contentHash, wordCount, fromCache: false, }; }