[object Object]

← back to Govarbitrage

auto-save: 2026-07-09T23:12:38 (1 files) — scripts/

ec9c8a3360201f67175d0ea02ac44857669c9394 · 2026-07-09 23:12:46 -0700 · Steve Abrams

Files touched

Diff

commit ec9c8a3360201f67175d0ea02ac44857669c9394
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 9 23:12:46 2026 -0700

    auto-save: 2026-07-09T23:12:38 (1 files) — scripts/
---
 scripts/run-digest.sh  |  19 ++++
 scripts/send-digest.ts | 235 +++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 254 insertions(+)

diff --git a/scripts/run-digest.sh b/scripts/run-digest.sh
new file mode 100755
index 0000000..7e223f6
--- /dev/null
+++ b/scripts/run-digest.sh
@@ -0,0 +1,19 @@
+#!/bin/sh
+# Wrapper for the twice-daily GovArbitrage opportunity digest (launchd-invoked).
+# Reads George's credentials from their single canonical home (~/.claude.json)
+# at runtime and exports them for this process only — never duplicated to disk.
+set -e
+cd "$(dirname "$0")/.."
+
+# Pull GEORGE_URL + GEORGE_BASIC_AUTH from the george MCP env in ~/.claude.json.
+eval "$(/usr/bin/python3 - <<'PY'
+import json, os, shlex
+p = os.path.expanduser("~/.claude.json")
+env = json.load(open(p))["mcpServers"]["george"]["env"]
+print("export GEORGE_URL=" + shlex.quote(env["GEORGE_URL"]))
+print("export GEORGE_BASIC_AUTH=" + shlex.quote(env["GEORGE_BASIC_AUTH"]))
+PY
+)"
+
+export PATH="/opt/homebrew/bin:/usr/bin:/bin:$PATH"
+exec /opt/homebrew/bin/npx tsx scripts/send-digest.ts
diff --git a/scripts/send-digest.ts b/scripts/send-digest.ts
new file mode 100644
index 0000000..c4c6c81
--- /dev/null
+++ b/scripts/send-digest.ts
@@ -0,0 +1,235 @@
+// Twice-daily "Top 10 Opportunities" email digest.
+// Ranks still-open auctions by Overall Opportunity score and emails the top 10
+// via George (the DW Gmail HTTP agent). Scheduled by launchd at 06:00 and 17:00.
+//
+// Env (from the project .env, loaded below):
+//   DATABASE_URL         - Postgres (required)
+//   GEORGE_URL           - George base URL (required to send)
+//   GEORGE_BASIC_AUTH    - base64 "user:pass" for George Basic Auth (required)
+//   DIGEST_TO            - recipient (default steve@designerwallcoverings.com)
+//   DIGEST_FROM_ACCOUNT  - George account (default steve-office)
+//   APP_URL              - base URL for listing links (default http://localhost:3737)
+//   DIGEST_DRY_RUN=1     - print the email instead of sending
+//
+// Run: npx tsx scripts/send-digest.ts
+
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+// --- minimal .env loader (don't rely on Prisma's dotenv side-effect) ----------
+function loadEnv() {
+  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
+  try {
+    const raw = readFileSync(join(root, ".env"), "utf8");
+    for (const line of raw.split("\n")) {
+      const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
+      if (!m) continue;
+      const key = m[1];
+      let val = m[2].trim();
+      if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
+        val = val.slice(1, -1);
+      }
+      if (process.env[key] === undefined) process.env[key] = val;
+    }
+  } catch {
+    /* no .env — rely on ambient env */
+  }
+}
+loadEnv();
+
+const { queryListings } = await import("../src/lib/listings");
+const { prisma } = await import("../src/lib/db");
+const { formatMoney, formatPercent, countdown } = await import("../src/lib/utils");
+
+const DIGEST_TO = process.env.DIGEST_TO || "steve@designerwallcoverings.com";
+const FROM_ACCOUNT = process.env.DIGEST_FROM_ACCOUNT || "steve-office";
+const APP_URL = process.env.APP_URL || "http://localhost:3737";
+
+const n = (d: unknown): number => (d == null ? 0 : Number(d));
+
+type Detail = NonNullable<Awaited<ReturnType<typeof fetchDetails>>>[number];
+
+/** Rank still-open auctions by Overall Opportunity and return the top-10 ids in order. */
+async function topTenIds(): Promise<string[]> {
+  const { rows } = await queryListings({
+    profile: "OVERALL_OPPORTUNITY",
+    sort: "opportunityScore",
+    dir: "desc",
+    pageSize: 200,
+  });
+  const now = Date.now();
+  return rows
+    .filter((r) => r.closingAt && new Date(r.closingAt).getTime() > now)
+    .slice(0, 10)
+    .map((r) => r.id);
+}
+
+/** Fetch full research + cost + scores for the given ids, preserving order. */
+async function fetchDetails(ids: string[]) {
+  const listings = await prisma.listing.findMany({
+    where: { id: { in: ids } },
+    include: { research: true, costBreakdown: true, scores: true },
+  });
+  const byId = new Map(listings.map((l) => [l.id, l]));
+  return ids.map((id) => byId.get(id)).filter((x): x is NonNullable<typeof x> => !!x);
+}
+
+function kv(label: string, value: string, strong = false): string {
+  return `<tr>
+    <td style="padding:2px 6px 2px 0;color:#64748b">${label}</td>
+    <td style="padding:2px 0;text-align:right;font-variant-numeric:tabular-nums${strong ? ";font-weight:700" : ""}">${value}</td>
+  </tr>`;
+}
+
+function valuationTable(r: NonNullable<Detail["research"]>): string {
+  return `<table style="border-collapse:collapse;font-size:12px;width:100%">
+    ${kv("New Retail", formatMoney(n(r.newRetail)))}
+    ${kv("New Replacement", formatMoney(n(r.newReplacement)))}
+    ${kv("Avg Retail", formatMoney(n(r.avgRetail)))}
+    ${kv("Used Low / Avg / High", `${formatMoney(n(r.usedLow))} / ${formatMoney(n(r.usedSoldPrice))} / ${formatMoney(n(r.usedHigh))}`)}
+    ${kv("Used Asking", formatMoney(n(r.usedAskingPrice)))}
+    ${kv("Wholesale", formatMoney(n(r.wholesaleValue)))}
+    ${kv("Liquidation", formatMoney(n(r.liquidationValue)))}
+    ${kv("Sell Today", formatMoney(n(r.sellTodayValue)))}
+    ${kv("7 / 30 / 90-Day", `${formatMoney(n(r.value7Day))} / ${formatMoney(n(r.value30Day))} / ${formatMoney(n(r.value90Day))}`)}
+    ${kv("Expected Sale", formatMoney(n(r.expectedSalePrice)), true)}
+    ${kv("Prob. of Sale", formatPercent(n(r.probabilityOfSale)))}
+    ${kv("Days to Sell", `${r.daysUntilSold ?? "—"}`)}
+    ${kv("Confidence", `${Math.round(n(r.confidenceScore))}/100`)}
+  </table>`;
+}
+
+function costTable(c: NonNullable<Detail["costBreakdown"]>): string {
+  return `<table style="border-collapse:collapse;font-size:12px;width:100%">
+    ${kv("Winning Bid", formatMoney(n(c.winningBid)))}
+    ${kv("Buyer Premium", formatMoney(n(c.buyerPremium)))}
+    ${kv("Sales Tax", formatMoney(n(c.salesTax)))}
+    ${kv("Shipping / Freight", `${formatMoney(n(c.shipping))} / ${formatMoney(n(c.freight))}`)}
+    ${kv("Insurance / Packing", `${formatMoney(n(c.insurance))} / ${formatMoney(n(c.packing))}`)}
+    ${kv("Pickup / Testing", `${formatMoney(n(c.pickupLabor))} / ${formatMoney(n(c.testing))}`)}
+    ${kv("Repairs / Cert.", `${formatMoney(n(c.repairs))} / ${formatMoney(n(c.certification))}`)}
+    ${kv("Mkt / Payment Fees", `${formatMoney(n(c.marketplaceFees))} / ${formatMoney(n(c.paymentFees))}`)}
+    ${kv("Storage / Photo / List", `${formatMoney(n(c.storage))} / ${formatMoney(n(c.photography))} / ${formatMoney(n(c.listingLabor))}`)}
+    ${kv("Total Investment", formatMoney(n(c.totalInvestment)), true)}
+    ${kv("Expected Returns", formatMoney(n(c.expectedReturns)))}
+    ${kv("Expected Net Profit", formatMoney(n(c.expectedNetProfit)), true)}
+    ${kv("ROI / Annualized", `${formatPercent(n(c.roi))} / ${formatPercent(n(c.annualizedReturn))}`, true)}
+    ${kv("Recommended Max Bid", formatMoney(n(c.recommendedMaxBid)), true)}
+  </table>`;
+}
+
+function scoreTable(scores: Detail["scores"]): string {
+  const overall = scores.find((s) => s.profile === "OVERALL_OPPORTUNITY") ?? scores[0];
+  if (!overall) return "";
+  const chip = (label: string, v: number) =>
+    kv(label, `${Math.round(v)}`);
+  return `<table style="border-collapse:collapse;font-size:12px;width:100%">
+    ${kv("Opportunity", `${Math.round(overall.value)}`, true)}
+    ${chip("Arbitrage", overall.arbitrage)}
+    ${chip("Demand", overall.demand)}
+    ${chip("Velocity", overall.velocity)}
+    ${chip("Logistics", overall.logistics)}
+    ${chip("Condition", overall.condition)}
+    ${chip("Competition", overall.competition)}
+    ${chip("Buyer", overall.buyer)}
+    ${kv("Risk", overall.risk)}
+    ${kv("Drop Ship", overall.dropShip)}
+  </table>
+  <div style="font-size:11px;color:#64748b;margin-top:6px">${escapeHtml(overall.explanation)}</div>`;
+}
+
+function renderItem(l: Detail, rank: number): string {
+  const closes = countdown(l.closingAt);
+  const net = formatMoney(n(l.costBreakdown?.expectedNetProfit));
+  const roi = formatPercent(n(l.costBreakdown?.roi));
+  return `<div style="border:1px solid #e2e8f0;border-radius:8px;margin:0 0 16px;padding:12px">
+    <div style="font-size:15px;font-weight:700">#${rank} · <a href="${APP_URL}/listings/${l.id}" style="color:#2563eb;text-decoration:none">${escapeHtml(l.title)}</a></div>
+    <div style="color:#64748b;font-size:12px;margin:2px 0 10px">
+      ${l.source.replace(/_/g, " ")} · #${escapeHtml(l.sourceAuctionId)} · ${escapeHtml(l.category || "—")} ·
+      Qty ${l.quantity} · ${l.condition.replace(/_/g, " ")} ·
+      ${[l.locationCity, l.locationState].filter(Boolean).join(", ") || "—"} ·
+      closes ${closes} · <b style="color:#16a34a">${net} net</b> · <b>${roi} ROI</b>
+    </div>
+    <table style="width:100%;border-collapse:collapse"><tr valign="top">
+      <td style="width:34%;padding-right:12px">
+        <div style="font-weight:600;color:#334155;font-size:12px;margin-bottom:2px">Valuation</div>
+        ${l.research ? valuationTable(l.research) : "<i>pending</i>"}
+      </td>
+      <td style="width:36%;padding-right:12px">
+        <div style="font-weight:600;color:#334155;font-size:12px;margin-bottom:2px">Cost Breakdown &amp; Profit</div>
+        ${l.costBreakdown ? costTable(l.costBreakdown) : "<i>pending</i>"}
+      </td>
+      <td style="width:30%">
+        <div style="font-weight:600;color:#334155;font-size:12px;margin-bottom:2px">Scores</div>
+        ${scoreTable(l.scores)}
+      </td>
+    </tr></table>
+  </div>`;
+}
+
+function buildHtml(items: Detail[]): string {
+  const when = new Date().toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" });
+  if (items.length === 0) {
+    return `<p>No open auction opportunities right now (${when}).</p>`;
+  }
+  return `<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#0f172a;max-width:900px">
+    <h2 style="margin:0 0 4px">GovArbitrage — Top ${items.length} Opportunities</h2>
+    <div style="color:#64748b;font-size:13px;margin-bottom:14px">${when} · ranked by Overall Opportunity · still-open auctions · detailed valuation, costs &amp; scores per item</div>
+    ${items.map((l, i) => renderItem(l, i + 1)).join("")}
+    <p style="margin-top:8px"><a href="${APP_URL}" style="color:#2563eb">Open the dashboard →</a></p>
+    <p style="color:#94a3b8;font-size:11px">Recommended max bids back-solve to a 40% target ROI. Figures are estimates — verify comps before bidding.</p>
+  </div>`;
+}
+
+function escapeHtml(s: string): string {
+  return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]!));
+}
+
+async function sendViaGeorge(subject: string, html: string) {
+  const base = process.env.GEORGE_URL;
+  const auth = process.env.GEORGE_BASIC_AUTH;
+  if (!base || !auth) throw new Error("GEORGE_URL / GEORGE_BASIC_AUTH not set");
+  const res = await fetch(`${base}/api/send`, {
+    method: "POST",
+    headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
+    body: JSON.stringify({ account: FROM_ACCOUNT, to: DIGEST_TO, subject, body: html }),
+  });
+  const text = await res.text();
+  if (!res.ok) throw new Error(`George send failed ${res.status}: ${text.slice(0, 300)}`);
+  return text;
+}
+
+async function main() {
+  const ids = await topTenIds();
+  const items = await fetchDetails(ids);
+  const hour = new Date().getHours();
+  const slot = hour < 12 ? "Morning" : "Evening";
+  const subject = `GovArbitrage — Top ${items.length} Opportunities (${slot})`;
+  const html = buildHtml(items);
+
+  if (process.env.DIGEST_DRY_RUN === "1") {
+    const { writeFileSync } = await import("node:fs");
+    writeFileSync("logs/digest-preview.html", html);
+    console.log(`[DRY RUN] to=${DIGEST_TO} subject="${subject}" (${items.length} items, ${html.length} bytes)`);
+    console.log(`Preview written to logs/digest-preview.html`);
+    items.forEach((l, i) =>
+      console.log(
+        `  ${i + 1}. ${l.title.slice(0, 40)} — net ${Math.round(n(l.costBreakdown?.expectedNetProfit))}, ROI ${Math.round(n(l.costBreakdown?.roi) * 100)}%`,
+      ),
+    );
+    await prisma.$disconnect();
+    return;
+  }
+
+  const result = await sendViaGeorge(subject, html);
+  console.log(`Sent "${subject}" to ${DIGEST_TO}: ${result.slice(0, 160)}`);
+  await prisma.$disconnect();
+}
+
+main()
+  .then(() => process.exit(0))
+  .catch((e) => {
+    console.error(`[digest] ${new Date().toISOString()} ERROR:`, e.message);
+    process.exit(1);
+  });

← 3d43a92 Phase 8: rate-limiting (in-memory sliding window) on login (  ·  back to Govarbitrage  ·  Phase 9: twice-daily Top-10 opportunity email digest with de dbc3003 →