← 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
A scripts/best-times.jsA scripts/competitors-scan.jsA scripts/db-init.sqlA scripts/gen-media.jsA scripts/ig-pull.jsA scripts/places-read.js
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 →