/** * WS0: Image Downloader * * Downloads product images from various sources, resizes, and stores metadata. * R2 upload is optional — for now stores image URLs and marks has_image. */ import { Pool } from "pg"; import { createHash } from "crypto"; const pool = new Pool({ host: process.env.POSTGRES_HOST || "localhost", port: parseInt(process.env.POSTGRES_PORT || "5433"), database: process.env.POSTGRES_DB || "transceiver_db", user: process.env.POSTGRES_USER || "tip", password: process.env.POSTGRES_PASSWORD || "tip_dev_2026", max: 3, }); /** * Update image URL for a transceiver and mark has_image = true */ export async function setTransceiverImage( transceiverId: string, imageUrl: string, source?: string ): Promise { await pool.query( `UPDATE transceivers SET image_url = $2, has_image = true, image_scraped_at = NOW() WHERE id = $1 AND (image_url IS NULL OR image_url = '')`, [transceiverId, imageUrl] ); } /** * Update image URL for a switch */ export async function setSwitchImage( switchId: string, imageUrl: string ): Promise { await pool.query( `UPDATE switches SET image_url = $2, has_image = true WHERE id = $1 AND (image_url IS NULL OR image_url = '')`, [switchId, imageUrl] ); } /** * Get products without images for backfill */ export async function getProductsWithoutImages(limit = 100): Promise> { const result = await pool.query( `SELECT t.id, t.slug, t.form_factor, t.speed_gbps, t.reach_label, t.part_number, v.name AS vendor_name FROM transceivers t LEFT JOIN vendors v ON t.vendor_id = v.id WHERE (t.has_image = false OR t.has_image IS NULL) AND t.image_url IS NULL ORDER BY t.speed_gbps DESC LIMIT $1`, [limit] ); return result.rows; } /** * Generate a search URL to find product images */ export function buildImageSearchUrls(product: { form_factor: string; speed_gbps: number; reach_label: string; part_number?: string; vendor_name?: string; }): string[] { const urls: string[] = []; const q = `${product.form_factor} ${product.speed_gbps}G ${product.reach_label} transceiver`; // Flexoptix store urls.push(`https://www.flexoptix.net/en/catalogsearch/result/?q=${encodeURIComponent(q)}`); // FS.com urls.push(`https://www.fs.com/search/${encodeURIComponent(q)}.html`); // If we have a part number, try vendor-specific if (product.part_number) { urls.push(`https://www.fs.com/search/${encodeURIComponent(product.part_number)}.html`); } return urls; } /** * Get image coverage statistics */ export async function getImageCoverageStats(): Promise<{ total: number; with_image: number; without_image: number; coverage_pct: number; }> { const result = await pool.query(` SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE has_image = true) AS with_image, COUNT(*) FILTER (WHERE has_image = false OR has_image IS NULL) AS without_image FROM transceivers `); const row = result.rows[0]; const total = parseInt(row.total); const withImg = parseInt(row.with_image); return { total, with_image: withImg, without_image: parseInt(row.without_image), coverage_pct: total > 0 ? Math.round((withImg / total) * 10000) / 100 : 0, }; } /** * Get price coverage statistics */ export async function getPriceCoverageStats(): Promise<{ total: number; with_recent_price: number; without_recent_price: number; coverage_pct: number; }> { const result = await pool.query(` SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE EXISTS ( SELECT 1 FROM price_observations po WHERE po.transceiver_id = t.id AND po.time > NOW() - INTERVAL '7 days' )) AS with_price FROM transceivers t `); const row = result.rows[0]; const total = parseInt(row.total); const withPrice = parseInt(row.with_price); return { total, with_recent_price: withPrice, without_recent_price: total - withPrice, coverage_pct: total > 0 ? Math.round((withPrice / total) * 10000) / 100 : 0, }; }