← back to Handbag Auth Nextjs

scripts/snapshotPrices.ts

232 lines

// Daily price snapshot collection script
// Collects current prices from all RSS items and stores historical data

import fs from "fs";
import path from "path";
import crypto from "crypto";
import type { PriceSnapshot, HandbagPriceHistory } from "../src/types/priceHistory";
import type { HandbagFeedItem } from "../src/types/handbagRss";

const SNAPSHOTS_FILE = path.resolve("data/price_snapshots.json");
const RSS_ITEMS_FILE = path.resolve("data/handbag_rss_items.json");

function loadRssItems(): HandbagFeedItem[] {
  if (!fs.existsSync(RSS_ITEMS_FILE)) return [];
  return JSON.parse(fs.readFileSync(RSS_ITEMS_FILE, "utf8"));
}

function loadSnapshots(): PriceSnapshot[] {
  if (!fs.existsSync(SNAPSHOTS_FILE)) return [];
  return JSON.parse(fs.readFileSync(SNAPSHOTS_FILE, "utf8"));
}

function saveSnapshots(snapshots: PriceSnapshot[]) {
  const dir = path.dirname(SNAPSHOTS_FILE);
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }
  fs.writeFileSync(SNAPSHOTS_FILE, JSON.stringify(snapshots, null, 2));
}

function makeSnapshotId(feedItem: HandbagFeedItem, date: string): string {
  const base = `${feedItem.link}-${date}`;
  return crypto.createHash("md5").update(base).digest("hex");
}

// Generate handbag ID from brand + model
// In production, this should match against your actual handbag database
function makeHandbagId(item: HandbagFeedItem): string {
  const brand = item.brand || "unknown";
  const model = item.model || item.title.slice(0, 30);
  return crypto
    .createHash("md5")
    .update(`${brand}-${model}`.toLowerCase())
    .digest("hex")
    .slice(0, 12);
}

function collectSnapshots() {
  const items = loadRssItems();
  const existingSnapshots = loadSnapshots();
  const today = new Date().toISOString().split("T")[0]; // "2025-11-15"
  const now = new Date().toISOString();

  // Create map of existing snapshots by ID
  const existingById = new Map(existingSnapshots.map((s) => [s.id, s]));

  let newCount = 0;

  console.log(`Collecting price snapshots for ${today}...`);

  for (const item of items) {
    // Only snapshot items with prices
    if (!item.price) continue;

    const snapshotId = makeSnapshotId(item, today);

    // Skip if already snapshotted today
    if (existingById.has(snapshotId)) {
      continue;
    }

    // Map condition to valid PriceSnapshot condition
    let condition: "new" | "used" | "auction" = "used";
    if (item.condition === "new") condition = "new";
    else if (item.condition === "auction") condition = "auction";

    const snapshot: PriceSnapshot = {
      id: snapshotId,
      handbagId: makeHandbagId(item),
      merchantId: item.sourceId,
      merchantName: item.sourceId.split("-")[0], // e.g. "fashionphile-new" -> "fashionphile"
      condition,
      price: item.price,
      currency: item.currency || "USD",
      url: item.link,
      snapshotDate: today,
      timestamp: now,
      inStock: true, // Assume in stock if found in RSS feed
      notes: item.summary?.slice(0, 200),
    };

    existingSnapshots.push(snapshot);
    existingById.set(snapshot.id, snapshot);
    newCount++;
  }

  if (newCount > 0) {
    saveSnapshots(existingSnapshots);
  }

  console.log(`\n=== Price Snapshot Summary ===`);
  console.log(`Date: ${today}`);
  console.log(`New snapshots: ${newCount}`);
  console.log(`Total snapshots: ${existingSnapshots.length}`);

  // Show some stats
  const todaySnapshots = existingSnapshots.filter((s) => s.snapshotDate === today);
  const avgPrice =
    todaySnapshots.reduce((sum, s) => sum + s.price, 0) / todaySnapshots.length;

  console.log(`Today's snapshots: ${todaySnapshots.length}`);
  console.log(`Average price: ${todaySnapshots[0]?.currency || "$"}${avgPrice.toFixed(2)}`);

  return newCount;
}

function analyzeHistory(handbagId: string): HandbagPriceHistory | null {
  const snapshots = loadSnapshots();
  const handbagSnapshots = snapshots.filter((s) => s.handbagId === handbagId);

  if (handbagSnapshots.length === 0) {
    return null;
  }

  // Sort by date
  handbagSnapshots.sort((a, b) => a.snapshotDate.localeCompare(b.snapshotDate));

  const prices = handbagSnapshots.map((s) => s.price);
  const currentLowestPrice = Math.min(...prices);
  const allTimeLowestPrice = Math.min(...prices);
  const allTimeHighestPrice = Math.max(...prices);
  const averagePrice = prices.reduce((a, b) => a + b, 0) / prices.length;

  // Calculate 30-day change
  const now = new Date();
  const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
  const recentSnapshots = handbagSnapshots.filter(
    (s) => new Date(s.snapshotDate) >= thirtyDaysAgo
  );

  let priceChange30d: number | undefined;
  if (recentSnapshots.length >= 2) {
    const oldPrice = recentSnapshots[0].price;
    const newPrice = recentSnapshots[recentSnapshots.length - 1].price;
    priceChange30d = ((newPrice - oldPrice) / oldPrice) * 100;
  }

  // Sample brand/model from first snapshot
  const sample = handbagSnapshots[0];

  return {
    handbagId,
    brand: sample.merchantName, // TODO: extract actual brand from data
    model: "Model", // TODO: extract from matched handbag
    snapshots: handbagSnapshots,
    currentLowestPrice,
    allTimeLowestPrice,
    allTimeHighestPrice,
    averagePrice,
    priceChange30d,
  };
}

// CLI commands
const isMainModule = import.meta.url === `file://${process.argv[1]}`;

if (isMainModule) {
  const command = process.argv[2];

  switch (command) {
    case "collect":
      collectSnapshots();
      break;

    case "analyze": {
      const handbagId = process.argv[3];
      if (!handbagId) {
        console.error("Usage: npm run price-history analyze <handbagId>");
        process.exit(1);
      }

      const history = analyzeHistory(handbagId);
      if (!history) {
        console.error(`No price history found for handbag: ${handbagId}`);
        process.exit(1);
      }

      console.log(`\n=== Price History: ${history.brand} ${history.model} ===`);
      console.log(`Total snapshots: ${history.snapshots.length}`);
      console.log(`Current lowest: $${history.currentLowestPrice}`);
      console.log(`All-time low: $${history.allTimeLowestPrice}`);
      console.log(`All-time high: $${history.allTimeHighestPrice}`);
      console.log(`Average: $${history.averagePrice?.toFixed(2)}`);
      if (history.priceChange30d) {
        console.log(
          `30-day change: ${history.priceChange30d > 0 ? "+" : ""}${history.priceChange30d.toFixed(1)}%`
        );
      }
      break;
    }

    case "stats": {
      const snapshots = loadSnapshots();
      const uniqueDates = new Set(snapshots.map((s) => s.snapshotDate));
      const uniqueHandbags = new Set(snapshots.map((s) => s.handbagId));

      console.log(`\n=== Price Snapshot Statistics ===`);
      console.log(`Total snapshots: ${snapshots.length}`);
      console.log(`Unique handbags tracked: ${uniqueHandbags.size}`);
      console.log(`Days of data: ${uniqueDates.size}`);

      const sortedDates = Array.from(uniqueDates).sort();
      if (sortedDates.length > 0) {
        console.log(`Date range: ${sortedDates[0]} - ${sortedDates[sortedDates.length - 1]}`);
      }
      break;
    }

    default:
      console.log("Daily Price Snapshot Tool");
      console.log("\nUsage:");
      console.log("  ts-node scripts/snapshotPrices.ts <command>");
      console.log("\nCommands:");
      console.log("  collect            - Collect today's price snapshots from RSS");
      console.log("  analyze <id>       - Analyze price history for a handbag");
      console.log("  stats              - Show snapshot statistics");
      process.exit(1);
  }
}

export { collectSnapshots, analyzeHistory };