Add PrismaticSync console tool for unattended Prismatic catalog sync

Standalone .NET 8 console app (not part of the main solution) that scrapes the
Prismatic Powders catalog via Playwright and pushes it into the app's catalog
import. Prismatic has no API, so this runs on a workstation (Task Scheduler),
never the deployed server.

- Discovery: incremental newest-first via ?category=created_at (stops once it
  reaches already-known URLs — cheap, finds new colors) and a full all-colors
  crawl for occasional reconcile.
- Scraper: resumable product-page scrape (sku/color/description/price tiers/
  SDS/TDS/app-guide/image), with --refresh-older-than to re-scrape stale
  products and catch price changes. Output matches the app import format so it
  flows through the same shared upsert as the Columbia sync.
- Resilience: brisk randomized base delay, escalating 403 cooldown-and-retry to
  avoid hard bans, periodic rest. All configurable.
- Visibility: streams every product + the inter-product wait to the console
  (colored) and a log file, with an up-front ETA.
- Push: token-authenticated POST to the app import endpoint (skips to manual
  upload when unconfigured).

The app-side token import endpoint is a separate follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 11:30:47 -04:00
parent f752abad86
commit c59d55529f
13 changed files with 1037 additions and 0 deletions
@@ -0,0 +1,49 @@
namespace PrismaticSync.Infrastructure;
/// <summary>
/// Minimal timestamped logger — writes to the console and appends to a rolling log file so an
/// unattended (Task Scheduler) run leaves an audit trail. Intentionally dependency-free.
/// </summary>
public static class Log
{
private static string _logFile = "prismatic-sync.log";
private static readonly object Gate = new();
public static void Configure(string logFile) => _logFile = logFile;
public static void Info(string message) => Write("INFO", message);
public static void Warn(string message) => Write("WARN", message);
public static void Error(string message) => Write("ERROR", message);
private static void Write(string level, string message)
{
var line = $"[{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}] {level,-5} {message}";
// Live console stream (visible on a manual run); color-code so warnings/errors stand out.
lock (Gate)
{
var color = level switch
{
"WARN" => ConsoleColor.Yellow,
"ERROR" => ConsoleColor.Red,
_ => (ConsoleColor?)null
};
if (color is { } c)
{
var previous = Console.ForegroundColor;
Console.ForegroundColor = c;
Console.WriteLine(line);
Console.ForegroundColor = previous;
}
else
{
Console.WriteLine(line);
}
// File trail — never let logging break a run.
try { File.AppendAllText(_logFile, line + Environment.NewLine); }
catch { /* ignore */ }
}
}
}