[object Object]

← back to Prestige Car Wash

Add scripts: db schema, gen-media (Nano Banana + SeeDance, dry-run gated), places-read, ig-pull, competitors-scan, best-times; fix gen-media --only off-by-one

b94f67053a4133374ffed9b52ca63780490d1392 · 2026-07-04 13:36:06 -0700 · Steve

Files touched

Diff

commit b94f67053a4133374ffed9b52ca63780490d1392
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Jul 4 13:36:06 2026 -0700

    Add scripts: db schema, gen-media (Nano Banana + SeeDance, dry-run gated), places-read, ig-pull, competitors-scan, best-times; fix gen-media --only off-by-one
---
 scripts/best-times.js       |  26 ++++++++
 scripts/competitors-scan.js |  68 +++++++++++++++++++
 scripts/db-init.sql         |  41 ++++++++++++
 scripts/gen-media.js        | 155 ++++++++++++++++++++++++++++++++++++++++++++
 scripts/ig-pull.js          |  42 ++++++++++++
 scripts/places-read.js      |  61 +++++++++++++++++
 6 files changed, 393 insertions(+)

diff --git a/scripts/best-times.js b/scripts/best-times.js
new file mode 100644
index 0000000..f3f910f
--- /dev/null
+++ b/scripts/best-times.js
@@ -0,0 +1,26 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * best-times.js — validate + (optionally) recompute the best-times recommendations.
+ *
+ * The demand/post-time windows in data/best-times.json are curated from car-wash
+ * seasonality + local SFV signal. This script validates the shape and prints a summary
+ * so the admin "Best Times" tabs always have well-formed data. Extend later to pull
+ * real IG/GBP insights once owner tokens exist.
+ */
+const fs = require('fs');
+const path = require('path');
+const FILE = path.join(__dirname, '..', 'data', 'best-times.json');
+
+const bt = JSON.parse(fs.readFileSync(FILE, 'utf8'));
+const days = bt.traffic?.by_day || [];
+const peak = days.filter(d => d.demand >= 5).map(d => d.day);
+const slow = days.filter(d => d.demand <= 2).map(d => d.day);
+
+console.log('⏰ Best-times summary');
+console.log(`  peak demand days:  ${peak.join(', ') || '—'}`);
+console.log(`  slow days (B2B/mobile fill): ${slow.join(', ') || '—'}`);
+console.log(`  post platforms configured: ${Object.keys(bt.post_times || {}).filter(k => k !== 'note').join(', ')}`);
+console.log(`  spend triggers: ${(bt.market_times?.triggers || []).length}`);
+if (!days.length) { console.error('  ✖ traffic.by_day is empty'); process.exit(1); }
+console.log('  ✔ best-times.json valid');
diff --git a/scripts/competitors-scan.js b/scripts/competitors-scan.js
new file mode 100644
index 0000000..db6e78e
--- /dev/null
+++ b/scripts/competitors-scan.js
@@ -0,0 +1,68 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * competitors-scan.js — refresh the SFV car-wash competitor set.
+ *
+ * Two paths:
+ *   1) If GOOGLE_PLACES_API_KEY is set, run a Places "Nearby/Text Search" for car washes
+ *      across SFV and merge new finds into data/competitors.json (id/name/city/rating/reviews).
+ *   2) Ad-platform signals (who's running Google/Meta/TikTok ads) come from the Claude-side
+ *      `advertising-signals` / `ad-social-tracker` skills — this script leaves ad_platforms
+ *      for that enrichment step and does not fabricate it.
+ *
+ * The seeded competitor set already reflects live Exa research; this keeps it fresh.
+ * Usage: node scripts/competitors-scan.js
+ */
+const fs = require('fs');
+const path = require('path');
+const FILE = path.join(__dirname, '..', 'data', 'competitors.json');
+
+function keyFrom(name) {
+  if (process.env[name]) return process.env[name];
+  try {
+    const m = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8')
+      .match(new RegExp('^' + name + '=(.+)$', 'm'));
+    return m ? m[1].trim() : '';
+  } catch { return ''; }
+}
+const KEY = keyFrom('GOOGLE_PLACES_API_KEY');
+const SFV = ['Sherman Oaks', 'Van Nuys', 'Encino', 'Northridge', 'Studio City', 'Reseda', 'Woodland Hills', 'North Hollywood', 'Tarzana'];
+const slug = s => 'cmp-' + s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 32);
+
+async function textSearch(city) {
+  const r = await fetch('https://places.googleapis.com/v1/places:searchText', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', 'X-Goog-Api-Key': KEY,
+      'X-Goog-FieldMask': 'places.displayName,places.formattedAddress,places.rating,places.userRatingCount,places.websiteUri' },
+    body: JSON.stringify({ textQuery: `car wash in ${city}, CA` })
+  });
+  if (!r.ok) throw new Error(`Places ${r.status}`);
+  return ((await r.json()).places || []);
+}
+
+async function main() {
+  const existing = JSON.parse(fs.readFileSync(FILE, 'utf8'));
+  if (!KEY) {
+    console.log('⚠ GOOGLE_PLACES_API_KEY not set — cannot auto-discover new competitors.');
+    console.log(`  Current seeded set: ${existing.length} competitors (from Exa research).`);
+    console.log('  For ad-platform signals, run the `advertising-signals` skill per competitor domain.');
+    return;
+  }
+  const byName = new Map(existing.map(c => [c.name.toLowerCase(), c]));
+  let added = 0;
+  for (const city of SFV) {
+    try {
+      for (const p of await textSearch(city)) {
+        const name = p.displayName?.text; if (!name || byName.has(name.toLowerCase())) continue;
+        const row = { id: slug(name), name, city, address: p.formattedAddress || '', format: '',
+          rating: p.rating || null, reviews: p.userRatingCount || 0, price_signal: '', notes: '',
+          ad_platforms: [], website: p.websiteUri || '', source: 'places-scan',
+          created_at: new Date().toISOString() };
+        existing.push(row); byName.set(name.toLowerCase(), row); added++;
+      }
+    } catch (e) { console.log(`  ✖ ${city}: ${e.message}`); }
+  }
+  fs.writeFileSync(FILE, JSON.stringify(existing, null, 2));
+  console.log(`✔ competitors: +${added} new (total ${existing.length})`);
+}
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/db-init.sql b/scripts/db-init.sql
new file mode 100644
index 0000000..f5a8ff2
--- /dev/null
+++ b/scripts/db-init.sql
@@ -0,0 +1,41 @@
+-- Prestige Car Wash — Postgres schema (optional backend; app runs on data/*.json otherwise).
+-- Every table carries created_at (feeds the admin date+time chip) + source provenance.
+
+CREATE TABLE IF NOT EXISTS pcw_services (
+  id text PRIMARY KEY, name text NOT NULL, category text, price numeric, price_display text,
+  duration_min int, blurb text, highlights jsonb DEFAULT '[]', media_still text, media_clip text,
+  ig_photo text, featured boolean DEFAULT false, sort_order int DEFAULT 99,
+  source text, created_at timestamptz DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS pcw_competitors (
+  id text PRIMARY KEY, name text NOT NULL, city text, address text, format text,
+  rating numeric, reviews int, price_signal text, notes text, ad_platforms jsonb DEFAULT '[]',
+  website text, source text, created_at timestamptz DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS pcw_suggestions (
+  id text PRIMARY KEY, title text NOT NULL, category text, impact int, effort int, rationale text,
+  source text, created_at timestamptz DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS pcw_holidays (
+  id text PRIMARY KEY, name text NOT NULL, date date, campaign text, channels jsonb DEFAULT '[]',
+  lead_days int, source text, created_at timestamptz DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS pcw_directories (
+  id text PRIMARY KEY, name text NOT NULL, type text, priority text, status text, url text, notes text,
+  source text, created_at timestamptz DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS pcw_ad_campaigns (
+  id text PRIMARY KEY, platform text, type text, status text, budget_month numeric, targeting text,
+  keywords jsonb DEFAULT '[]', headline text, body text, source text, created_at timestamptz DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS pcw_best_times (
+  id serial PRIMARY KEY, payload jsonb NOT NULL, source text, created_at timestamptz DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS pcw_media (
+  id serial PRIMARY KEY, service_id text REFERENCES pcw_services(id), kind text, path text,
+  model text, cost_usd numeric, source text, created_at timestamptz DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS pcw_leads (
+  id serial PRIMARY KEY, name text, phone text, email text, vehicle text, service text,
+  preferred text, message text, source text, created_at timestamptz DEFAULT now()
+);
diff --git a/scripts/gen-media.js b/scripts/gen-media.js
new file mode 100644
index 0000000..6f4f46a
--- /dev/null
+++ b/scripts/gen-media.js
@@ -0,0 +1,155 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * gen-media.js — wash/wax service media pipeline.
+ *   Nano Banana (Gemini 2.5 Flash Image)  ->  brand-consistent hero STILL
+ *        └─ that still is the first frame handed to ->
+ *   SeeDance (bytedance/seedance-1-* on Replicate)  ->  5s image-to-video CLIP
+ *
+ * SAFETY: dry-run by default (spends $0). Real generation requires `--go`.
+ *   Running the paid pilot batch is Steve-gated — get the go before --go.
+ *
+ * Usage:
+ *   node scripts/gen-media.js                 # dry run: plan + cost estimate
+ *   node scripts/gen-media.js --stills        # dry run limited to stills
+ *   node scripts/gen-media.js --go            # REAL generation (spends money)
+ *   node scripts/gen-media.js --go --lite     # use seedance-1-lite (cheaper)
+ *   node scripts/gen-media.js --go --only svc-hand-wash,svc-wax-seal
+ *
+ * Keys: read from process.env, falling back to the master secrets .env (read-only).
+ */
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.join(__dirname, '..');
+const MEDIA = path.join(ROOT, 'media');
+const LEDGER = path.join(process.env.HOME, '.claude', 'cost-ledger.jsonl');
+
+// ---- key resolution (env first, then master secrets .env) ------------------
+function keyFrom(name) {
+  if (process.env[name]) return process.env[name];
+  try {
+    const master = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
+    const m = master.match(new RegExp('^' + name + '=(.+)$', 'm'));
+    return m ? m[1].trim() : '';
+  } catch { return ''; }
+}
+const GEMINI = keyFrom('GEMINI_API_KEY');
+const REPLICATE = keyFrom('REPLICATE_API_TOKEN');
+
+// ---- args ------------------------------------------------------------------
+const argv = process.argv.slice(2);
+const GO = argv.includes('--go');
+const LITE = argv.includes('--lite');
+const STILLS_ONLY = argv.includes('--stills');
+const onlyIdx = argv.indexOf('--only');
+const onlyArg = onlyIdx >= 0 ? (argv[onlyIdx + 1] || '').split(',').filter(Boolean) : [];
+
+// Pricing (approx, for estimate + ledger)
+const COST_STILL = 0.039;                    // Gemini 2.5 Flash Image per image
+const COST_CLIP = LITE ? 0.09 : 0.30;        // SeeDance lite vs pro, ~5s
+const SEEDANCE_MODEL = LITE ? 'bytedance/seedance-1-lite' : 'bytedance/seedance-1-pro';
+
+// Brand prompt scaffold — keeps every asset on-brand (deep navy, chrome, cinematic).
+const BRAND = 'Cinematic, photorealistic, professional automotive detailing shot, deep navy and chrome palette, dramatic studio lighting, glossy reflections, shallow depth of field, premium car-wash brand aesthetic, no text, no logos, no watermark';
+const PROMPTS = {
+  'svc-express-wash':  'a clean sedan under a spray of water and white foam at an express car wash bay',
+  'svc-full-service':  'a detailer vacuuming and wiping the interior of a car, water beading on the exterior',
+  'svc-hand-wash':     'gloved hands washing a luxury car with a foam cannon and two buckets, thick suds sliding down the door',
+  'svc-wax-seal':      'a microfiber applicator spreading ceramic wax on a glossy black hood, water beading into perfect droplets',
+  'svc-interior-detail':'close-up of a steam cleaner and brush restoring a leather car seat and dashboard to like-new',
+  'svc-ceramic-coating':'a technician applying 9H ceramic coating to a sports car panel, rainbow sheen on the fresh coat',
+  'svc-headlight':     'before-and-after of a foggy yellow headlight being polished clear, crisp and bright',
+  'svc-complete-detail':'a fully detailed car in a showroom, mirror-like paint, spotless wheels, glowing under lights'
+};
+
+const services = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'services.json'), 'utf8'));
+let targets = services.filter(s => PROMPTS[s.id]);
+if (onlyArg.length) targets = targets.filter(s => onlyArg.includes(s.id));
+
+function logCost(model, units, cost, note) {
+  try {
+    fs.mkdirSync(path.dirname(LEDGER), { recursive: true });
+    fs.appendFileSync(LEDGER, JSON.stringify({
+      ts: new Date().toISOString(), project: 'prestige-car-wash', model, units, cost_usd: +cost.toFixed(4), note
+    }) + '\n');
+  } catch {}
+}
+
+async function nanoBananaStill(svc) {
+  const prompt = `${PROMPTS[svc.id]}. ${BRAND}`;
+  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${GEMINI}`;
+  const r = await fetch(url, {
+    method: 'POST', headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] })
+  });
+  if (!r.ok) throw new Error(`Gemini ${r.status}: ${(await r.text()).slice(0, 200)}`);
+  const j = await r.json();
+  const part = (j.candidates?.[0]?.content?.parts || []).find(p => p.inlineData);
+  if (!part) throw new Error('no image in Gemini response');
+  const buf = Buffer.from(part.inlineData.data, 'base64');
+  const out = path.join(MEDIA, `${svc.id}.png`);
+  fs.writeFileSync(out, buf);
+  logCost('gemini-2.5-flash-image', 1, COST_STILL, svc.id);
+  return { path: out, dataUri: `data:image/png;base64,${part.inlineData.data}` };
+}
+
+async function seedanceClip(svc, imageDataUri) {
+  const motion = `Smooth cinematic push-in, water and foam moving realistically, ${PROMPTS[svc.id]}`;
+  // Replicate model-based prediction with polling.
+  let pred = await (await fetch(`https://api.replicate.com/v1/models/${SEEDANCE_MODEL}/predictions`, {
+    method: 'POST',
+    headers: { 'Authorization': `Bearer ${REPLICATE}`, 'Content-Type': 'application/json', 'Prefer': 'wait' },
+    body: JSON.stringify({ input: { image: imageDataUri, prompt: motion, duration: 5 } })
+  })).json();
+  const started = Date.now();
+  while (pred.status && !['succeeded', 'failed', 'canceled'].includes(pred.status)) {
+    if (Date.now() - started > 300000) throw new Error('SeeDance timeout');
+    await new Promise(r => setTimeout(r, 3000));
+    pred = await (await fetch(pred.urls.get, { headers: { 'Authorization': `Bearer ${REPLICATE}` } })).json();
+  }
+  if (pred.status !== 'succeeded') throw new Error(`SeeDance ${pred.status}: ${pred.error || ''}`);
+  const videoUrl = Array.isArray(pred.output) ? pred.output[0] : pred.output;
+  const buf = Buffer.from(await (await fetch(videoUrl)).arrayBuffer());
+  const out = path.join(MEDIA, `${svc.id}.mp4`);
+  fs.writeFileSync(out, buf);
+  logCost(SEEDANCE_MODEL, 1, COST_CLIP, svc.id);
+  return { path: out };
+}
+
+async function main() {
+  fs.mkdirSync(MEDIA, { recursive: true });
+  const nStills = targets.length;
+  const nClips = STILLS_ONLY ? 0 : targets.length;
+  const est = nStills * COST_STILL + nClips * COST_CLIP;
+
+  console.log(`\n🎬 Prestige media pilot  (${SEEDANCE_MODEL})`);
+  console.log(`   services: ${targets.map(t => t.id).join(', ')}`);
+  console.log(`   stills:   ${nStills} × $${COST_STILL} = $${(nStills * COST_STILL).toFixed(2)}`);
+  console.log(`   clips:    ${nClips} × $${COST_CLIP} = $${(nClips * COST_CLIP).toFixed(2)}`);
+  console.log(`   EST TOTAL: $${est.toFixed(2)}`);
+  console.log(`   keys: GEMINI ${GEMINI ? 'ok' : 'MISSING'} · REPLICATE ${REPLICATE ? 'ok' : 'MISSING'}`);
+
+  if (!GO) {
+    console.log(`\n   DRY RUN — no spend. Re-run with --go to generate (Steve-gated).\n`);
+    return;
+  }
+  if (!GEMINI || !REPLICATE) { console.error('\n   ✖ missing key(s) — route via /secrets first.\n'); process.exit(1); }
+
+  let spent = 0;
+  for (const svc of targets) {
+    try {
+      process.stdout.write(`   • ${svc.id}: still…`);
+      const still = await nanoBananaStill(svc); spent += COST_STILL;
+      process.stdout.write(` ok  ($${spent.toFixed(2)})`);
+      if (!STILLS_ONLY) {
+        process.stdout.write(` · clip…`);
+        await seedanceClip(svc, still.dataUri); spent += COST_CLIP;
+        process.stdout.write(` ok  ($${spent.toFixed(2)})`);
+      }
+      console.log('');
+    } catch (e) { console.log(`  ✖ ${e.message}`); }
+  }
+  console.log(`\n   ✔ done. Actual spend ≈ $${spent.toFixed(2)} (logged to cost-ledger.jsonl)\n`);
+}
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/ig-pull.js b/scripts/ig-pull.js
new file mode 100644
index 0000000..6d89bfe
--- /dev/null
+++ b/scripts/ig-pull.js
@@ -0,0 +1,42 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * ig-pull.js — pull public media for the services gallery from Instagram @prestigecwash.
+ *
+ * Reality check: Instagram blocks unauthenticated scraping; a reliable pull needs the
+ * business-owner Graph API token (deferred — Steve doesn't have logins wired yet).
+ * This script attempts the public oEmbed/profile path best-effort and, on failure,
+ * leaves the service `ig_photo` fields empty so the site falls back to generated media.
+ *
+ * Usage: node scripts/ig-pull.js  [postUrl1 postUrl2 ...]
+ *   With explicit public post URLs it uses IG oEmbed (most reliable public route).
+ */
+const handle = process.env.IG_HANDLE || 'prestigecwash';
+const postUrls = process.argv.slice(2);
+
+async function oembed(url) {
+  try {
+    const r = await fetch(`https://api.instagram.com/oembed/?url=${encodeURIComponent(url)}`);
+    if (!r.ok) return null;
+    const j = await r.json();
+    return { url, thumbnail: j.thumbnail_url, title: j.title };
+  } catch { return null; }
+}
+
+async function main() {
+  console.log(`📸 IG pull for @${handle}`);
+  if (!postUrls.length) {
+    console.log('  No post URLs given. Owner Graph API token is needed for a full profile pull.');
+    console.log('  Once the owner token is available, wire the Graph media endpoint here.');
+    console.log('  For now, service cards use Nano Banana / SeeDance generated media.');
+    return;
+  }
+  const results = [];
+  for (const u of postUrls) {
+    const o = await oembed(u);
+    console.log(o ? `  ✔ ${u} -> ${o.thumbnail}` : `  ✖ ${u} (blocked / private)`);
+    if (o) results.push(o);
+  }
+  console.log(`  pulled ${results.length}/${postUrls.length}`);
+}
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/places-read.js b/scripts/places-read.js
new file mode 100644
index 0000000..7ee333b
--- /dev/null
+++ b/scripts/places-read.js
@@ -0,0 +1,61 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * places-read.js — read-only pull of the Google Business listing via the Places API.
+ * Writes data/places.json (hours, rating, reviews, photos). No writes to Google.
+ *
+ * Needs GOOGLE_PLACES_API_KEY + PCW_PLACE_ID (Steve-gated: adding the key + billing).
+ * Without them it leaves the mock in place and explains what to do.
+ */
+const fs = require('fs');
+const path = require('path');
+const OUT = path.join(__dirname, '..', 'data', 'places.json');
+
+function keyFrom(name) {
+  if (process.env[name]) return process.env[name];
+  try {
+    const m = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8')
+      .match(new RegExp('^' + name + '=(.+)$', 'm'));
+    return m ? m[1].trim() : '';
+  } catch { return ''; }
+}
+const KEY = keyFrom('GOOGLE_PLACES_API_KEY');
+const PLACE = process.env.PCW_PLACE_ID || keyFrom('PCW_PLACE_ID');
+
+async function main() {
+  if (!KEY || !PLACE) {
+    console.log('⚠ GOOGLE_PLACES_API_KEY / PCW_PLACE_ID not set — keeping mock data/places.json.');
+    console.log('  Gated setup: enable Places API + billing on Google Cloud, then `/secrets` the key.');
+    return;
+  }
+  // Places API (New) Place Details
+  const url = `https://places.googleapis.com/v1/places/${PLACE}`;
+  const r = await fetch(url, {
+    headers: {
+      'X-Goog-Api-Key': KEY,
+      'X-Goog-FieldMask': 'displayName,formattedAddress,nationalPhoneNumber,rating,userRatingCount,regularOpeningHours,websiteUri,googleMapsUri,reviews,photos'
+    }
+  });
+  if (!r.ok) { console.error(`Places API ${r.status}: ${(await r.text()).slice(0, 200)}`); process.exit(1); }
+  const p = await r.json();
+  const out = {
+    _mock: false,
+    name: p.displayName?.text || 'Prestige Car Wash',
+    address: p.formattedAddress || '',
+    phone: p.nationalPhoneNumber || '',
+    rating: p.rating || null,
+    reviews_count: p.userRatingCount || 0,
+    hours: p.regularOpeningHours?.weekdayDescriptions || [],
+    website: p.websiteUri || '',
+    maps_url: p.googleMapsUri || '',
+    place_id: PLACE,
+    reviews: (p.reviews || []).slice(0, 5).map(rv => ({
+      author: rv.authorAttribution?.displayName || '', rating: rv.rating, text: rv.text?.text || ''
+    })),
+    photos: (p.photos || []).slice(0, 6).map(ph => ph.name),
+    updated_at: new Date().toISOString()
+  };
+  fs.writeFileSync(OUT, JSON.stringify(out, null, 2));
+  console.log(`✔ wrote data/places.json — ${out.name}, ${out.rating}★ (${out.reviews_count})`);
+}
+main().catch(e => { console.error(e); process.exit(1); });

← 4a6ce21 PCW scaffold: marketing site + growth admin (10 buckets), se  ·  back to Prestige Car Wash  ·  Fix CSP: allow inline event-handler attrs (script-src-attr) 4f61ddf →