From 7787c3153468def2e2f407bbc855fb035403bf43 Mon Sep 17 00:00:00 2001 From: fsu9913-gif Date: Sat, 14 Mar 2026 15:02:49 -0700 Subject: [PATCH 1/2] Create main.yml Cloudflare sites --- .github/workflows/main.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1 @@ + From d4144342e5be9218c9557e62a304669f9cc70bca Mon Sep 17 00:00:00 2001 From: fsu9913-gif Date: Sat, 9 May 2026 10:17:04 -0700 Subject: [PATCH 2/2] Copilot/all part of the plan (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add basic CI workflow with build job This workflow triggers on push and pull request events for the main branch, and can be run manually. It includes steps to check out the repository and run scripts. * Add Stockton Cloudflare Worker (cleantruckcheckstockton) Mirrors the cleantruckcheckhayward worker with Stockton-specific content: - City: Stockton, CA (geo coords 37.9577, -121.2908, zip 95202) - Service area: Stockton, Lodi, Tracy, Manteca, San Joaquin Valley - Local landmarks: Port of Stockton, I-5 corridor - Same services, pricing, phone (916-890-4427), CARB ID (IF530523) - Schema markup updated for Stockton - wrangler.toml for easy CLI deploy - GitHub Actions workflow (deploy-stockton-worker.yml) for CI/CD https://claude.ai/code/session_01AQJPh9fmajADBDexqf4wUZ * Add Silverback AI homepage and toolkit workers, clean up repo - Add Silverback AI homepage worker (workers/silverbackai/) with consulting services, pre-built solutions, and branding - Add Silverback AI toolkit worker (workers/silverbackai-toolkit/) with searchable/filterable tool catalog for legal, property, web, automation, and data tools - Remove unused files: .DS_Store, blank.yml CI template, incomplete flutter-deploy.yml - Move deploy workflows to .github/workflows/ so GitHub Actions actually triggers them - Add .gitignore for .DS_Store, node_modules, .wrangler, logs https://claude.ai/code/session_01MRgSWQ9BDdeYUYNjjeuT44 * Rebrand SilverbackAI with gorilla logo in high-vis vest Replace abstract SVG character with new brand identity: - Compact header logo: gorilla head with high-vis vest collar, purple eye glow - Full hero illustration: gorilla walking toward moon, rear view with "SILVERBACK AI" on vest back, carrying lunch pail labeled "AI" - Applied to both homepage and toolkit sites https://claude.ai/code/session_01MRgSWQ9BDdeYUYNjjeuT44 * Add Silverback AI Studio security dashboard from fsu9913-gif repo Retrieved complete React/Vite/Firebase app from fsu9913-gif/AI-Studio-for-Silverback. Security monitoring dashboard for 3875 Ruby St with Gemini AI analysis, real-time WebSocket alerts, Firebase auth, and branding studio. Intended for security.silverbackai.agency subdomain on Cloudflare. https://claude.ai/code/session_01NP44asjMUDLf5yfrZ6eU9j * Add DMC Properties rent roll dashboard from gia-silverback-claude repo Retrieved full property management dashboard for Dave Cowan's West Region properties (42 units across Oakland & SF). Includes rent tracking, CA legal notices, bank reconciliation, and market intel. https://claude.ai/code/session_01NP44asjMUDLf5yfrZ6eU9j * Add security.silverbackai.agency Cloudflare Worker Deploy a security dashboard to security.silverbackai.agency so the subdomain serves a real page instead of an error. Includes: - worker.js: Full security dashboard with system status, metrics, feature highlights, property info, and live activity log - wrangler.toml: Routes to security.silverbackai.agency/* - GitHub Actions workflow for automated deployment https://claude.ai/code/session_01AAAvg7yzhtZy9qnE3jAzap * Add CLAUDE.md for repository guidance https://claude.ai/code/session_01AAAvg7yzhtZy9qnE3jAzap * Add custom domain routes to homepage and toolkit workers - silverbackai.agency/* and www.silverbackai.agency/* → homepage - toolkit.silverbackai.agency/* → toolkit - Fixes error pages on custom domain by routing traffic to workers https://claude.ai/code/session_01AAAvg7yzhtZy9qnE3jAzap * Update SilverbackAI for launch on silverbackai.agency (#10) - Switch domain references from silverbackai.com to silverbackai.agency - Add Security AI as a 5th solution card; rename Property Manager Pro to Rent DMC Suite - Add robots.txt and sitemap.xml routes on the homepage worker - Fix font loading: replace render-blocking @import with preconnect + stylesheet link in - Fix logo link from "#" to "/" so the brand mark navigates home - Fix toolkit "Learn More" anchors from #contact to existing #request - Fix toolkit footer cross-link to use absolute silverbackai.agency URL - Add canonical, og:url, twitter:card meta tags - Add security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy) and longer cache TTLs https://claude.ai/code/session_018WuDLPmUYaxJUpkfieYY4Z Co-authored-by: Claude * Ship silverbackai.agency: fix deploy, add intake form, redesign hero (#11) Root cause: wrangler v3 rejects top-level `routes` when an `[env.production]` stanza exists. Deploys have been erroring silently for two months. Fixed by removing the dead env.production blocks and switching to custom_domain routes (auto-create DNS + SSL). Also: - Hero redesign with "What keeps you up at night?" + inline intake form - Bumped contrast, removed fake stats, removed Rent DMC Suite card - New "Live Now" section with real in-production proof - Proper worker routing: /, /robots.txt, /sitemap.xml, /api/intake, /tools/:slug, 404 - Intake form backend logs via console.log for now (wrangler tail) * Add Squarespace API key and permissions details Added Squarespace API key information including permissions. * Add Telegram bot webhook setup workflow GitHub Actions workflow to configure the Telegram bot webhook pointing to the Mila Cloud Run service. Supports set, delete, and info actions via manual dispatch. https://claude.ai/code/session_01Mh1Rkj2WQKVTeYuLXXUswF * Add norcal-toolkit — 6 Python agents for the full scrape-to-cash pipeline Lead scraper (Google Places API + scoring), cold emailer (3-touch personalized sequences), CRM tracker (pipeline + follow-ups), invoice generator (HTML invoices with fleet discounts), review/social/blog agent, daily workflow runner, and interactive job booking workflow. https://claude.ai/code/session_01Mh1Rkj2WQKVTeYuLXXUswF --------- Co-authored-by: Claude Co-authored-by: B --- .DS_Store | Bin 6148 -> 0 bytes .../deploy-security-silverbackai.yml | 23 + .../workflows/deploy-silverbackai-toolkit.yml | 23 + .github/workflows/deploy-silverbackai.yml | 23 + .github/workflows/deploy-stockton-worker.yml | 24 + .gitignore | 4 + CLAUDE.md | 53 + norcal-toolkit/01_lead_scraper.py | 395 ++++ norcal-toolkit/02_cold_emailer.py | 313 +++ norcal-toolkit/03_crm_tracker.py | 349 ++++ norcal-toolkit/04_invoice_generator.py | 464 +++++ norcal-toolkit/05_review_request.py | 352 ++++ norcal-toolkit/config.py | 96 + norcal-toolkit/daily_workflow.py | 134 ++ norcal-toolkit/job_workflow.py | 185 ++ workers/cleantruckcheckstockton/worker.js | 1311 +++++++++++++ workers/cleantruckcheckstockton/wrangler.toml | 7 + workers/dmc-properties/rent-roll.html | 1605 ++++++++++++++++ .../norcalcarbmobile/squarespace-export.xml | 11 + workers/security-silverbackai/worker.js | 974 ++++++++++ workers/security-silverbackai/wrangler.toml | 11 + workers/silverback-ai-studio/.env.example | 9 + workers/silverback-ai-studio/README.md | 30 + .../firebase-applet-config.json | 10 + .../firebase-blueprint.json | 145 ++ workers/silverback-ai-studio/firestore.rules | 84 + workers/silverback-ai-studio/index.html | 12 + workers/silverback-ai-studio/metadata.json | 5 + workers/silverback-ai-studio/package.json | 37 + workers/silverback-ai-studio/server.ts | 88 + workers/silverback-ai-studio/src/App.tsx | 1673 +++++++++++++++++ workers/silverback-ai-studio/src/firebase.ts | 12 + workers/silverback-ai-studio/src/index.css | 27 + workers/silverback-ai-studio/src/main.tsx | 10 + workers/silverback-ai-studio/tsconfig.json | 26 + workers/silverback-ai-studio/vite.config.ts | 22 + workers/silverbackai-toolkit/worker.js | 830 ++++++++ workers/silverbackai-toolkit/wrangler.toml | 9 + workers/silverbackai/worker.js | 1546 +++++++++++++++ workers/silverbackai/wrangler.toml | 11 + workflows/flutter-deploy.yml | 36 - workflows/telegram-webhook.yml | 46 + 42 files changed, 10989 insertions(+), 36 deletions(-) delete mode 100644 .DS_Store create mode 100644 .github/workflows/deploy-security-silverbackai.yml create mode 100644 .github/workflows/deploy-silverbackai-toolkit.yml create mode 100644 .github/workflows/deploy-silverbackai.yml create mode 100644 .github/workflows/deploy-stockton-worker.yml create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 norcal-toolkit/01_lead_scraper.py create mode 100644 norcal-toolkit/02_cold_emailer.py create mode 100644 norcal-toolkit/03_crm_tracker.py create mode 100644 norcal-toolkit/04_invoice_generator.py create mode 100644 norcal-toolkit/05_review_request.py create mode 100644 norcal-toolkit/config.py create mode 100644 norcal-toolkit/daily_workflow.py create mode 100644 norcal-toolkit/job_workflow.py create mode 100644 workers/cleantruckcheckstockton/worker.js create mode 100644 workers/cleantruckcheckstockton/wrangler.toml create mode 100644 workers/dmc-properties/rent-roll.html create mode 100644 workers/norcalcarbmobile/squarespace-export.xml create mode 100644 workers/security-silverbackai/worker.js create mode 100644 workers/security-silverbackai/wrangler.toml create mode 100644 workers/silverback-ai-studio/.env.example create mode 100644 workers/silverback-ai-studio/README.md create mode 100644 workers/silverback-ai-studio/firebase-applet-config.json create mode 100644 workers/silverback-ai-studio/firebase-blueprint.json create mode 100644 workers/silverback-ai-studio/firestore.rules create mode 100644 workers/silverback-ai-studio/index.html create mode 100644 workers/silverback-ai-studio/metadata.json create mode 100644 workers/silverback-ai-studio/package.json create mode 100644 workers/silverback-ai-studio/server.ts create mode 100644 workers/silverback-ai-studio/src/App.tsx create mode 100644 workers/silverback-ai-studio/src/firebase.ts create mode 100644 workers/silverback-ai-studio/src/index.css create mode 100644 workers/silverback-ai-studio/src/main.tsx create mode 100644 workers/silverback-ai-studio/tsconfig.json create mode 100644 workers/silverback-ai-studio/vite.config.ts create mode 100644 workers/silverbackai-toolkit/worker.js create mode 100644 workers/silverbackai-toolkit/wrangler.toml create mode 100644 workers/silverbackai/worker.js create mode 100644 workers/silverbackai/wrangler.toml delete mode 100644 workflows/flutter-deploy.yml create mode 100644 workflows/telegram-webhook.yml diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 2ff9b28db65fc5f0c0384905eea17920510b187b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKJ8nWT5S&dYh-fHN`U<&$h2R8S01-qJq$dSMX9rjC*VQs6!m@b5#TJNCjcF+Lp}q6Hw%7!Kn+ zdI@6l0I?U2iHy)Jsl=pOwHTIk##`m}!Z9)FuxdW6Zno-BEN)4Dld85(9_O{T(RXyu`KG&Z9uy8yj)_r@x$tuQ d97&nie9rw|I3@<2@t_m+GvK<&q`-eG@B`zl6~_Po diff --git a/.github/workflows/deploy-security-silverbackai.yml b/.github/workflows/deploy-security-silverbackai.yml new file mode 100644 index 0000000..b123be7 --- /dev/null +++ b/.github/workflows/deploy-security-silverbackai.yml @@ -0,0 +1,23 @@ +name: Deploy Security Silverback AI to Cloudflare + +on: + push: + branches: [main, master] + paths: + - 'workers/security-silverbackai/**' + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + name: Deploy security-silverbackai + steps: + - uses: actions/checkout@v4 + + - name: Deploy to Cloudflare Workers + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: bafa242dd95d3fdce72540d20accd0a2 + workingDirectory: workers/security-silverbackai + command: deploy diff --git a/.github/workflows/deploy-silverbackai-toolkit.yml b/.github/workflows/deploy-silverbackai-toolkit.yml new file mode 100644 index 0000000..0d43064 --- /dev/null +++ b/.github/workflows/deploy-silverbackai-toolkit.yml @@ -0,0 +1,23 @@ +name: Deploy Silverback AI Toolkit to Cloudflare + +on: + push: + branches: [main, master] + paths: + - 'workers/silverbackai-toolkit/**' + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + name: Deploy silverbackai-toolkit + steps: + - uses: actions/checkout@v4 + + - name: Deploy to Cloudflare Workers + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: bafa242dd95d3fdce72540d20accd0a2 + workingDirectory: workers/silverbackai-toolkit + command: deploy diff --git a/.github/workflows/deploy-silverbackai.yml b/.github/workflows/deploy-silverbackai.yml new file mode 100644 index 0000000..dfb0615 --- /dev/null +++ b/.github/workflows/deploy-silverbackai.yml @@ -0,0 +1,23 @@ +name: Deploy Silverback AI Homepage to Cloudflare + +on: + push: + branches: [main, master] + paths: + - 'workers/silverbackai/**' + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + name: Deploy silverbackai + steps: + - uses: actions/checkout@v4 + + - name: Deploy to Cloudflare Workers + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: bafa242dd95d3fdce72540d20accd0a2 + workingDirectory: workers/silverbackai + command: deploy diff --git a/.github/workflows/deploy-stockton-worker.yml b/.github/workflows/deploy-stockton-worker.yml new file mode 100644 index 0000000..9828215 --- /dev/null +++ b/.github/workflows/deploy-stockton-worker.yml @@ -0,0 +1,24 @@ +# .github/workflows/deploy-stockton-worker.yml +name: Deploy Stockton Worker to Cloudflare + +on: + push: + branches: [main, master] + paths: + - 'workers/cleantruckcheckstockton/**' + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + name: Deploy cleantruckcheckstockton + steps: + - uses: actions/checkout@v4 + + - name: Deploy to Cloudflare Workers + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: bafa242dd95d3fdce72540d20accd0a2 + workingDirectory: workers/cleantruckcheckstockton + command: deploy diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..63c69c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +node_modules/ +.wrangler/ +*.log diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c242434 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,53 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Multi-property web platform deployed on **Cloudflare Workers**, plus a standalone React security app. All workers share Cloudflare account ID `bafa242dd95d3fdce72540d20accd0a2`. + +## Architecture + +``` +workers/ +├── silverbackai/ # Marketing homepage (worker.js → HTML) +├── silverbackai-toolkit/ # AI tools catalog (worker.js → HTML) +├── security-silverbackai/ # Security dashboard (worker.js → HTML + JSON API) +├── cleantruckcheckstockton/ # CARB emissions testing service (worker.js → HTML) +├── silverback-ai-studio/ # Full React + Express security app (NOT a CF Worker) +└── dmc-properties/ # Property management dashboard (static HTML) +``` + +**Cloudflare Workers** (`silverbackai`, `silverbackai-toolkit`, `security-silverbackai`, `cleantruckcheckstockton`): Single `worker.js` files that return complete HTML inline. Each has a `wrangler.toml` for config and a GitHub Actions workflow for auto-deploy on push to `main`. + +**silverback-ai-studio**: React 19 + Express + WebSocket app deployed to Google Cloud Run via AI Studio. Uses Firebase Firestore for event storage, Google Gemini API for AI analysis, and Google Sign-In for auth. Monitors 3875 Ruby St, Oakland. + +## Deployment + +All Cloudflare Worker deploys use GitHub Actions with `cloudflare/wrangler-action@v3`. Workflows are in `.github/workflows/` and trigger on pushes to `main`/`master` scoped by path (e.g., `workers/silverbackai/**`). Requires `CLOUDFLARE_API_TOKEN` secret. + +Custom domain routing is configured in each worker's `wrangler.toml` via `routes` (e.g., `security.silverbackai.agency/*`). DNS records must be configured in Cloudflare dashboard separately. + +To manually deploy a worker: +```bash +cd workers/ +npx wrangler deploy +``` + +## Silverback AI Studio Commands + +```bash +cd workers/silverback-ai-studio +npm install +npm run dev # Start dev server (Express + Vite, port 3000) +npm run build # Production build via Vite +npm run lint # TypeScript type check (tsc --noEmit) +``` + +## Key Patterns + +- Workers return full HTML as template literals inside `fetch()` handler — all CSS is inlined +- Dark theme with purple accents (`#8b5cf6`) is the brand standard across all sites +- The security-silverbackai worker has `/api/health` and `/api/status` JSON endpoints +- Firebase admin is hardcoded to `bryan@norcalcarbmobile.com` in studio app +- Firestore rules enforce auth and role-based access (admin vs viewer) diff --git a/norcal-toolkit/01_lead_scraper.py b/norcal-toolkit/01_lead_scraper.py new file mode 100644 index 0000000..cfbfd0f --- /dev/null +++ b/norcal-toolkit/01_lead_scraper.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +""" +NorCal Carb Mobile — Lead Scraper Agent +Scrapes Google Places API for fleet/trucking/dealer leads, +scores them, deduplicates, and exports to CSV. + +Usage: + python3 01_lead_scraper.py # scrape all areas, all queries + python3 01_lead_scraper.py --area sacramento # single area + python3 01_lead_scraper.py --query "trucking" # single query + python3 01_lead_scraper.py --export squarespace # squarespace-ready CSV + +Requires: GOOGLE_PLACES_API_KEY env var +""" +import argparse +import csv +import json +import os +import sys +import time +from datetime import datetime + +import requests + +from config import ( + GOOGLE_PLACES_API_KEY, + SERVICE_AREAS, + DEFAULT_SEARCH_RADIUS_METERS, + SCRAPE_QUERIES, + SCORING, + DATA_DIR, + LEADS_CSV, +) + +# ─── Google Places API ─────────────────────────────────────────── + +PLACES_SEARCH_URL = "https://maps.googleapis.com/maps/api/place/textsearch/json" +PLACES_DETAIL_URL = "https://maps.googleapis.com/maps/api/place/details/json" + +FLEET_KEYWORDS = [ + "fleet", "truck", "trucking", "vehicle", "diesel", "haul", + "freight", "CDL", "DOT", "CARB", "emissions", "smog", + "heavy duty", "semi", "trailer", "logistics", "transport", +] + +COMPLIANCE_KEYWORDS = [ + "CARB", "smog", "emissions", "compliance", "clean truck", + "inspection", "opacity", "OBD", "HD I/M", +] + + +def search_places(query, lat, lng, radius, api_key, next_page_token=None): + """Search Google Places API. Returns results list + next_page_token.""" + params = { + "query": query, + "location": f"{lat},{lng}", + "radius": radius, + "key": api_key, + } + if next_page_token: + params = {"pagetoken": next_page_token, "key": api_key} + + resp = requests.get(PLACES_SEARCH_URL, params=params, timeout=15) + resp.raise_for_status() + data = resp.json() + + if data.get("status") not in ("OK", "ZERO_RESULTS"): + print(f" [WARN] API status: {data.get('status')} — {data.get('error_message', '')}") + + return data.get("results", []), data.get("next_page_token") + + +def get_place_details(place_id, api_key): + """Get phone, website, and extra details for a place.""" + params = { + "place_id": place_id, + "fields": "formatted_phone_number,website,opening_hours,business_status,reviews", + "key": api_key, + } + resp = requests.get(PLACES_DETAIL_URL, params=params, timeout=15) + resp.raise_for_status() + return resp.json().get("result", {}) + + +def score_lead(place, details): + """Score a lead 0-20 based on signals. Higher = hotter.""" + score = 0 + name_lower = place.get("name", "").lower() + types = place.get("types", []) + + # Review count + review_count = place.get("user_ratings_total", 0) + if review_count >= 10: + score += SCORING["high_review_count"] + + # In service area (already filtered by radius, give points) + score += SCORING["in_service_area"] + + # Has website + if details.get("website"): + score += SCORING["has_website"] + + # Fleet/truck keywords in name + if any(kw in name_lower for kw in FLEET_KEYWORDS): + score += SCORING["fleet_keywords"] + + # Compliance keywords in name or reviews + review_text = " ".join( + r.get("text", "") for r in details.get("reviews", []) + ).lower() + if any(kw.lower() in name_lower or kw.lower() in review_text for kw in COMPLIANCE_KEYWORDS): + score += SCORING["compliance_keywords"] + + # Phone available + if details.get("formatted_phone_number"): + score += SCORING["phone_available"] + + return score + + +def tier_label(score): + if score >= 8: + return "TIER 1 — CALL THIS WEEK" + elif score >= 5: + return "TIER 2 — CALL THIS MONTH" + else: + return "TIER 3 — MONTHLY LIST" + + +def load_existing_leads(): + """Load existing leads to deduplicate.""" + existing = set() + if os.path.exists(LEADS_CSV): + with open(LEADS_CSV, "r") as f: + reader = csv.DictReader(f) + for row in reader: + existing.add(row.get("place_id", "")) + return existing + + +def scrape_leads(areas=None, queries=None, api_key=None): + """Main scraper. Returns list of lead dicts.""" + if not api_key: + print("[ERROR] GOOGLE_PLACES_API_KEY not set.") + print(" Set it: export GOOGLE_PLACES_API_KEY='your-key-here'") + print(" Get one: https://console.cloud.google.com/apis/credentials") + print("") + print(" Running in DEMO MODE with sample data...") + return _demo_leads() + + areas = areas or SERVICE_AREAS + queries = queries or SCRAPE_QUERIES + existing_ids = load_existing_leads() + leads = [] + seen_ids = set() + + total_queries = len(areas) * len(queries) + query_num = 0 + + for area_name, coords in areas.items(): + for query in queries: + query_num += 1 + search_term = f"{query} near {area_name.replace('_', ' ')}, CA" + print(f" [{query_num}/{total_queries}] Searching: {search_term}") + + try: + results, next_token = search_places( + search_term, coords["lat"], coords["lng"], + DEFAULT_SEARCH_RADIUS_METERS, api_key, + ) + + # Fetch up to 2 pages (60 results per query) + all_results = results + if next_token: + time.sleep(2) # Google requires delay before next_page_token + page2, _ = search_places(None, None, None, None, api_key, next_token) + all_results += page2 + + for place in all_results: + pid = place.get("place_id", "") + if pid in seen_ids or pid in existing_ids: + continue + seen_ids.add(pid) + + # Get details (phone, website) + details = get_place_details(pid, api_key) + time.sleep(0.1) # rate limit courtesy + + score = score_lead(place, details) + tier = tier_label(score) + + lead = { + "place_id": pid, + "business_name": place.get("name", ""), + "address": place.get("formatted_address", ""), + "phone": details.get("formatted_phone_number", ""), + "website": details.get("website", ""), + "rating": place.get("rating", ""), + "review_count": place.get("user_ratings_total", 0), + "category": query, + "area": area_name, + "score": score, + "tier": tier, + "status": "NEW", + "scraped_date": datetime.now().strftime("%Y-%m-%d"), + "notes": "", + } + leads.append(lead) + + time.sleep(0.5) # rate limit between queries + + except requests.RequestException as e: + print(f" [ERROR] {search_term}: {e}") + continue + + return leads + + +def _demo_leads(): + """Demo data when no API key is set — shows the format.""" + return [ + { + "place_id": "DEMO_001", + "business_name": "Valley Fleet Services", + "address": "4521 Stockton Blvd, Sacramento, CA 95820", + "phone": "(916) 555-0101", + "website": "https://valleyfleet.example.com", + "rating": 4.2, + "review_count": 47, + "category": "fleet management", + "area": "sacramento", + "score": 11, + "tier": "TIER 1 — CALL THIS WEEK", + "status": "NEW", + "scraped_date": datetime.now().strftime("%Y-%m-%d"), + "notes": "Demo lead — replace with real API key", + }, + { + "place_id": "DEMO_002", + "business_name": "Delta Trucking Inc", + "address": "890 Navy Dr, Stockton, CA 95206", + "phone": "(209) 555-0202", + "website": "https://deltatrucking.example.com", + "rating": 3.8, + "review_count": 23, + "category": "trucking company", + "area": "stockton", + "score": 9, + "tier": "TIER 1 — CALL THIS WEEK", + "status": "NEW", + "scraped_date": datetime.now().strftime("%Y-%m-%d"), + "notes": "Demo lead", + }, + { + "place_id": "DEMO_003", + "business_name": "Roseville Auto Group", + "address": "300 Automall Dr, Roseville, CA 95661", + "phone": "(916) 555-0303", + "website": "https://rosevilleauto.example.com", + "rating": 4.5, + "review_count": 156, + "category": "auto dealer", + "area": "roseville", + "score": 7, + "tier": "TIER 2 — CALL THIS MONTH", + "status": "NEW", + "scraped_date": datetime.now().strftime("%Y-%m-%d"), + "notes": "Demo lead", + }, + { + "place_id": "DEMO_004", + "business_name": "Mike's Tow & Recovery", + "address": "1100 E Main St, Stockton, CA 95205", + "phone": "(209) 555-0404", + "website": "", + "rating": 3.2, + "review_count": 8, + "category": "tow yard", + "area": "stockton", + "score": 4, + "tier": "TIER 3 — MONTHLY LIST", + "status": "NEW", + "scraped_date": datetime.now().strftime("%Y-%m-%d"), + "notes": "Demo lead", + }, + { + "place_id": "DEMO_005", + "business_name": "Garcia Landscaping & Tree Service", + "address": "2200 Fruitridge Rd, Sacramento, CA 95822", + "phone": "(916) 555-0505", + "website": "", + "rating": 4.7, + "review_count": 31, + "category": "landscaping company", + "area": "sacramento", + "score": 6, + "tier": "TIER 2 — CALL THIS MONTH", + "status": "NEW", + "scraped_date": datetime.now().strftime("%Y-%m-%d"), + "notes": "Demo lead — likely has truck fleet", + }, + ] + + +def export_csv(leads, filepath, fmt="standard"): + """Export leads to CSV. fmt='squarespace' for email campaign import.""" + os.makedirs(os.path.dirname(filepath), exist_ok=True) + + if fmt == "squarespace": + # Squarespace email campaign CSV format + fieldnames = ["Email", "First Name", "Last Name", "Company", "Phone", "Tags"] + rows = [] + for lead in leads: + name_parts = lead["business_name"].split() + rows.append({ + "Email": "", # must be filled manually or enriched + "First Name": name_parts[0] if name_parts else "", + "Last Name": " ".join(name_parts[1:]) if len(name_parts) > 1 else "", + "Company": lead["business_name"], + "Phone": lead["phone"], + "Tags": lead["tier"].split("—")[0].strip(), + }) + else: + fieldnames = [ + "place_id", "business_name", "address", "phone", "website", + "rating", "review_count", "category", "area", "score", "tier", + "status", "scraped_date", "notes", + ] + rows = leads + + with open(filepath, "a" if os.path.exists(filepath) else "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") + if f.tell() == 0: + writer.writeheader() + writer.writerows(rows) + + return filepath + + +def print_summary(leads): + """Print a quick summary of scraped leads.""" + tier1 = [l for l in leads if l["score"] >= 8] + tier2 = [l for l in leads if 5 <= l["score"] < 8] + tier3 = [l for l in leads if l["score"] < 5] + + print(f"\n{'='*60}") + print(f" SCRAPE COMPLETE — {datetime.now().strftime('%Y-%m-%d %H:%M')}") + print(f"{'='*60}") + print(f" Total leads: {len(leads)}") + print(f" TIER 1 (8+): {len(tier1)} — CALL THIS WEEK") + print(f" TIER 2 (5-7): {len(tier2)} — CALL THIS MONTH") + print(f" TIER 3 (<5): {len(tier3)} — MONTHLY LIST") + print(f"{'='*60}") + + if tier1: + print(f"\n 🔥 HOT LEADS (Tier 1):") + for l in sorted(tier1, key=lambda x: x["score"], reverse=True): + print(f" [{l['score']}] {l['business_name']} — {l['phone']} — {l['area']}") + + print() + + +def main(): + parser = argparse.ArgumentParser(description="NorCal Lead Scraper") + parser.add_argument("--area", help="Single area to scrape (e.g., sacramento)") + parser.add_argument("--query", help="Single search query (e.g., 'trucking company')") + parser.add_argument("--export", choices=["standard", "squarespace"], default="standard", + help="CSV export format") + parser.add_argument("--output", help="Custom output file path") + args = parser.parse_args() + + areas = {args.area: SERVICE_AREAS[args.area]} if args.area else None + queries = [args.query] if args.query else None + + print(f"\n NorCal Carb Mobile — Lead Scraper") + print(f" {'='*40}") + + leads = scrape_leads(areas=areas, queries=queries, api_key=GOOGLE_PLACES_API_KEY) + + if not leads: + print(" No new leads found.") + return + + # Export + output_path = args.output or LEADS_CSV + if args.export == "squarespace": + output_path = args.output or os.path.join(DATA_DIR, "leads_squarespace.csv") + + export_csv(leads, output_path, fmt=args.export) + print_summary(leads) + print(f" Saved to: {output_path}\n") + + +if __name__ == "__main__": + main() diff --git a/norcal-toolkit/02_cold_emailer.py b/norcal-toolkit/02_cold_emailer.py new file mode 100644 index 0000000..4c57db6 --- /dev/null +++ b/norcal-toolkit/02_cold_emailer.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +""" +NorCal Carb Mobile — Cold Email Personalization & Sender Agent +Reads leads CSV, generates personalized cold email sequences, +and optionally sends via SMTP (secondary domain). + +Usage: + python3 02_cold_emailer.py --preview # preview emails, don't send + python3 02_cold_emailer.py --send --tier 1 # send to Tier 1 only + python3 02_cold_emailer.py --send --limit 10 # send to first 10 unsent + python3 02_cold_emailer.py --sequence 2 # send follow-up #2 + +IMPORTANT: Use a secondary domain for cold email (not your main domain). +""" +import argparse +import csv +import os +import smtplib +import sys +from datetime import datetime +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +from config import ( + BUSINESS_NAME, BUSINESS_PHONE, BUSINESS_WEBSITE, OWNER_NAME, + BAR_LICENSE, SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, + COLD_EMAIL_FROM, DATA_DIR, LEADS_CSV, EMAILS_LOG, PRICING, +) + +# ─── Email Templates (3-touch sequence) ───────────────────────── + +TEMPLATES = { + 1: { + "subject": "Quick question about {business_name}", + "body": """Hey {contact_name}, + +Do you handle your fleet smog and emissions testing in-house, or send vehicles out? + +I run {our_business} — we're a BAR-licensed mobile testing unit that comes on-site. No vehicle downtime, no trips to a station. We handle OBD, OVI, and smoke opacity testing right in your lot. + +Wanted to see if that's useful for {business_name}. We're in the {area} area this week. + +{owner_name} +{our_business} +{phone} +{website}""", + }, + 2: { + "subject": "Re: Quick question about {business_name}", + "body": """Hey {contact_name}, + +Bumping this up — I'm in {area} this week and have openings. + +Happy to swing by and knock out a test so you can see how the mobile setup works. No commitment, no pressure. Most shops save 2-3 hours per vehicle compared to driving to a station. + +Worth 15 minutes? + +{owner_name} +{phone}""", + }, + 3: { + "subject": "Re: Quick question about {business_name}", + "body": """Hey {contact_name}, + +Last note from me on this. If mobile smog testing ever makes sense for your operation, I'm a call away. + +Quick numbers: we test on-site for ${obd_price}/vehicle (OBD) and ${ovi_price}/vehicle (OVI). Fleets of 5+ get {fleet_discount}% off. California requires testing 2x/year now, going to 4x/year in October 2027 — the compliance load is about to double. + +If that deadline sneaks up, give me a ring. + +{owner_name} +{our_business} +{phone}""", + }, +} + +# ─── Category-specific openers ────────────────────────────────── + +CATEGORY_HOOKS = { + "trucking company": "With CARB's HD I/M program, every truck over 14,000 lbs needs testing twice a year — and that doubles to 4x in 2027.", + "fleet management": "Managing compliance across a fleet is a headache. We take that off your plate with on-site testing and deadline tracking.", + "diesel repair shop": "A lot of shops refer mobile testing to us — your customers get tested on your lot, you keep the relationship.", + "construction company": "Construction fleets are high on CARB's radar. We come to your yard so your trucks don't sit idle at a station.", + "auto dealer": "Dealers moving used heavy-duty inventory need clean test results. We come to you — no transport needed.", + "tow yard": "Tow trucks over 14,000 lbs are in the HD I/M program. Most tow operators don't know that yet.", + "body shop": "Shops that refer us for post-repair smog testing keep their bays free and their customers happy.", + "landscaping company": "Landscaping trucks and trailers over 14K lbs fall under CARB's Clean Truck Check. We test on-site.", + "school bus service": "School bus fleets need compliance testing. We come to your depot — zero disruption to routes.", + "freight broker": "Your carriers need clean compliance. We offer fleet testing they can schedule in 60 seconds.", + "logistics company": "Logistics fleets get hit hard by CARB deadlines. We test at your warehouse — no downtime.", + "property management": "If your maintenance fleet includes diesel trucks over 14K lbs, they need CARB testing.", + "waste management": "Every garbage truck and hauler over 14K lbs needs bi-annual testing. We come to your yard.", + "delivery service": "Delivery fleets with heavy-duty vehicles are in CARB's crosshairs. $10K/day fines are no joke.", +} + + +def personalize_email(template_num, lead): + """Generate a personalized email from template + lead data.""" + template = TEMPLATES[template_num] + contact = lead.get("business_name", "there").split()[0] # first word as contact name + + category = lead.get("category", "") + hook = CATEGORY_HOOKS.get(category, "") + + vars = { + "business_name": lead.get("business_name", "your business"), + "contact_name": contact, + "area": lead.get("area", "your area").replace("_", " ").title(), + "our_business": BUSINESS_NAME, + "owner_name": OWNER_NAME, + "phone": BUSINESS_PHONE, + "website": BUSINESS_WEBSITE, + "obd_price": f"{PRICING['obd_test']:.0f}", + "ovi_price": f"{PRICING['ovi_test']:.0f}", + "fleet_discount": PRICING["fleet_discount_pct"], + "category_hook": hook, + } + + subject = template["subject"].format(**vars) + body = template["body"].format(**vars) + + # Inject category hook after first paragraph in email 1 + if template_num == 1 and hook: + lines = body.split("\n\n") + if len(lines) >= 2: + lines.insert(1, hook) + body = "\n\n".join(lines) + + return subject, body + + +def load_leads(tier_filter=None, limit=None): + """Load leads from CSV, optionally filtered.""" + if not os.path.exists(LEADS_CSV): + print(f"[ERROR] No leads file found at {LEADS_CSV}") + print(" Run 01_lead_scraper.py first.") + sys.exit(1) + + leads = [] + with open(LEADS_CSV, "r") as f: + reader = csv.DictReader(f) + for row in reader: + if tier_filter: + score = int(row.get("score", 0)) + if tier_filter == 1 and score < 8: + continue + elif tier_filter == 2 and not (5 <= score < 8): + continue + elif tier_filter == 3 and score >= 5: + continue + leads.append(row) + + if limit: + leads = leads[:limit] + + return leads + + +def load_sent_log(): + """Load email send log to track what's been sent.""" + sent = {} # {place_id: {1: date, 2: date, ...}} + if os.path.exists(EMAILS_LOG): + with open(EMAILS_LOG, "r") as f: + reader = csv.DictReader(f) + for row in reader: + pid = row.get("place_id", "") + seq = int(row.get("sequence_num", 0)) + if pid not in sent: + sent[pid] = {} + sent[pid][seq] = row.get("sent_date", "") + return sent + + +def log_sent(place_id, business_name, email_to, sequence_num, subject): + """Log a sent email.""" + os.makedirs(DATA_DIR, exist_ok=True) + file_exists = os.path.exists(EMAILS_LOG) + + with open(EMAILS_LOG, "a", newline="") as f: + writer = csv.DictWriter(f, fieldnames=[ + "place_id", "business_name", "email_to", "sequence_num", + "subject", "sent_date", "status", + ]) + if not file_exists: + writer.writeheader() + writer.writerow({ + "place_id": place_id, + "business_name": business_name, + "email_to": email_to, + "sequence_num": sequence_num, + "subject": subject, + "sent_date": datetime.now().strftime("%Y-%m-%d %H:%M"), + "status": "sent", + }) + + +def send_email(to_email, subject, body, from_email=None): + """Send email via SMTP. Returns True on success.""" + if not SMTP_USER or not SMTP_PASS: + print(" [SKIP] SMTP not configured — set SMTP_USER and SMTP_PASS env vars") + return False + + from_addr = from_email or COLD_EMAIL_FROM or SMTP_USER + + msg = MIMEMultipart() + msg["From"] = f"{OWNER_NAME} <{from_addr}>" + msg["To"] = to_email + msg["Subject"] = subject + msg.attach(MIMEText(body, "plain")) + + try: + with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15) as server: + server.starttls() + server.login(SMTP_USER, SMTP_PASS) + server.send_message(msg) + return True + except Exception as e: + print(f" [ERROR] Send failed: {e}") + return False + + +def preview_emails(leads, sequence_num): + """Print email previews without sending.""" + sent_log = load_sent_log() + + print(f"\n{'='*60}") + print(f" EMAIL PREVIEW — Sequence #{sequence_num}") + print(f"{'='*60}\n") + + count = 0 + for lead in leads: + pid = lead.get("place_id", "") + already_sent = sent_log.get(pid, {}) + + if sequence_num in already_sent: + continue + + subject, body = personalize_email(sequence_num, lead) + count += 1 + + print(f" ─── To: {lead['business_name']} ({lead.get('phone', 'no phone')}) ───") + print(f" Score: {lead.get('score', '?')} | {lead.get('tier', '?')}") + print(f" Subject: {subject}") + print(f" ---") + for line in body.split("\n"): + print(f" {line}") + print(f" {'─'*50}\n") + + print(f" Total emails to send: {count}\n") + + +def send_sequence(leads, sequence_num, dry_run=False): + """Send email sequence to leads.""" + sent_log = load_sent_log() + sent_count = 0 + skip_count = 0 + + for lead in leads: + pid = lead.get("place_id", "") + already_sent = sent_log.get(pid, {}) + + # Skip if this sequence already sent + if sequence_num in already_sent: + skip_count += 1 + continue + + # Skip if previous sequence not sent yet (must go in order) + if sequence_num > 1 and (sequence_num - 1) not in already_sent: + continue + + # Need an email address to send to + # In practice, you'd enrich this from scraping or manual entry + email_to = lead.get("email", "") + if not email_to: + print(f" [SKIP] {lead['business_name']} — no email address") + continue + + subject, body = personalize_email(sequence_num, lead) + + if dry_run: + print(f" [DRY RUN] Would send to {email_to}: {subject}") + else: + success = send_email(email_to, subject, body) + if success: + log_sent(pid, lead["business_name"], email_to, sequence_num, subject) + sent_count += 1 + print(f" [SENT] {lead['business_name']} → {email_to}") + + print(f"\n Sent: {sent_count} | Skipped (already sent): {skip_count}\n") + + +def main(): + parser = argparse.ArgumentParser(description="NorCal Cold Emailer") + parser.add_argument("--preview", action="store_true", help="Preview emails without sending") + parser.add_argument("--send", action="store_true", help="Actually send emails") + parser.add_argument("--sequence", type=int, default=1, choices=[1, 2, 3], + help="Which email in the sequence (1=initial, 2=bump, 3=breakup)") + parser.add_argument("--tier", type=int, choices=[1, 2, 3], help="Filter by tier") + parser.add_argument("--limit", type=int, help="Max leads to process") + parser.add_argument("--dry-run", action="store_true", help="Log but don't actually send") + args = parser.parse_args() + + leads = load_leads(tier_filter=args.tier, limit=args.limit) + print(f"\n Loaded {len(leads)} leads" + (f" (Tier {args.tier})" if args.tier else "")) + + if args.preview or (not args.send): + preview_emails(leads, args.sequence) + elif args.send: + send_sequence(leads, args.sequence, dry_run=args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/norcal-toolkit/03_crm_tracker.py b/norcal-toolkit/03_crm_tracker.py new file mode 100644 index 0000000..7d1378e --- /dev/null +++ b/norcal-toolkit/03_crm_tracker.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +""" +NorCal Carb Mobile — Follow-Up Tracker / Mini CRM +Tracks lead status, schedules follow-ups, manages the pipeline. + +Usage: + python3 03_crm_tracker.py status # pipeline overview + python3 03_crm_tracker.py due # today's follow-ups + python3 03_crm_tracker.py add "Company" "phone" "email" "notes" + python3 03_crm_tracker.py update PLACE_ID --status contacted + python3 03_crm_tracker.py schedule PLACE_ID --date 2026-03-20 --note "call back" + python3 03_crm_tracker.py import leads.csv # import from scraper + python3 03_crm_tracker.py export google # export for Google Sheets +""" +import argparse +import csv +import os +import sys +from datetime import datetime, timedelta + +from config import DATA_DIR, LEADS_CSV, CRM_CSV, FOLLOWUP_SCHEDULE + +# ─── CRM Fields ────────────────────────────────────────────────── + +CRM_FIELDS = [ + "place_id", "business_name", "contact_name", "phone", "email", + "address", "website", "category", "score", "tier", + "status", # NEW, CONTACTED, RESPONDED, SCHEDULED, TESTED, INVOICED, PAID, LOST + "last_contact", # date of last outreach + "next_followup", # date of next scheduled follow-up + "contact_count", # number of times contacted + "notes", # running notes + "created_date", + "updated_date", +] + +VALID_STATUSES = [ + "NEW", "CONTACTED", "RESPONDED", "SCHEDULED", + "TESTED", "INVOICED", "PAID", "LOST", +] + + +def load_crm(): + """Load CRM data from CSV.""" + records = [] + if os.path.exists(CRM_CSV): + with open(CRM_CSV, "r") as f: + reader = csv.DictReader(f) + for row in reader: + records.append(row) + return records + + +def save_crm(records): + """Save CRM data to CSV.""" + os.makedirs(DATA_DIR, exist_ok=True) + with open(CRM_CSV, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=CRM_FIELDS, extrasaction="ignore") + writer.writeheader() + writer.writerows(records) + + +def find_record(records, identifier): + """Find a record by place_id or business name (partial match).""" + for r in records: + if r.get("place_id") == identifier: + return r + if identifier.lower() in r.get("business_name", "").lower(): + return r + return None + + +def import_from_leads(leads_file=None): + """Import leads from scraper CSV into CRM.""" + src = leads_file or LEADS_CSV + if not os.path.exists(src): + print(f"[ERROR] File not found: {src}") + sys.exit(1) + + crm = load_crm() + existing_ids = {r["place_id"] for r in crm} + imported = 0 + + with open(src, "r") as f: + reader = csv.DictReader(f) + for row in reader: + pid = row.get("place_id", "") + if pid in existing_ids: + continue + + now = datetime.now().strftime("%Y-%m-%d") + crm.append({ + "place_id": pid, + "business_name": row.get("business_name", ""), + "contact_name": "", + "phone": row.get("phone", ""), + "email": "", + "address": row.get("address", ""), + "website": row.get("website", ""), + "category": row.get("category", ""), + "score": row.get("score", "0"), + "tier": row.get("tier", ""), + "status": "NEW", + "last_contact": "", + "next_followup": now, # follow up today + "contact_count": "0", + "notes": row.get("notes", ""), + "created_date": now, + "updated_date": now, + }) + imported += 1 + existing_ids.add(pid) + + save_crm(crm) + print(f" Imported {imported} new leads into CRM ({len(crm)} total)") + + +def show_status(): + """Show pipeline status overview.""" + crm = load_crm() + if not crm: + print(" CRM is empty. Run: python3 03_crm_tracker.py import") + return + + # Count by status + status_counts = {} + for r in crm: + s = r.get("status", "NEW") + status_counts[s] = status_counts.get(s, 0) + 1 + + # Count by tier + tier_counts = {"TIER 1": 0, "TIER 2": 0, "TIER 3": 0} + for r in crm: + tier = r.get("tier", "") + for t in tier_counts: + if t in tier: + tier_counts[t] += 1 + + print(f"\n{'='*60}") + print(f" PIPELINE STATUS — {datetime.now().strftime('%Y-%m-%d')}") + print(f"{'='*60}") + print(f" Total leads: {len(crm)}") + print() + + # Status pipeline + pipeline_order = ["NEW", "CONTACTED", "RESPONDED", "SCHEDULED", "TESTED", "INVOICED", "PAID", "LOST"] + for s in pipeline_order: + count = status_counts.get(s, 0) + if count > 0: + bar = "#" * min(count, 40) + print(f" {s:12s} [{count:3d}] {bar}") + + print(f"\n By Tier:") + for t, c in tier_counts.items(): + print(f" {t}: {c}") + + # Revenue + tested = status_counts.get("TESTED", 0) + status_counts.get("INVOICED", 0) + status_counts.get("PAID", 0) + paid = status_counts.get("PAID", 0) + print(f"\n Jobs completed: {tested}") + print(f" Paid: {paid}") + print(f"{'='*60}\n") + + +def show_due(): + """Show follow-ups due today or overdue.""" + crm = load_crm() + today = datetime.now().strftime("%Y-%m-%d") + + due = [] + overdue = [] + for r in crm: + next_fu = r.get("next_followup", "") + if not next_fu or r.get("status") in ("PAID", "LOST"): + continue + if next_fu <= today: + if next_fu < today: + overdue.append(r) + else: + due.append(r) + + print(f"\n{'='*60}") + print(f" FOLLOW-UPS DUE — {today}") + print(f"{'='*60}") + + if overdue: + print(f"\n OVERDUE ({len(overdue)}):") + for r in sorted(overdue, key=lambda x: x.get("score", "0"), reverse=True): + print(f" [{r.get('score', '?')}] {r['business_name']:30s} {r.get('phone', ''):15s} " + f"status={r['status']} due={r['next_followup']} contacts={r.get('contact_count', 0)}") + + if due: + print(f"\n DUE TODAY ({len(due)}):") + for r in sorted(due, key=lambda x: x.get("score", "0"), reverse=True): + print(f" [{r.get('score', '?')}] {r['business_name']:30s} {r.get('phone', ''):15s} " + f"status={r['status']} contacts={r.get('contact_count', 0)}") + + if not due and not overdue: + print(" Nothing due today. Go make some calls anyway.") + + print(f"{'='*60}\n") + + +def add_lead(name, phone="", email="", notes=""): + """Manually add a lead to CRM.""" + crm = load_crm() + now = datetime.now().strftime("%Y-%m-%d") + + new_id = f"MANUAL_{datetime.now().strftime('%Y%m%d%H%M%S')}" + crm.append({ + "place_id": new_id, + "business_name": name, + "contact_name": "", + "phone": phone, + "email": email, + "address": "", + "website": "", + "category": "manual", + "score": "5", + "tier": "TIER 2 — CALL THIS MONTH", + "status": "NEW", + "last_contact": "", + "next_followup": now, + "contact_count": "0", + "notes": notes, + "created_date": now, + "updated_date": now, + }) + + save_crm(crm) + print(f" Added: {name} ({phone})") + + +def update_lead(identifier, status=None, note=None, contact_name=None, email=None, schedule_date=None): + """Update a lead's status, notes, or schedule next follow-up.""" + crm = load_crm() + record = find_record(crm, identifier) + + if not record: + print(f" [ERROR] Lead not found: {identifier}") + return + + now = datetime.now().strftime("%Y-%m-%d") + + if status: + if status.upper() not in VALID_STATUSES: + print(f" [ERROR] Invalid status: {status}. Valid: {', '.join(VALID_STATUSES)}") + return + record["status"] = status.upper() + + # Auto-set next follow-up based on contact count + if status.upper() == "CONTACTED": + count = int(record.get("contact_count", 0)) + 1 + record["contact_count"] = str(count) + record["last_contact"] = now + + # Schedule next follow-up from cadence + if count <= len(FOLLOWUP_SCHEDULE): + days = FOLLOWUP_SCHEDULE[count - 1] + record["next_followup"] = (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d") + else: + record["next_followup"] = (datetime.now() + timedelta(days=30)).strftime("%Y-%m-%d") + + if contact_name: + record["contact_name"] = contact_name + + if email: + record["email"] = email + + if note: + existing = record.get("notes", "") + record["notes"] = f"{existing} | [{now}] {note}" if existing else f"[{now}] {note}" + + if schedule_date: + record["next_followup"] = schedule_date + + record["updated_date"] = now + save_crm(crm) + print(f" Updated: {record['business_name']} → status={record['status']}, next={record.get('next_followup', 'none')}") + + +def export_google(): + """Export CRM in a format ready for Google Sheets import.""" + crm = load_crm() + output = os.path.join(DATA_DIR, "crm_google_sheets.csv") + + with open(output, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=CRM_FIELDS, extrasaction="ignore") + writer.writeheader() + writer.writerows(crm) + + print(f" Exported {len(crm)} records to {output}") + print(f" Upload to Google Sheets → File → Import → Upload") + + +def main(): + parser = argparse.ArgumentParser(description="NorCal CRM Tracker") + sub = parser.add_subparsers(dest="command") + + sub.add_parser("status", help="Pipeline overview") + sub.add_parser("due", help="Today's follow-ups") + + add_p = sub.add_parser("add", help="Add a lead manually") + add_p.add_argument("name", help="Business name") + add_p.add_argument("phone", nargs="?", default="", help="Phone number") + add_p.add_argument("email", nargs="?", default="", help="Email address") + add_p.add_argument("notes", nargs="?", default="", help="Notes") + + update_p = sub.add_parser("update", help="Update a lead") + update_p.add_argument("identifier", help="Place ID or business name") + update_p.add_argument("--status", help="New status") + update_p.add_argument("--note", help="Add a note") + update_p.add_argument("--contact", help="Contact person name") + update_p.add_argument("--email", help="Email address") + update_p.add_argument("--date", help="Schedule follow-up date (YYYY-MM-DD)") + + sched_p = sub.add_parser("schedule", help="Schedule follow-up") + sched_p.add_argument("identifier", help="Place ID or business name") + sched_p.add_argument("--date", required=True, help="Follow-up date (YYYY-MM-DD)") + sched_p.add_argument("--note", default="", help="Note for the follow-up") + + import_p = sub.add_parser("import", help="Import from scraper CSV") + import_p.add_argument("file", nargs="?", help="CSV file path (default: leads.csv)") + + sub.add_parser("export", help="Export for Google Sheets") + + args = parser.parse_args() + + if args.command == "status": + show_status() + elif args.command == "due": + show_due() + elif args.command == "add": + add_lead(args.name, args.phone, args.email, args.notes) + elif args.command == "update": + update_lead(args.identifier, status=args.status, note=args.note, + contact_name=args.contact, email=args.email, schedule_date=args.date) + elif args.command == "schedule": + update_lead(args.identifier, schedule_date=args.date, note=args.note) + elif args.command == "import": + import_from_leads(args.file) + elif args.command == "export": + export_google() + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/norcal-toolkit/04_invoice_generator.py b/norcal-toolkit/04_invoice_generator.py new file mode 100644 index 0000000..9c30064 --- /dev/null +++ b/norcal-toolkit/04_invoice_generator.py @@ -0,0 +1,464 @@ +#!/usr/bin/env python3 +""" +NorCal Carb Mobile — Invoice Generator +Creates professional invoices as HTML (printable/PDF-able) and tracks A/R. + +Usage: + python3 04_invoice_generator.py create "Valley Fleet" --obd 3 --ovi 1 + python3 04_invoice_generator.py create "Delta Trucking" --obd 5 --fleet-discount + python3 04_invoice_generator.py list # all invoices + python3 04_invoice_generator.py overdue # unpaid past due + python3 04_invoice_generator.py paid INV-20260317-001 # mark as paid + python3 04_invoice_generator.py remind # send payment reminders +""" +import argparse +import csv +import os +import sys +from datetime import datetime, timedelta + +from jinja2 import Template + +from config import ( + BUSINESS_NAME, BUSINESS_PHONE, BUSINESS_EMAIL, BUSINESS_WEBSITE, + BUSINESS_ADDRESS, BAR_LICENSE, OWNER_NAME, PRICING, + DATA_DIR, INVOICES_DIR, CRM_CSV, INVOICE_TERMS_DAYS, +) + +# ─── Invoice Tracking ──────────────────────────────────────────── + +INVOICE_LOG = os.path.join(DATA_DIR, "invoices.csv") + +INVOICE_FIELDS = [ + "invoice_id", "customer_name", "customer_phone", "customer_email", + "customer_address", "date_issued", "date_due", "date_paid", + "obd_count", "obd_rate", "ovi_count", "ovi_rate", + "smoke_count", "smoke_rate", "discount_pct", "subtotal", + "discount_amount", "total", "status", "notes", "file_path", +] + + +def next_invoice_id(): + """Generate sequential invoice ID.""" + today = datetime.now().strftime("%Y%m%d") + seq = 1 + + if os.path.exists(INVOICE_LOG): + with open(INVOICE_LOG, "r") as f: + reader = csv.DictReader(f) + for row in reader: + iid = row.get("invoice_id", "") + if today in iid: + try: + existing_seq = int(iid.split("-")[-1]) + seq = max(seq, existing_seq + 1) + except ValueError: + pass + + return f"INV-{today}-{seq:03d}" + + +def calculate_totals(obd_count, ovi_count, smoke_count, fleet_discount=False): + """Calculate line items and totals.""" + obd_rate = PRICING["obd_test"] + ovi_rate = PRICING["ovi_test"] + smoke_rate = PRICING["smoke_opacity_test"] + discount_pct = 0 + + obd_total = obd_count * obd_rate + ovi_total = ovi_count * ovi_rate + smoke_total = smoke_count * smoke_rate + subtotal = obd_total + ovi_total + smoke_total + + total_vehicles = obd_count + ovi_count + smoke_count + if fleet_discount or total_vehicles >= PRICING["fleet_discount_threshold"]: + discount_pct = PRICING["fleet_discount_pct"] + + discount_amount = subtotal * (discount_pct / 100) + total = subtotal - discount_amount + + return { + "obd_rate": obd_rate, "ovi_rate": ovi_rate, "smoke_rate": smoke_rate, + "obd_total": obd_total, "ovi_total": ovi_total, "smoke_total": smoke_total, + "subtotal": subtotal, "discount_pct": discount_pct, + "discount_amount": discount_amount, "total": total, + } + + +# ─── HTML Invoice Template ────────────────────────────────────── + +INVOICE_HTML = Template(""" + + + +Invoice {{ invoice_id }} + + + +
+
+

{{ business_name }}

+

{{ business_address }}

+

{{ business_phone }} | {{ business_email }}

+

BAR License: {{ bar_license }}

+
+
+

INVOICE

+

{{ invoice_id }}

+

Date: {{ date_issued }}

+

Due: {{ date_due }}

+

{{ status }}

+
+
+ +
+
+

Bill To

+

{{ customer_name }}

+ {% if customer_address %}

{{ customer_address }}

{% endif %} + {% if customer_phone %}

{{ customer_phone }}

{% endif %} + {% if customer_email %}

{{ customer_email }}

{% endif %} +
+
+

Service Location

+

On-site mobile testing

+ {% if customer_address %}

{{ customer_address }}

{% endif %} +
+
+ + + + + + + + + + + + + {% if obd_count > 0 %} + + + + + + + + {% endif %} + {% if ovi_count > 0 %} + + + + + + + + {% endif %} + {% if smoke_count > 0 %} + + + + + + + + {% endif %} + +
ServiceDescriptionQtyRateAmount
OBD TestOn-Board Diagnostics emissions test{{ obd_count }}${{ "%.2f"|format(obd_rate) }}${{ "%.2f"|format(obd_total) }}
OVI TestOpacity / Visual Inspection test{{ ovi_count }}${{ "%.2f"|format(ovi_rate) }}${{ "%.2f"|format(ovi_total) }}
Smoke Opacity TestSmoke opacity measurement{{ smoke_count }}${{ "%.2f"|format(smoke_rate) }}${{ "%.2f"|format(smoke_total) }}
+ +
+ + + + + + {% if discount_pct > 0 %} + + + + + {% endif %} + + + + +
Subtotal${{ "%.2f"|format(subtotal) }}
Fleet Discount ({{ discount_pct }}%)-${{ "%.2f"|format(discount_amount) }}
Total Due${{ "%.2f"|format(total) }}
+
+ +
+ Payment Terms + Payment due within {{ terms_days }} days of invoice date. + Make checks payable to {{ business_name }}. + For questions, contact {{ business_phone }} or {{ business_email }}. +
+ + + +""") + + +def create_invoice(customer_name, obd=0, ovi=0, smoke=0, fleet_discount=False, + customer_phone="", customer_email="", customer_address="", notes=""): + """Create an invoice and save as HTML + log entry.""" + os.makedirs(INVOICES_DIR, exist_ok=True) + os.makedirs(DATA_DIR, exist_ok=True) + + inv_id = next_invoice_id() + now = datetime.now() + date_issued = now.strftime("%Y-%m-%d") + date_due = (now + timedelta(days=INVOICE_TERMS_DAYS)).strftime("%Y-%m-%d") + + totals = calculate_totals(obd, ovi, smoke, fleet_discount) + + # Render HTML + html = INVOICE_HTML.render( + invoice_id=inv_id, + business_name=BUSINESS_NAME, + business_address=BUSINESS_ADDRESS, + business_phone=BUSINESS_PHONE, + business_email=BUSINESS_EMAIL, + business_website=BUSINESS_WEBSITE, + bar_license=BAR_LICENSE, + customer_name=customer_name, + customer_phone=customer_phone, + customer_email=customer_email, + customer_address=customer_address, + date_issued=date_issued, + date_due=date_due, + status="UNPAID", + status_class="unpaid", + obd_count=obd, obd_rate=totals["obd_rate"], obd_total=totals["obd_total"], + ovi_count=ovi, ovi_rate=totals["ovi_rate"], ovi_total=totals["ovi_total"], + smoke_count=smoke, smoke_rate=totals["smoke_rate"], smoke_total=totals["smoke_total"], + subtotal=totals["subtotal"], + discount_pct=totals["discount_pct"], + discount_amount=totals["discount_amount"], + total=totals["total"], + terms_days=INVOICE_TERMS_DAYS, + ) + + # Save HTML file + html_path = os.path.join(INVOICES_DIR, f"{inv_id}.html") + with open(html_path, "w") as f: + f.write(html) + + # Log to CSV + file_exists = os.path.exists(INVOICE_LOG) + with open(INVOICE_LOG, "a", newline="") as f: + writer = csv.DictWriter(f, fieldnames=INVOICE_FIELDS) + if not file_exists: + writer.writeheader() + writer.writerow({ + "invoice_id": inv_id, + "customer_name": customer_name, + "customer_phone": customer_phone, + "customer_email": customer_email, + "customer_address": customer_address, + "date_issued": date_issued, + "date_due": date_due, + "date_paid": "", + "obd_count": obd, + "obd_rate": totals["obd_rate"], + "ovi_count": ovi, + "ovi_rate": totals["ovi_rate"], + "smoke_count": smoke, + "smoke_rate": totals["smoke_rate"], + "discount_pct": totals["discount_pct"], + "subtotal": totals["subtotal"], + "discount_amount": totals["discount_amount"], + "total": totals["total"], + "status": "UNPAID", + "notes": notes, + "file_path": html_path, + }) + + print(f"\n Invoice created: {inv_id}") + print(f" Customer: {customer_name}") + print(f" Services: {obd} OBD, {ovi} OVI, {smoke} Smoke") + if totals["discount_pct"] > 0: + print(f" Discount: {totals['discount_pct']}% fleet discount (-${totals['discount_amount']:.2f})") + print(f" Total: ${totals['total']:.2f}") + print(f" Due: {date_due}") + print(f" File: {html_path}") + print(f"\n Open in browser to print/save as PDF.\n") + + return inv_id + + +def list_invoices(): + """List all invoices.""" + if not os.path.exists(INVOICE_LOG): + print(" No invoices yet.") + return + + with open(INVOICE_LOG, "r") as f: + reader = csv.DictReader(f) + invoices = list(reader) + + print(f"\n{'='*80}") + print(f" ALL INVOICES") + print(f"{'='*80}") + print(f" {'ID':<22s} {'Customer':<25s} {'Total':>10s} {'Due':<12s} {'Status':<10s}") + print(f" {'-'*22} {'-'*25} {'-'*10} {'-'*12} {'-'*10}") + + total_outstanding = 0 + total_paid = 0 + + for inv in invoices: + total = float(inv.get("total", 0)) + status = inv.get("status", "UNPAID") + if status == "PAID": + total_paid += total + else: + total_outstanding += total + + print(f" {inv['invoice_id']:<22s} {inv['customer_name']:<25s} " + f"${total:>8.2f} {inv.get('date_due', ''):<12s} {status:<10s}") + + print(f"\n Outstanding: ${total_outstanding:.2f} | Paid: ${total_paid:.2f}") + print(f"{'='*80}\n") + + +def show_overdue(): + """Show overdue invoices.""" + if not os.path.exists(INVOICE_LOG): + print(" No invoices yet.") + return + + today = datetime.now().strftime("%Y-%m-%d") + overdue = [] + + with open(INVOICE_LOG, "r") as f: + reader = csv.DictReader(f) + for inv in reader: + if inv.get("status") != "PAID" and inv.get("date_due", "9999") < today: + overdue.append(inv) + + print(f"\n{'='*60}") + print(f" OVERDUE INVOICES — {today}") + print(f"{'='*60}") + + if not overdue: + print(" All invoices current. Nice.") + else: + total = 0 + for inv in overdue: + amt = float(inv.get("total", 0)) + total += amt + days = (datetime.now() - datetime.strptime(inv["date_due"], "%Y-%m-%d")).days + print(f" {inv['invoice_id']} {inv['customer_name']:<25s} " + f"${amt:.2f} {days}d overdue {inv.get('customer_phone', '')}") + print(f"\n Total overdue: ${total:.2f}") + + print(f"{'='*60}\n") + + +def mark_paid(invoice_id): + """Mark an invoice as paid.""" + if not os.path.exists(INVOICE_LOG): + print(" No invoices found.") + return + + invoices = [] + found = False + + with open(INVOICE_LOG, "r") as f: + reader = csv.DictReader(f) + for inv in reader: + if inv["invoice_id"] == invoice_id: + inv["status"] = "PAID" + inv["date_paid"] = datetime.now().strftime("%Y-%m-%d") + found = True + print(f" Marked PAID: {invoice_id} — {inv['customer_name']} — ${inv['total']}") + invoices.append(inv) + + if not found: + print(f" Invoice not found: {invoice_id}") + return + + with open(INVOICE_LOG, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=INVOICE_FIELDS) + writer.writeheader() + writer.writerows(invoices) + + +def main(): + parser = argparse.ArgumentParser(description="NorCal Invoice Generator") + sub = parser.add_subparsers(dest="command") + + create_p = sub.add_parser("create", help="Create a new invoice") + create_p.add_argument("customer", help="Customer name") + create_p.add_argument("--obd", type=int, default=0, help="Number of OBD tests") + create_p.add_argument("--ovi", type=int, default=0, help="Number of OVI tests") + create_p.add_argument("--smoke", type=int, default=0, help="Number of smoke opacity tests") + create_p.add_argument("--fleet-discount", action="store_true", help="Apply fleet discount") + create_p.add_argument("--phone", default="", help="Customer phone") + create_p.add_argument("--email", default="", help="Customer email") + create_p.add_argument("--address", default="", help="Customer address") + create_p.add_argument("--notes", default="", help="Invoice notes") + + sub.add_parser("list", help="List all invoices") + sub.add_parser("overdue", help="Show overdue invoices") + + paid_p = sub.add_parser("paid", help="Mark invoice as paid") + paid_p.add_argument("invoice_id", help="Invoice ID (e.g., INV-20260317-001)") + + sub.add_parser("remind", help="Show who needs payment reminders") + + args = parser.parse_args() + + if args.command == "create": + create_invoice( + args.customer, obd=args.obd, ovi=args.ovi, smoke=args.smoke, + fleet_discount=args.fleet_discount, customer_phone=args.phone, + customer_email=args.email, customer_address=args.address, notes=args.notes, + ) + elif args.command == "list": + list_invoices() + elif args.command == "overdue": + show_overdue() + elif args.command == "paid": + mark_paid(args.invoice_id) + elif args.command == "remind": + show_overdue() # same view — overdue = needs reminder + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/norcal-toolkit/05_review_request.py b/norcal-toolkit/05_review_request.py new file mode 100644 index 0000000..ea86851 --- /dev/null +++ b/norcal-toolkit/05_review_request.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +""" +NorCal Carb Mobile — Review Request & Social Post Agent +Sends review requests after jobs and generates social media posts. + +Usage: + python3 05_review_request.py review "Valley Fleet" --phone "(916) 555-0101" + python3 05_review_request.py review "Delta Trucking" --email "joe@delta.com" + python3 05_review_request.py social --type job_done --city Sacramento --tests 4 + python3 05_review_request.py social --type tip --topic "OBD vs OVI" + python3 05_review_request.py blog --topic "CARB 2027 quarterly testing" + python3 05_review_request.py batch-review # review requests for today's tested jobs +""" +import argparse +import csv +import os +import sys +from datetime import datetime + +from config import ( + BUSINESS_NAME, BUSINESS_PHONE, BUSINESS_WEBSITE, OWNER_NAME, + GOOGLE_REVIEW_LINK, DATA_DIR, CRM_CSV, + SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, BUSINESS_EMAIL, +) + +# ─── Review Request Templates ──────────────────────────────────── + +REVIEW_SMS = """Hey {contact}! Thanks for choosing {business} today. If you have 30 seconds, a Google review helps us out a ton: + +{review_link} + +Appreciate you! — {owner}""" + +REVIEW_EMAIL_SUBJECT = "Thanks for choosing {business}!" + +REVIEW_EMAIL_BODY = """Hey {contact}, + +Thanks for letting us handle your testing today at {company}. We appreciate your business. + +If you have 30 seconds, a quick Google review would mean a lot to a small local operation like ours: + +{review_link} + +If there's anything we could have done better, just reply to this email — I read every one. + +Thanks again, +{owner} +{business} +{phone}""" + + +# ─── Social Media Post Templates ───────────────────────────────── + +SOCIAL_TEMPLATES = { + "job_done": [ + "{tests} vehicles tested on-site today in {city}. No trip to the shop needed. That's the mobile advantage. #NorCalCarbMobile #MobileSmogTesting #CARB", + "Another {tests}-vehicle day in {city}. Fleet tested, results delivered, zero downtime. #CleanTruckCheck #FleetCompliance #Sacramento", + "On-site in {city} today — {tests} trucks tested and done before lunch. Your fleet doesn't stop, we come to you. #MobileTesting #CARB #NorCal", + ], + "tip": [ + "Did you know? CARB requires all diesel vehicles over 14,000 lbs to be tested 2x/year. That doubles to 4x/year in October 2027. Is your fleet ready? #CARB #CleanTruckCheck", + "OBD vs OVI — what's the difference? OBD reads your truck's computer for emissions codes. OVI is a visual + opacity test for older vehicles. Both are required under HD I/M. #FleetTips", + "Non-compliance with CARB's Clean Truck Check can cost $10,000/DAY per vehicle + DMV registration hold. Don't wait for the letter. #CARBCompliance #FleetManagement", + "No exemptions under CARB's HD I/M program. Small fleets, low-use vehicles, even out-of-state trucks operating in CA — everyone's in. #CleanTruckCheck", + "$31.18/year per vehicle is the CARB annual fee. Compare that to $10K/day in fines. Compliance is the cheapest insurance you'll buy. #FleetCompliance", + ], + "behind_scenes": [ + "Loading up the mobile testing unit for another day in the field. {city} fleet owners, we're in your area this week. #MobileSmog #NorCal", + "Early morning calibration check before heading out. Every test has to be accurate — that's why we calibrate daily. #Quality #SmogTesting", + "Just wrapped a 12-vehicle fleet test. The owner said he used to lose half a day per truck driving to a station. No more. #MobileTesting", + ], + "milestone": [ + "100 vehicles tested and counting. Thank you to every fleet owner who trusts us with their compliance. #Milestone #NorCalCarbMobile", + "Started this year with a truck and a credential. Now serving fleets across Sacramento, Stockton, and the East Bay. Growth through service. #SmallBusiness", + ], +} + + +# ─── Blog Outline Templates ───────────────────────────────────── + +BLOG_TEMPLATES = { + "what_is_obd": { + "title": "What Is an OBD Test and When Does Your Truck Need One?", + "keyword": "OBD test truck California", + "outline": [ + "H1: What Is an OBD Test and When Does Your Truck Need One?", + "Intro: Direct answer — OBD = On-Board Diagnostics scan required under CARB HD I/M", + "H2: What Does an OBD Test Check?", + " - Reads DTCs (Diagnostic Trouble Codes) from vehicle's ECU", + " - Checks emissions readiness monitors", + " - Records VIN, odometer, test results", + "H2: Which Vehicles Need OBD Testing?", + " - All diesel/alt-fuel vehicles >14,000 lbs GVWR", + " - 2013+ model year (OBD-equipped)", + " - No exemptions: small fleet, low-use, out-of-state", + "H2: How Often?", + " - Currently: 2x per year", + " - October 2027: increases to 4x per year", + "H2: What If You Fail?", + " - Must repair and retest within 120 days", + " - Non-compliance: up to $10K/day + DMV hold", + "H2: Mobile OBD Testing — How It Works", + " - We come to your yard/lot", + " - Test takes 15-20 minutes per vehicle", + " - Results submitted to CARB same day", + "CTA: Schedule your fleet's OBD test today — [phone] / [website]", + ], + }, + "ovi_vs_obd": { + "title": "OVI vs OBD Testing: What's the Difference for Your Fleet?", + "keyword": "OVI vs OBD test difference", + "outline": [ + "H1: OVI vs OBD Testing: What's the Difference for Your Fleet?", + "Intro: Two test types under CARB's Clean Truck Check — here's which one your truck needs", + "H2: OBD Test (On-Board Diagnostics)", + " - For 2013+ model year vehicles with OBD systems", + " - Electronic scan of emissions computer", + " - Checks DTCs, readiness monitors", + "H2: OVI Test (Opacity / Visual Inspection)", + " - For pre-2013 vehicles without OBD capability", + " - Smoke opacity measurement (SAE J1667 snap-idle)", + " - Visual inspection of emissions controls", + "H2: Which Test Does Your Truck Need?", + " - Table: Model Year → Test Type", + " - 2013+: OBD | Pre-2013: OVI | Mixed fleet: both", + "H2: Testing Frequency", + " - OBD: 2x/year now, 4x/year Oct 2027", + " - OVI: 2x/year (unchanged)", + "H2: Costs and What to Expect", + " - Our pricing: $X OBD, $X OVI", + " - On-site mobile testing saves fleet downtime", + "CTA: Not sure which test your fleet needs? Call us — [phone]", + ], + }, + "carb_2027": { + "title": "CARB 2027 Quarterly Testing Mandate: What Fleet Owners Need to Know", + "keyword": "CARB 2027 quarterly testing requirement", + "outline": [ + "H1: CARB 2027 Quarterly Testing Mandate: What Fleet Owners Need to Know", + "Intro: Starting October 2027, OBD testing doubles from 2x to 4x per year", + "H2: What's Changing?", + " - OBD-equipped vehicles (2013+) go to quarterly testing", + " - That's 4 tests per vehicle per year", + " - Calendar-based scheduling (every 90 days)", + "H2: Why Is CARB Doing This?", + " - Catch emissions failures faster", + " - Align with federal EPA goals", + " - Data: X% of tested vehicles had unreported issues", + "H2: Impact on Your Fleet", + " - 10 trucks = 40 tests/year (up from 20)", + " - 50 trucks = 200 tests/year", + " - Scheduling headache if you're driving to a station", + "H2: How to Prepare NOW", + " - Build a testing schedule by VIN", + " - Partner with a mobile tester (eliminate downtime)", + " - Budget for 2x the testing costs", + "H2: Mobile Testing Is the Answer", + " - We come to you — test during load/unload", + " - Fleet scheduling: we track your deadlines", + " - Volume pricing for quarterly contracts", + "CTA: Lock in your 2027 testing schedule now — [phone] / [website]", + ], + }, +} + + +def send_review_request_email(contact, company, email): + """Send a review request email.""" + if not SMTP_USER or not SMTP_PASS: + print(f" [PREVIEW] Would email {email}:") + body = REVIEW_EMAIL_BODY.format( + contact=contact, company=company, business=BUSINESS_NAME, + review_link=GOOGLE_REVIEW_LINK, owner=OWNER_NAME, phone=BUSINESS_PHONE, + ) + print(f" Subject: {REVIEW_EMAIL_SUBJECT.format(business=BUSINESS_NAME)}") + for line in body.split("\n"): + print(f" {line}") + return + + import smtplib + from email.mime.text import MIMEText + from email.mime.multipart import MIMEMultipart + + subject = REVIEW_EMAIL_SUBJECT.format(business=BUSINESS_NAME) + body = REVIEW_EMAIL_BODY.format( + contact=contact, company=company, business=BUSINESS_NAME, + review_link=GOOGLE_REVIEW_LINK, owner=OWNER_NAME, phone=BUSINESS_PHONE, + ) + + msg = MIMEMultipart() + msg["From"] = f"{OWNER_NAME} <{BUSINESS_EMAIL}>" + msg["To"] = email + msg["Subject"] = subject + msg.attach(MIMEText(body, "plain")) + + try: + with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15) as server: + server.starttls() + server.login(SMTP_USER, SMTP_PASS) + server.send_message(msg) + print(f" [SENT] Review request to {email}") + except Exception as e: + print(f" [ERROR] {e}") + + +def generate_sms(contact, company): + """Generate review request SMS text (for copy-paste or Twilio).""" + msg = REVIEW_SMS.format( + contact=contact, business=BUSINESS_NAME, + review_link=GOOGLE_REVIEW_LINK, owner=OWNER_NAME, + ) + print(f"\n SMS for {company} ({contact}):") + print(f" {'─'*40}") + for line in msg.split("\n"): + print(f" {line}") + print(f" {'─'*40}") + print(f" Characters: {len(msg)}\n") + + +def generate_social_post(post_type, city="Sacramento", tests=0, topic=""): + """Generate social media post.""" + import random + + templates = SOCIAL_TEMPLATES.get(post_type, SOCIAL_TEMPLATES["tip"]) + template = random.choice(templates) + + post = template.format( + city=city, tests=tests, topic=topic, + business=BUSINESS_NAME, owner=OWNER_NAME, + ) + + print(f"\n SOCIAL POST ({post_type}):") + print(f" {'─'*50}") + print(f" {post}") + print(f" {'─'*50}") + print(f" Characters: {len(post)}") + print(f" Platforms: Instagram, Facebook, LinkedIn, Google Business Profile") + print() + + +def generate_blog_outline(topic): + """Generate a blog post outline.""" + # Match topic to template + template = None + topic_lower = topic.lower() + for key, tmpl in BLOG_TEMPLATES.items(): + if key.replace("_", " ") in topic_lower or any( + word in topic_lower for word in key.split("_") + ): + template = tmpl + break + + if not template: + # Generic outline + print(f"\n BLOG OUTLINE: {topic}") + print(f" {'─'*50}") + print(f" H1: {topic}") + print(f" - Intro: Answer the question directly in first paragraph") + print(f" - H2: What it is / Why it matters") + print(f" - H2: How it works / What to expect") + print(f" - H2: Impact on your fleet (specific numbers)") + print(f" - H2: How we help (mobile testing advantage)") + print(f" - CTA: Call {BUSINESS_PHONE} or visit {BUSINESS_WEBSITE}") + print(f" Target: 800-1500 words | One primary keyword") + print(f" {'─'*50}\n") + return + + print(f"\n BLOG OUTLINE:") + print(f" {'─'*50}") + print(f" Title: {template['title']}") + print(f" Keyword: {template['keyword']}") + print(f" Target: 800-1500 words") + print() + for line in template["outline"]: + print(f" {line}") + print(f" {'─'*50}\n") + + +def batch_review_requests(): + """Send review requests to all jobs tested today.""" + if not os.path.exists(CRM_CSV): + print(" No CRM data. Run 03_crm_tracker.py import first.") + return + + today = datetime.now().strftime("%Y-%m-%d") + count = 0 + + with open(CRM_CSV, "r") as f: + reader = csv.DictReader(f) + for row in reader: + if row.get("status") == "TESTED" and row.get("updated_date") == today: + contact = row.get("contact_name") or row.get("business_name", "there").split()[0] + company = row.get("business_name", "") + + if row.get("email"): + send_review_request_email(contact, company, row["email"]) + count += 1 + + if row.get("phone"): + generate_sms(contact, company) + count += 1 + + if count == 0: + print(" No jobs marked TESTED today. Update CRM status first.") + else: + print(f"\n Sent {count} review requests for today's jobs.") + + +def main(): + parser = argparse.ArgumentParser(description="NorCal Review & Social Agent") + sub = parser.add_subparsers(dest="command") + + rev_p = sub.add_parser("review", help="Send review request") + rev_p.add_argument("company", help="Company name") + rev_p.add_argument("--phone", default="", help="Phone for SMS") + rev_p.add_argument("--email", default="", help="Email for review request") + rev_p.add_argument("--contact", default="", help="Contact person name") + + social_p = sub.add_parser("social", help="Generate social media post") + social_p.add_argument("--type", choices=["job_done", "tip", "behind_scenes", "milestone"], + default="job_done", help="Post type") + social_p.add_argument("--city", default="Sacramento", help="City name") + social_p.add_argument("--tests", type=int, default=4, help="Number of tests") + social_p.add_argument("--topic", default="", help="Topic for tip posts") + + blog_p = sub.add_parser("blog", help="Generate blog post outline") + blog_p.add_argument("--topic", required=True, help="Blog topic") + + sub.add_parser("batch-review", help="Review requests for today's tested jobs") + + args = parser.parse_args() + + if args.command == "review": + contact = args.contact or args.company.split()[0] + if args.email: + send_review_request_email(contact, args.company, args.email) + if args.phone: + generate_sms(contact, args.company) + if not args.email and not args.phone: + print(" Provide --email and/or --phone") + elif args.command == "social": + generate_social_post(args.type, city=args.city, tests=args.tests, topic=args.topic) + elif args.command == "blog": + generate_blog_outline(args.topic) + elif args.command == "batch-review": + batch_review_requests() + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/norcal-toolkit/config.py b/norcal-toolkit/config.py new file mode 100644 index 0000000..6d748d3 --- /dev/null +++ b/norcal-toolkit/config.py @@ -0,0 +1,96 @@ +""" +NorCal Carb Mobile Toolkit — Central Configuration +All API keys, business info, and defaults in one place. +Set via environment variables or edit defaults below. +""" +import os + +# ─── Business Info ─────────────────────────────────────────────── +BUSINESS_NAME = os.getenv("NORCAL_BUSINESS_NAME", "NorCal Carb Mobile") +BUSINESS_PHONE = os.getenv("NORCAL_PHONE", "(916) 555-0199") +BUSINESS_EMAIL = os.getenv("NORCAL_EMAIL", "bryan@norcalcarbmobile.com") +BUSINESS_WEBSITE = os.getenv("NORCAL_WEBSITE", "https://norcalcarbmobile.com") +BUSINESS_ADDRESS = os.getenv("NORCAL_ADDRESS", "Sacramento, CA") +BAR_LICENSE = os.getenv("NORCAL_BAR_LICENSE", "BAR-XXXXXX") +OWNER_NAME = os.getenv("NORCAL_OWNER", "Bryan") + +# ─── Google Places API ─────────────────────────────────────────── +GOOGLE_PLACES_API_KEY = os.getenv("GOOGLE_PLACES_API_KEY", "") + +# ─── Service Area (lat/lng centers for scraping radius) ────────── +SERVICE_AREAS = { + "sacramento": {"lat": 38.5816, "lng": -121.4944}, + "stockton": {"lat": 37.9577, "lng": -121.2908}, + "roseville": {"lat": 38.7521, "lng": -121.2880}, + "elk_grove": {"lat": 38.4088, "lng": -121.3716}, + "modesto": {"lat": 37.6391, "lng": -120.9969}, + "east_bay": {"lat": 37.8044, "lng": -122.2712}, + "vallejo": {"lat": 38.1041, "lng": -122.2566}, +} + +# ─── Scraper Defaults ──────────────────────────────────────────── +DEFAULT_SEARCH_RADIUS_METERS = 30000 # 30km +SCRAPE_QUERIES = [ + "trucking company", + "fleet management", + "diesel repair shop", + "construction company", + "auto dealer", + "tow yard", + "body shop", + "landscaping company", + "school bus service", + "freight broker", + "agricultural hauler", + "logistics company", + "property management", + "waste management", + "delivery service", +] + +# ─── Lead Scoring Weights ──────────────────────────────────────── +SCORING = { + "high_review_count": 2, # 10+ reviews = established + "in_service_area": 3, # within our service radius + "has_website": 1, # professional operation + "fleet_keywords": 3, # mentions fleet/trucks/vehicles + "compliance_keywords": 2, # mentions CARB/smog/emissions + "recently_opened": 1, # new business, needs services + "multiple_locations": 2, # bigger operation + "phone_available": 1, # reachable +} + +# ─── Pricing ───────────────────────────────────────────────────── +PRICING = { + "obd_test": 75.00, + "ovi_test": 85.00, + "smoke_opacity_test": 85.00, + "fleet_discount_threshold": 5, # 5+ vehicles = discount + "fleet_discount_pct": 10, # 10% off +} + +# ─── Email / SMTP ──────────────────────────────────────────────── +SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com") +SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) +SMTP_USER = os.getenv("SMTP_USER", "") +SMTP_PASS = os.getenv("SMTP_PASS", "") +COLD_EMAIL_FROM = os.getenv("COLD_EMAIL_FROM", "") # secondary domain for cold + +# ─── Google Review Link ────────────────────────────────────────── +GOOGLE_REVIEW_LINK = os.getenv( + "GOOGLE_REVIEW_LINK", + "https://g.page/r/YOUR_PLACE_ID/review" +) + +# ─── File Paths ────────────────────────────────────────────────── +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +LEADS_CSV = os.path.join(DATA_DIR, "leads.csv") +CRM_CSV = os.path.join(DATA_DIR, "crm.csv") +INVOICES_DIR = os.path.join(DATA_DIR, "invoices") +EMAILS_LOG = os.path.join(DATA_DIR, "emails_sent.csv") + +# ─── Follow-Up Cadence (days after initial contact) ────────────── +FOLLOWUP_SCHEDULE = [1, 3, 7, 14, 30] + +# ─── Invoice Terms ─────────────────────────────────────────────── +INVOICE_TERMS_DAYS = 15 # Net 15 diff --git a/norcal-toolkit/daily_workflow.py b/norcal-toolkit/daily_workflow.py new file mode 100644 index 0000000..b24ee92 --- /dev/null +++ b/norcal-toolkit/daily_workflow.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +NorCal Carb Mobile — Daily Workflow Runner +Run this every morning. It executes the full daily cycle: + + python3 daily_workflow.py # full daily run + python3 daily_workflow.py --step 3 # run specific step only + python3 daily_workflow.py --dry-run # preview everything, change nothing + +THE DAILY LOOP: + 1. Scrape new leads (weekly on Monday, skip other days) + 2. Import new leads into CRM + 3. Show today's follow-ups (who to call/email) + 4. Send cold email sequences (Tier 1 first) + 5. Pipeline status check + 6. Generate a social media post + 7. Check overdue invoices + 8. Batch review requests for yesterday's tested jobs +""" +import os +import sys +import subprocess +from datetime import datetime + +TOOLKIT_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def run_step(script, args=None, description=""): + """Run a toolkit script as a subprocess.""" + cmd = [sys.executable, os.path.join(TOOLKIT_DIR, script)] + if args: + cmd.extend(args) + + print(f"\n{'='*60}") + print(f" STEP: {description}") + print(f" CMD: {' '.join(cmd)}") + print(f"{'='*60}\n") + + result = subprocess.run(cmd, cwd=TOOLKIT_DIR, capture_output=False) + return result.returncode == 0 + + +def daily_workflow(step=None, dry_run=False): + """Execute the daily workflow.""" + today = datetime.now() + day_name = today.strftime("%A") + date_str = today.strftime("%Y-%m-%d") + + print(f"\n{'#'*60}") + print(f" NORCAL CARB MOBILE — DAILY WORKFLOW") + print(f" {day_name}, {date_str}") + print(f"{'#'*60}") + + steps = { + 1: { + "desc": "Scrape New Leads (Monday only)", + "script": "01_lead_scraper.py", + "args": [], + "condition": day_name == "Monday", + "skip_msg": "Lead scraping runs on Mondays. Skipping.", + }, + 2: { + "desc": "Import Leads to CRM", + "script": "03_crm_tracker.py", + "args": ["import"], + "condition": True, + }, + 3: { + "desc": "Today's Follow-Ups (WHO TO CALL)", + "script": "03_crm_tracker.py", + "args": ["due"], + "condition": True, + }, + 4: { + "desc": "Cold Email — Tier 1 Sequence #1", + "script": "02_cold_emailer.py", + "args": ["--preview", "--tier", "1", "--sequence", "1"] if dry_run + else ["--send", "--tier", "1", "--sequence", "1", "--dry-run"], + "condition": True, + }, + 5: { + "desc": "Pipeline Status", + "script": "03_crm_tracker.py", + "args": ["status"], + "condition": True, + }, + 6: { + "desc": "Generate Social Media Post", + "script": "05_review_request.py", + "args": ["social", "--type", "tip"], + "condition": True, + }, + 7: { + "desc": "Check Overdue Invoices", + "script": "04_invoice_generator.py", + "args": ["overdue"], + "condition": True, + }, + 8: { + "desc": "Batch Review Requests", + "script": "05_review_request.py", + "args": ["batch-review"], + "condition": True, + }, + } + + for num, s in steps.items(): + if step and num != step: + continue + + if not s.get("condition", True): + print(f"\n [SKIP] Step {num}: {s.get('skip_msg', 'Condition not met')}") + continue + + run_step(s["script"], s["args"], f"Step {num}: {s['desc']}") + + print(f"\n{'#'*60}") + print(f" DAILY WORKFLOW COMPLETE — {datetime.now().strftime('%H:%M')}") + print(f" Next: Make your calls. Close your deals.") + print(f"{'#'*60}\n") + + +def main(): + import argparse + parser = argparse.ArgumentParser(description="NorCal Daily Workflow") + parser.add_argument("--step", type=int, help="Run specific step only (1-8)") + parser.add_argument("--dry-run", action="store_true", help="Preview mode, no changes") + args = parser.parse_args() + + daily_workflow(step=args.step, dry_run=args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/norcal-toolkit/job_workflow.py b/norcal-toolkit/job_workflow.py new file mode 100644 index 0000000..3d9ec09 --- /dev/null +++ b/norcal-toolkit/job_workflow.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +NorCal Carb Mobile — Job Workflow (Call-to-Cash) +The exact workflow for when a call comes in and you book a job. + +Usage: + python3 job_workflow.py # interactive walkthrough + python3 job_workflow.py --quick "Valley Fleet" --obd 3 --ovi 1 + +This is the scenario from the howto: +"3 OBD tests and 1 OVI test today at 3pm" +""" +import argparse +import os +import sys +from datetime import datetime + +TOOLKIT_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, TOOLKIT_DIR) + +from config import PRICING, BUSINESS_PHONE, BUSINESS_NAME, OWNER_NAME + + +def calculate_quote(obd=0, ovi=0, smoke=0, fleet_discount=False): + """Quick price calculator.""" + total_vehicles = obd + ovi + smoke + subtotal = (obd * PRICING["obd_test"] + + ovi * PRICING["ovi_test"] + + smoke * PRICING["smoke_opacity_test"]) + + discount = 0 + if fleet_discount or total_vehicles >= PRICING["fleet_discount_threshold"]: + discount = subtotal * (PRICING["fleet_discount_pct"] / 100) + + return subtotal, discount, subtotal - discount + + +def interactive_workflow(): + """Walk through the full job workflow interactively.""" + print(f"\n{'='*60}") + print(f" NORCAL CARB MOBILE — JOB WORKFLOW") + print(f" Call-to-Cash Checklist") + print(f"{'='*60}") + + # Step 1: Capture + print(f"\n STEP 1: CAPTURE THE CALL") + print(f" {'─'*40}") + company = input(" Business name: ").strip() or "Walk-in Customer" + contact = input(" Contact person: ").strip() or company.split()[0] + phone = input(" Phone: ").strip() + email = input(" Email: ").strip() + address = input(" Service address: ").strip() + + print(f"\n STEP 2: SCOPE THE JOB") + print(f" {'─'*40}") + obd = int(input(" Number of OBD tests: ").strip() or "0") + ovi = int(input(" Number of OVI tests: ").strip() or "0") + smoke = int(input(" Number of Smoke Opacity tests: ").strip() or "0") + appt_time = input(" Appointment time (e.g., 3pm today): ").strip() or "TBD" + + # Calculate + subtotal, discount, total = calculate_quote(obd, ovi, smoke) + total_vehicles = obd + ovi + smoke + + print(f"\n STEP 3: CONFIRM QUOTE") + print(f" {'─'*40}") + print(f" Customer: {company} ({contact})") + print(f" Address: {address}") + print(f" Time: {appt_time}") + print(f" Services:") + if obd: print(f" {obd}x OBD Test @ ${PRICING['obd_test']:.0f} = ${obd * PRICING['obd_test']:.2f}") + if ovi: print(f" {ovi}x OVI Test @ ${PRICING['ovi_test']:.0f} = ${ovi * PRICING['ovi_test']:.2f}") + if smoke: print(f" {smoke}x Smoke @ ${PRICING['smoke_opacity_test']:.0f} = ${smoke * PRICING['smoke_opacity_test']:.2f}") + if discount > 0: + print(f" Fleet discount ({PRICING['fleet_discount_pct']}%): -${discount:.2f}") + print(f" ───────────────────────────") + print(f" TOTAL: ${total:.2f}") + + confirm = input(f"\n Send confirmation text/email? (y/n): ").strip().lower() + if confirm == "y": + confirmation = ( + f"Confirmed: {company}\n" + f"{obd + ovi + smoke} vehicle tests at {appt_time}\n" + f"Address: {address}\n" + f"Total: ${total:.2f}\n" + f"— {OWNER_NAME}, {BUSINESS_NAME} {BUSINESS_PHONE}" + ) + print(f"\n CONFIRMATION MESSAGE (copy/paste to text or email):") + print(f" {'─'*40}") + for line in confirmation.split("\n"): + print(f" {line}") + print(f" {'─'*40}") + + # Step 4: Add to CRM + print(f"\n STEP 4: ADD TO CRM") + print(f" {'─'*40}") + add_crm = input(" Add to CRM? (y/n): ").strip().lower() + if add_crm == "y": + from subprocess import run + run([sys.executable, os.path.join(TOOLKIT_DIR, "03_crm_tracker.py"), + "add", company, phone, email, f"Scheduled: {appt_time} | {obd} OBD, {ovi} OVI"], + cwd=TOOLKIT_DIR) + # Update status to SCHEDULED + run([sys.executable, os.path.join(TOOLKIT_DIR, "03_crm_tracker.py"), + "update", company, "--status", "SCHEDULED", + "--contact", contact, "--email", email, + "--note", f"Booked {total_vehicles} tests at {appt_time}"], + cwd=TOOLKIT_DIR) + + # Step 5: After testing + print(f"\n STEP 5: AFTER TESTING (run these after the job)") + print(f" {'─'*40}") + print(f" a) Update CRM status:") + print(f" python3 03_crm_tracker.py update \"{company}\" --status TESTED") + print(f"") + print(f" b) Generate invoice:") + print(f" python3 04_invoice_generator.py create \"{company}\" --obd {obd} --ovi {ovi}" + + (f" --smoke {smoke}" if smoke else "") + + (f" --fleet-discount" if total_vehicles >= PRICING["fleet_discount_threshold"] else "") + + f" --phone \"{phone}\" --email \"{email}\" --address \"{address}\"") + print(f"") + print(f" c) Send review request:") + print(f" python3 05_review_request.py review \"{company}\" --phone \"{phone}\"" + + (f" --email \"{email}\"" if email else "")) + print(f"") + print(f" d) Post to social:") + city = address.split(",")[0].strip() if "," in address else "your area" + print(f" python3 05_review_request.py social --type job_done --city \"{city}\" --tests {total_vehicles}") + print(f"") + print(f" e) Update CRM to invoiced:") + print(f" python3 03_crm_tracker.py update \"{company}\" --status INVOICED") + + print(f"\n{'='*60}") + print(f" JOB BOOKED. Go make money.") + print(f"{'='*60}\n") + + +def quick_workflow(company, obd=0, ovi=0, smoke=0, phone="", email="", address=""): + """Non-interactive quick booking.""" + subtotal, discount, total = calculate_quote(obd, ovi, smoke) + total_vehicles = obd + ovi + smoke + + print(f"\n QUICK BOOKING: {company}") + print(f" {'─'*40}") + print(f" {obd} OBD + {ovi} OVI + {smoke} Smoke = ${total:.2f}") + + if total_vehicles >= PRICING["fleet_discount_threshold"]: + print(f" Fleet discount applied: -${discount:.2f}") + + # Add to CRM + from subprocess import run + run([sys.executable, os.path.join(TOOLKIT_DIR, "03_crm_tracker.py"), + "add", company, phone, email, f"{obd} OBD, {ovi} OVI, {smoke} Smoke"], + cwd=TOOLKIT_DIR, capture_output=True) + run([sys.executable, os.path.join(TOOLKIT_DIR, "03_crm_tracker.py"), + "update", company, "--status", "SCHEDULED"], + cwd=TOOLKIT_DIR, capture_output=True) + + print(f" Added to CRM as SCHEDULED") + print(f"\n After job, run:") + print(f" python3 04_invoice_generator.py create \"{company}\" --obd {obd} --ovi {ovi}" + + (f" --smoke {smoke}" if smoke else "")) + print() + + +def main(): + parser = argparse.ArgumentParser(description="NorCal Job Workflow") + parser.add_argument("--quick", metavar="COMPANY", help="Quick booking (non-interactive)") + parser.add_argument("--obd", type=int, default=0) + parser.add_argument("--ovi", type=int, default=0) + parser.add_argument("--smoke", type=int, default=0) + parser.add_argument("--phone", default="") + parser.add_argument("--email", default="") + parser.add_argument("--address", default="") + args = parser.parse_args() + + if args.quick: + quick_workflow(args.quick, obd=args.obd, ovi=args.ovi, smoke=args.smoke, + phone=args.phone, email=args.email, address=args.address) + else: + interactive_workflow() + + +if __name__ == "__main__": + main() diff --git a/workers/cleantruckcheckstockton/worker.js b/workers/cleantruckcheckstockton/worker.js new file mode 100644 index 0000000..942b746 --- /dev/null +++ b/workers/cleantruckcheckstockton/worker.js @@ -0,0 +1,1311 @@ +// worker.js - Clean Truck Check Stockton +var worker_default = { + async fetch(request) { + const html = ` + + + + + + + + + + + + + + + + + Mobile CARB Testing Stockton CA | Clean Truck Check - HD-OBD & Opacity + + + + + +
+ +
+ + +
+
+
+

+ Mobile CARB Testing in Stockton, CA +

+

+ Licensed emissions testing comes to you. No downtime. No waiting at a shop. + HD-OBD, smoke/opacity, and fleet testing for the Central Valley and San Joaquin. +

+ +
+ ⚠️ Biannual testing is NOW REQUIRED in 2026. Don't wait for a citation. Schedule today. +
+ + + +
+
+
+
4.9★ / 47+ Reviews
+
+
+
+
Licensed CARB Tester
IF530523
+
+
+
🏁
+
Mobile Service
We Come To You
+
+
+
+
+
+ + +
+
+

Our Services

+

+ Professional CARB emissions testing for trucks and fleets. Licensed and certified for Central Valley operations. +

+ +
+
+
🔧
+

HD-OBD Testing

+

Heavy-duty on-board diagnostic testing for trucks 2013+. Complete compliance certification.

+
$75
+ Schedule Now → +
+ +
+
💨
+

Smoke/Opacity Testing

+

Professional smoke and opacity testing. Quick turnaround with official CARB documentation.

+
$199
+ Schedule Now → +
+ +
+
🚛
+

Fleet Opacity Testing

+

Multi-vehicle fleet testing with volume discounts. Mobile service reduces operational downtime.

+
$149+
+ Schedule Now → +
+ +
+
🏠
+

RV/Motorhome Testing

+

Full-service CARB testing for RVs and motorhomes. We handle all paperwork and compliance.

+
$300
+ Schedule Now → +
+
+ +
+

Need a Quote?

+

+ Get instant pricing and schedule your test. Our mobile service covers Stockton, Lodi, Tracy, + Manteca, and throughout the San Joaquin Valley. +

+ Call for Pricing +
+
+
+ + +
+
+

Why Choose Mobile CARB Testing?

+

+ No more taking your truck out of service. We bring the equipment to you. +

+ +
+
+
⏱️
+
+

Zero Downtime

+

We test at your location. No driving to a shop, no waiting in a queue. Keep operating.

+
+
+ +
+
💰
+
+

Cost Effective

+

Avoid fuel costs and operational delays. Competitive pricing for single vehicles and fleets.

+
+
+ +
+
+
+

Licensed & Certified

+

CARB Tester ID IF530523. All testing meets 2026 biannual compliance requirements.

+
+
+ +
+
📋
+
+

Instant Documentation

+

Receive official test results immediately. Digital and printed certificates provided.

+
+
+ +
+
🗺️
+
+

Central Valley Wide Coverage

+

Stockton, Lodi, Tracy, Manteca, Modesto, and throughout the San Joaquin Valley corridor.

+
+
+ +
+
+
+

Expert Service

+

Years of experience. Fast, professional, and reliable. 4.9★ rated by Central Valley customers.

+
+
+
+
+
+ + +
+
+

CARB Testing for Stockton & the San Joaquin Valley

+ +
+
+

Serving Stockton and Surrounding Areas

+

+ NorCal CARB Mobile LLC provides professional emissions testing throughout the Central Valley, + including Stockton, Lodi, Tracy, and Manteca. With the Port of Stockton and + major I-5 logistics hubs in our region, compliance testing is essential for fleet operations. +

+ +
    +
  • Stockton (Central Service Hub)
  • +
  • Lodi & Galt
  • +
  • Tracy & Manteca
  • +
  • I-5 Corridor Coverage
  • +
  • Port of Stockton Area
  • +
  • San Joaquin Industrial Districts
  • +
  • Modesto & Turlock
  • +
  • Ripon & Escalon
  • +
+ +
+ 2026 CARB Compliance: California's biannual testing requirement is in effect. + All heavy-duty trucks registered in California must undergo testing. Schedule now to avoid + penalties and vehicle downtime. +
+
+ +
+
+

Stockton Service Info

+ +
+

Primary Contact

+ + 916-890-4427 + +
+ +
+

CARB Tester ID

+

IF530523

+
+ +
+

Service Hours

+

Monday - Friday: 6am - 5pm
Saturday: 8am - 4pm

+
+ +
+

Primary Website

+ + norcalcarbmobile.com + +
+
+
+
+
+
+ + +
+
+

Frequently Asked Questions

+

+ Everything you need to know about CARB testing in Stockton. +

+ +
+
+
+

What is CARB testing and why do I need it?

+ + +
+
+

+ CARB (California Air Resources Board) testing measures heavy-duty truck emissions to ensure compliance with state air quality standards. + As of 2026, all trucks registered in California must undergo biannual (every 2 years) HD-OBD testing or smoke/opacity testing. + This is a legal requirement to operate in California and avoid citations. +

+
+
+ +
+
+

How long does CARB testing take?

+ + +
+
+

+ HD-OBD testing typically takes 30-45 minutes per vehicle. Smoke/opacity testing takes 15-30 minutes. + Since our testing is mobile, we come to your location, eliminating travel time and downtime. + You can keep your truck operational throughout the process. +

+
+
+ +
+
+

Do you test at our facility or do we need to go somewhere?

+ + +
+
+

+ We come to you. Our mobile testing service means we bring all equipment to your location in Stockton, + your fleet yard, or anywhere else in the San Joaquin Valley. There's no need to take your truck out of service + or drive to a testing center. This is one of our biggest advantages for busy fleets. +

+
+
+ +
+
+

What's the difference between HD-OBD and smoke/opacity testing?

+ + +
+
+

+ HD-OBD (Heavy-Duty On-Board Diagnostic) testing checks the truck's onboard computer systems for emissions compliance. + It's required for trucks 2013 and newer. Smoke/opacity testing measures visible emissions during acceleration. + Many trucks require both or can choose either. We'll help you determine which your vehicle needs. +

+
+
+ +
+
+

How much does CARB testing cost?

+ + +
+
+

+ Pricing varies by service: HD-OBD testing is $75 per vehicle, smoke/opacity testing is $199, + RV/motorhome testing is $300, and fleet opacity testing starts at $149+ with volume discounts. + Call us at 916-890-4427 for a specific quote based on your vehicles and needs. +

+
+
+ +
+
+

Are you licensed and certified?

+ + +
+
+

+ Yes. We are a licensed CARB tester with ID IF530523. Our team is certified to perform all required emissions + testing under California Air Resources Board regulations. All test results are official and valid for registration + and compliance purposes throughout California. +

+
+
+ +
+
+

How do I schedule a test?

+ + +
+
+

+ Call us at 916-890-4427 to schedule. We serve Stockton, Lodi, Tracy, Manteca, and throughout the San Joaquin Valley. + We offer flexible scheduling with early morning and weekend availability. You can also visit norcalcarbmobile.com/contact + to request service online and we'll follow up with you. +

+
+
+ +
+
+

What happens if my truck fails the test?

+ + +
+
+

+ If your truck doesn't pass, you'll receive documentation of the failure. You'll need to address the emissions issue + (often through repairs or maintenance) and then retest. We can discuss options with you and help schedule a retest + after repairs. Many failures are due to maintenance items that are relatively inexpensive to fix. +

+
+
+ +
+
+

Is your service available on weekends?

+ + +
+
+

+ Yes, we offer Saturday testing. Service hours are Monday-Friday 6am-5pm and Saturday 8am-4pm. + Call 916-890-4427 to check availability and schedule a weekend test time that works for your fleet. +

+
+
+
+
+
+ + +
+
+
+

Ready to Get Compliant?

+

+ Schedule your CARB test today. Mobile service in Stockton and the San Joaquin Valley. + Quick, professional, licensed testing. +

+ +
+
+
+ + + + + + + + + + + + diff --git a/workers/norcalcarbmobile/squarespace-export.xml b/workers/norcalcarbmobile/squarespace-export.xml new file mode 100644 index 0000000..690ec7b --- /dev/null +++ b/workers/norcalcarbmobile/squarespace-export.xml @@ -0,0 +1,11 @@ +squarespace API Key Generated +Keep your API key somewhere safe. For your security, this is the only time we’ll show your key. + +Key Name +Admin + +Permissions +Forms (Read Only), Inventory (Read and Write), Orders (Read and Write), Products (Read and Write), Profiles (Read Only), Transactions (Read Only) + +API Key +51b09d71-3c5e-4d51-bb56-056f34a4d197 diff --git a/workers/security-silverbackai/worker.js b/workers/security-silverbackai/worker.js new file mode 100644 index 0000000..a8a9fdc --- /dev/null +++ b/workers/security-silverbackai/worker.js @@ -0,0 +1,974 @@ +// worker.js - Silverback AI Security Dashboard +var worker_default = { + async fetch(request) { + const url = new URL(request.url); + + // Health check endpoint + if (url.pathname === '/api/health') { + return new Response(JSON.stringify({ status: 'operational', timestamp: new Date().toISOString() }), { + headers: { 'Content-Type': 'application/json' } + }); + } + + // Status endpoint + if (url.pathname === '/api/status') { + return new Response(JSON.stringify({ + systems: { + ai_engine: 'operational', + monitoring: 'operational', + alerts: 'operational', + weirdness_detection: 'operational' + }, + uptime: '99.97%', + last_check: new Date().toISOString() + }), { + headers: { 'Content-Type': 'application/json' } + }); + } + + const html = ` + + + + + + + + + + + + Silverback AI — Security Systems + + + + +
+
+
+
+
+ + + +
+
+

SILVERBACK AI

+ Security Systems +
+
+
+
+ All Systems Operational + Online +
+
+
+
+ +
+
+
+
+ + MONITORING ACTIVE +
+

+ AI-Powered Property Security +

+

+ Advanced threat detection and real-time monitoring for 3875 Ruby St, Oakland. Privacy-first AI security compliant with Oakland municipal law. +

+
+
+ +
+
+
+
System Uptime
+
99.97%
+
Last 30 days
+
+
+
Threats Blocked
+
1,247
+
This month
+
+
+
Active Monitors
+
8
+
Cameras + sensors
+
+
+
Avg Response
+
0.3s
+
Alert to detection
+
+
+
+ +
+
+
+

System Status

+ Updated just now +
+
+
+
+
🧠
+
+
AI Analysis Engine
+
Gemini-powered threat assessment
+
+
+ Operational +
+
+
+
📸
+
+
Video Monitoring
+
RTSP camera feeds active
+
+
+ Operational +
+
+
+
🚨
+
+
Real-Time Alerts
+
WebSocket push notifications
+
+
+ Operational +
+
+
+
🔎
+
+
Weirdness Detection
+
Anomaly pattern analysis
+
+
+ Operational +
+
+
+
📈
+
+
Event Logging
+
Firebase Firestore real-time DB
+
+
+ Operational +
+
+
+
🔐
+
+
Access Control
+
Role-based authentication
+
+
+ Operational +
+
+
+
+ +
+
+
+

Security Capabilities

+
+
+
+
🧠
+

AI Threat Detection

+

Powered by Google Gemini, our AI engine analyzes camera feeds and sensor data in real-time to detect threats including break-ins, theft, and suspicious activity before they escalate.

+ gemini-powered +
+
+
👻
+

Weirdness Algorithm

+

Proprietary anomaly detection that learns what "normal" looks like for your property. Configurable motion thresholds, lingering detection, and time-of-day awareness flag unusual patterns others miss.

+ anomaly-detection +
+
+
+

Real-Time Alerts

+

WebSocket-powered instant notifications for high and critical severity events. Email alerts for configured recipients with full event context and AI-generated analysis summaries.

+ websocket-push +
+
+
👁
+

Privacy-First Monitoring

+

Fully compliant with Oakland municipal privacy laws. Tenant tracking uses numeric IDs only, faces are blurred in all recordings, and data retention follows strict local regulations.

+ oakland-compliant +
+
+
+
+ +
+
+
+

Protected Property

+
+
+
+

Ruby Street Apartments

+
3875 Ruby St, Oakland, CA
+
+ Type + Vintage 24-Unit Apartment +
+
+ Cameras + 8 RTSP Feeds +
+
+ AI Analysis + 24/7 Active +
+
+ Event Types + Theft, Break-in, Sublease, Activity +
+
+ Reporting + Daily / Weekly / Monthly +
+
+
+
+ + Oakland Privacy Law Compliant +
+
+ + Tenant Privacy Protected (ID Only) +
+
+ + Face Blurring Active +
+
+ + Role-Based Access Control +
+
+
+
+
+ +
+
+
+

Recent Activity

+
+
+
+ event_logs / security_events +
+
+ Live +
+
+
+
+
+
+
+
+ + + + + +`; + + return new Response(html, { + headers: { + 'Content-Type': 'text/html;charset=UTF-8', + 'Cache-Control': 'public, max-age=300', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'strict-origin-when-cross-origin' + } + }); + } +}; + +export default worker_default; diff --git a/workers/security-silverbackai/wrangler.toml b/workers/security-silverbackai/wrangler.toml new file mode 100644 index 0000000..61aa5b1 --- /dev/null +++ b/workers/security-silverbackai/wrangler.toml @@ -0,0 +1,11 @@ +name = "security-silverbackai" +main = "worker.js" +compatibility_date = "2026-03-01" +account_id = "bafa242dd95d3fdce72540d20accd0a2" + +routes = [ + { pattern = "security.silverbackai.agency/*", zone_name = "silverbackai.agency" } +] + +[env.production] +name = "security-silverbackai" diff --git a/workers/silverback-ai-studio/.env.example b/workers/silverback-ai-studio/.env.example new file mode 100644 index 0000000..7a550fe --- /dev/null +++ b/workers/silverback-ai-studio/.env.example @@ -0,0 +1,9 @@ +# GEMINI_API_KEY: Required for Gemini AI API calls. +# AI Studio automatically injects this at runtime from user secrets. +# Users configure this via the Secrets panel in the AI Studio UI. +GEMINI_API_KEY="MY_GEMINI_API_KEY" + +# APP_URL: The URL where this applet is hosted. +# AI Studio automatically injects this at runtime with the Cloud Run service URL. +# Used for self-referential links, OAuth callbacks, and API endpoints. +APP_URL="MY_APP_URL" diff --git a/workers/silverback-ai-studio/README.md b/workers/silverback-ai-studio/README.md new file mode 100644 index 0000000..d02447b --- /dev/null +++ b/workers/silverback-ai-studio/README.md @@ -0,0 +1,30 @@ +# Silverback AI - Security Systems (AI Studio App) + +Comprehensive React-based security monitoring dashboard for AI-powered property surveillance at 3875 Ruby St, Oakland. + +View in AI Studio: https://ai.studio/apps/d3deeaf9-500c-40a5-a8f4-39a756d120af + +## Run Locally + +**Prerequisites:** Node.js + +1. Install dependencies: `npm install` +2. Set the `GEMINI_API_KEY` in `.env.local` to your Gemini API key +3. Run the app: `npm run dev` + +## Features +- Real-time WebSocket alert notifications with audio +- Firebase-integrated event logging and report generation +- AI-powered event analysis (Google Gemini) +- Weirdness detection algorithm +- CSV report export +- Branding asset generation via AI image synthesis +- Google Sign-In authentication +- Privacy-first architecture (Oakland law compliant) + +## Tech Stack +- React 19, Tailwind CSS 4, Framer Motion +- Firebase (Auth, Firestore) +- Google GenAI API (Gemini) +- Express + WebSocket server +- Vite build system diff --git a/workers/silverback-ai-studio/firebase-applet-config.json b/workers/silverback-ai-studio/firebase-applet-config.json new file mode 100644 index 0000000..b232b67 --- /dev/null +++ b/workers/silverback-ai-studio/firebase-applet-config.json @@ -0,0 +1,10 @@ +{ + "projectId": "custom-invoice-490714", + "appId": "1:595536838872:web:0a680a6d94249738e157ad", + "apiKey": "AIzaSyDJLSbEl1-VRGvCc5-8nE2f6w3HHWnwzYM", + "authDomain": "custom-invoice-490714.firebaseapp.com", + "firestoreDatabaseId": "ai-studio-d3deeaf9-500c-40a5-a8f4-39a756d120af", + "storageBucket": "custom-invoice-490714.firebasestorage.app", + "messagingSenderId": "595536838872", + "measurementId": "" +} diff --git a/workers/silverback-ai-studio/firebase-blueprint.json b/workers/silverback-ai-studio/firebase-blueprint.json new file mode 100644 index 0000000..0121bd1 --- /dev/null +++ b/workers/silverback-ai-studio/firebase-blueprint.json @@ -0,0 +1,145 @@ +{ + "entities": { + "EventLog": { + "title": "Event Log", + "description": "A log of a security event detected by the camera AI.", + "type": "object", + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "description": "The time the event was detected." + }, + "type": { + "type": "string", + "enum": ["theft", "break-in", "sublease_violation", "general_activity", "weirdness_alert"], + "description": "The type of event detected." + }, + "description": { + "type": "string", + "description": "A brief description of the event." + }, + "severity": { + "type": "string", + "enum": ["low", "medium", "high", "critical"], + "description": "The severity level of the event." + }, + "location": { + "type": "string", + "description": "The specific area where the event occurred." + }, + "aiAnalysis": { + "type": "string", + "description": "Detailed analysis of the clip content generated by AI." + } + }, + "required": ["timestamp", "type", "description", "severity"] + }, + "WeirdnessConfig": { + "title": "Weirdness Configuration", + "description": "Configurable parameters for the weirdness detection algorithm.", + "type": "object", + "properties": { + "normalHours": { + "type": "array", + "items": { "type": "integer" }, + "description": "List of hours considered 'normal'." + }, + "motionThreshold": { + "type": "number", + "description": "The threshold for motion detection (0.0 to 1.0)." + }, + "lingeringThreshold": { + "type": "integer", + "description": "The threshold for lingering detection (in seconds)." + }, + "emailTo": { + "type": "string", + "format": "email", + "description": "The email address to send alerts to." + }, + "smtpServer": { + "type": "string", + "description": "The SMTP server to use for sending emails." + }, + "rtspUrl": { + "type": "string", + "description": "The RTSP URL of the camera stream." + }, + "rtspUsername": { + "type": "string", + "description": "The username for the RTSP stream." + }, + "rtspPassword": { + "type": "string", + "description": "The password for the RTSP stream." + } + }, + "required": ["normalHours", "motionThreshold", "lingeringThreshold", "emailTo"] + }, + "SecurityReport": { + "title": "Security Report", + "description": "A generated summary of security events over a period.", + "type": "object", + "properties": { + "generatedAt": { + "type": "string", + "format": "date-time", + "description": "When the report was generated." + }, + "period": { + "type": "string", + "enum": ["daily", "weekly", "monthly"], + "description": "The time period covered by the report." + }, + "summary": { + "type": "string", + "description": "A summary of the findings." + }, + "eventCount": { + "type": "integer", + "description": "Total number of events in this period." + }, + "highSeverityCount": { + "type": "integer", + "description": "Number of high severity events." + }, + "status": { + "type": "string", + "enum": ["generated", "emailed", "downloaded"], + "description": "The current status of the report." + } + }, + "required": ["generatedAt", "period", "summary", "eventCount"] + }, + "UserProfile": { + "title": "User Profile", + "description": "Information about the authorized user.", + "type": "object", + "properties": { + "uid": { "type": "string" }, + "email": { "type": "string" }, + "role": { "type": "string", "enum": ["admin", "viewer"] } + }, + "required": ["uid", "email", "role"] + } + }, + "firestore": { + "/event_logs/{logId}": { + "schema": "EventLog", + "description": "Collection of all security event logs." + }, + "/security_reports/{reportId}": { + "schema": "SecurityReport", + "description": "Collection of generated security reports." + }, + "/users/{userId}": { + "schema": "UserProfile", + "description": "Authorized user profiles." + }, + "/config/weirdness": { + "schema": "WeirdnessConfig", + "description": "Global configuration for the weirdness detection algorithm." + } + } +} diff --git a/workers/silverback-ai-studio/firestore.rules b/workers/silverback-ai-studio/firestore.rules new file mode 100644 index 0000000..598e0da --- /dev/null +++ b/workers/silverback-ai-studio/firestore.rules @@ -0,0 +1,84 @@ +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + function isAuthenticated() { + return request.auth != null; + } + + function isOwner(userId) { + return isAuthenticated() && request.auth.uid == userId; + } + + function isAdmin() { + return isAuthenticated() && + (get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == 'admin' || + (request.auth.token.email == "bryan@norcalcarbmobile.com" && request.auth.token.email_verified == true)); + } + + function isValidEmail(email) { + return email is string && email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); + } + + function isValidDateString(dateStr) { + return dateStr is string && dateStr.matches("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}.*Z?$"); + } + + function isValidUser(data) { + return data.keys().hasAll(['uid', 'email', 'role']) && + data.uid == request.auth.uid && + isValidEmail(data.email) && + data.role in ['admin', 'viewer']; + } + + function isValidEventLog(data) { + return data.keys().hasAll(['timestamp', 'type', 'description', 'severity']) && + isValidDateString(data.timestamp) && + data.type in ['theft', 'break-in', 'sublease_violation', 'general_activity', 'weirdness_alert'] && + data.description is string && data.description.size() < 1000 && + data.severity in ['low', 'medium', 'high', 'critical'] && + (!('aiAnalysis' in data) || (data.aiAnalysis is string && data.aiAnalysis.size() < 5000)); + } + + function isValidWeirdnessConfig(data) { + return data.keys().hasAll(['normalHours', 'motionThreshold', 'lingeringThreshold', 'emailTo']) && + data.normalHours is list && + data.motionThreshold is number && + data.lingeringThreshold is int && + isValidEmail(data.emailTo) && + (!('rtspUrl' in data) || data.rtspUrl is string) && + (!('rtspUsername' in data) || data.rtspUsername is string) && + (!('rtspPassword' in data) || data.rtspPassword is string); + } + + function isValidSecurityReport(data) { + return data.keys().hasAll(['generatedAt', 'period', 'summary', 'eventCount']) && + isValidDateString(data.generatedAt) && + data.period in ['daily', 'weekly', 'monthly'] && + data.summary is string && data.summary.size() < 5000 && + data.eventCount is int; + } + + match /users/{userId} { + allow read: if isOwner(userId) || isAdmin(); + allow create: if isOwner(userId) && isValidUser(request.resource.data) && (request.resource.data.role == 'viewer' || isAdmin()); + allow update: if isOwner(userId) && isValidUser(request.resource.data) && (request.resource.data.role == resource.data.role || isAdmin()); + } + + match /event_logs/{logId} { + allow read: if isAuthenticated(); + allow create: if isAdmin() && isValidEventLog(request.resource.data); + allow update, delete: if isAdmin(); + } + + match /security_reports/{reportId} { + allow read: if isAuthenticated(); + allow create: if isAdmin() && isValidSecurityReport(request.resource.data); + allow update, delete: if isAdmin(); + } + + match /config/weirdness { + allow read: if isAuthenticated(); + allow write: if isAdmin() && isValidWeirdnessConfig(request.resource.data); + } + } +} diff --git a/workers/silverback-ai-studio/index.html b/workers/silverback-ai-studio/index.html new file mode 100644 index 0000000..ab6e008 --- /dev/null +++ b/workers/silverback-ai-studio/index.html @@ -0,0 +1,12 @@ + + + + + + Silverback AI - Security Systems + + +
+ + + diff --git a/workers/silverback-ai-studio/metadata.json b/workers/silverback-ai-studio/metadata.json new file mode 100644 index 0000000..44f5aa4 --- /dev/null +++ b/workers/silverback-ai-studio/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "SILVERBACK AI - SECURITY", + "description": "Advanced AI Security for vintage 24-unit apartment buildings. Protecting 3875 Ruby St with privacy-first monitoring compliant with Oakland laws.", + "requestFramePermissions": [] +} diff --git a/workers/silverback-ai-studio/package.json b/workers/silverback-ai-studio/package.json new file mode 100644 index 0000000..4ea0f2d --- /dev/null +++ b/workers/silverback-ai-studio/package.json @@ -0,0 +1,37 @@ +{ + "name": "react-example", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "tsx server.ts", + "build": "vite build", + "preview": "vite preview", + "clean": "rm -rf dist", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@google/genai": "^1.29.0", + "@tailwindcss/vite": "^4.1.14", + "@vitejs/plugin-react": "^5.0.4", + "dotenv": "^17.2.3", + "express": "^4.21.2", + "firebase": "^12.11.0", + "lucide-react": "^0.546.0", + "motion": "^12.23.24", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^6.2.0", + "ws": "^8.20.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.14.0", + "@types/ws": "^8.18.1", + "autoprefixer": "^10.4.21", + "tailwindcss": "^4.1.14", + "tsx": "^4.21.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } +} diff --git a/workers/silverback-ai-studio/server.ts b/workers/silverback-ai-studio/server.ts new file mode 100644 index 0000000..bcd8500 --- /dev/null +++ b/workers/silverback-ai-studio/server.ts @@ -0,0 +1,88 @@ +import express from "express"; +import { createServer } from "http"; +import { WebSocketServer, WebSocket } from "ws"; +import { createServer as createViteServer } from "vite"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +async function startServer() { + const app = express(); + const server = createServer(app); + const wss = new WebSocketServer({ server }); + const PORT = 3000; + + app.use(express.json()); + + // Store connected clients + const clients = new Set(); + + wss.on("connection", (ws) => { + clients.add(ws); + console.log("Client connected to WebSocket"); + + ws.on("close", () => { + clients.delete(ws); + console.log("Client disconnected"); + }); + }); + + // Broadcast function + const broadcast = (data: any) => { + const message = JSON.stringify(data); + clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + } + }); + }; + + // API to trigger high-severity alerts (for testing/demo) + app.post("/api/trigger-alert", (req, res) => { + const { type, description, severity, location } = req.body; + + if (severity === 'high' || severity === 'critical') { + const alert = { + id: Date.now().toString(), + timestamp: new Date().toISOString(), + type: type || 'weirdness_alert', + description: description || 'High-severity event detected!', + severity: severity || 'high', + location: location || 'Main Entrance', + }; + + broadcast({ type: 'ALERT', payload: alert }); + return res.json({ status: "Alert broadcasted", alert }); + } + + res.status(400).json({ error: "Only high or critical severity alerts are broadcasted via WebSocket" }); + }); + + // Health check + app.get("/api/health", (req, res) => { + res.json({ status: "ok", clients: clients.size }); + }); + + // Vite middleware for development + if (process.env.NODE_ENV !== "production") { + const vite = await createViteServer({ + server: { middlewareMode: true }, + appType: "spa", + }); + app.use(vite.middlewares); + } else { + const distPath = path.join(process.cwd(), 'dist'); + app.use(express.static(distPath)); + app.get('*', (req, res) => { + res.sendFile(path.join(distPath, 'index.html')); + }); + } + + server.listen(PORT, "0.0.0.0", () => { + console.log(`Server running on http://localhost:${PORT}`); + }); +} + +startServer().catch(console.error); diff --git a/workers/silverback-ai-studio/src/App.tsx b/workers/silverback-ai-studio/src/App.tsx new file mode 100644 index 0000000..76017d2 --- /dev/null +++ b/workers/silverback-ai-studio/src/App.tsx @@ -0,0 +1,1673 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect } from "react"; +import { motion, AnimatePresence } from "motion/react"; +import { + Shield, + Lock, + Camera, + AlertTriangle, + UserCheck, + MapPin, + CheckCircle2, + Cpu, + FileText, + Download, + Mail, + Plus, + LogOut, + LogIn, + Loader2, + X, + History, + Settings, + Activity, + Zap, + Clock, + Palette, + Bell +} from "lucide-react"; +import { + auth, + db, + googleProvider, + signInWithPopup, + signOut, + onAuthStateChanged, + collection, + addDoc, + query, + orderBy, + onSnapshot, + doc, + setDoc, + getDoc, + updateDoc, + where, + limit, + User +} from "./firebase"; +import { GoogleGenAI } from "@google/genai"; + +// --- Types --- +enum OperationType { + CREATE = 'create', + UPDATE = 'update', + DELETE = 'delete', + LIST = 'list', + GET = 'get', + WRITE = 'write', +} + +interface FirestoreErrorInfo { + error: string; + operationType: OperationType; + path: string | null; + authInfo: { + userId: string | undefined; + email: string | null | undefined; + emailVerified: boolean | undefined; + isAnonymous: boolean | undefined; + tenantId: string | null | undefined; + providerInfo: { + providerId: string; + displayName: string | null; + email: string | null; + photoUrl: string | null; + }[]; + } +} + +interface EventLog { + id?: string; + timestamp: string; + type: 'theft' | 'break-in' | 'sublease_violation' | 'general_activity' | 'weirdness_alert'; + description: string; + severity: 'low' | 'medium' | 'high' | 'critical'; + location?: string; + aiAnalysis?: string; +} + +interface WeirdnessConfig { + normalHours: number[]; + motionThreshold: number; + lingeringThreshold: number; + emailTo: string; + smtpServer: string; + rtspUrl?: string; + rtspUsername?: string; + rtspPassword?: string; +} + +interface SecurityReport { + id?: string; + generatedAt: string; + period: 'daily' | 'weekly' | 'monthly'; + summary: string; + eventCount: number; + highSeverityCount: number; + status: 'generated' | 'emailed' | 'downloaded'; +} + +// --- Presentation Component --- +function PresentationView({ onClose, currentSlide, setCurrentSlide }: { onClose: () => void, currentSlide: number, setCurrentSlide: (s: number) => void }) { + const slides = [ + { + title: "SILVERBACK AI", + subtitle: "Next-Generation Security Systems", + content: "Protecting 3875 Ruby St with intelligent camera recognition and real-time weirdness detection. Secure, private, and compliant.", + bg: "https://images.unsplash.com/photo-1550751827-4bd374c3f58b?auto=format&fit=crop&q=80&w=1200" + }, + { + title: "The Challenge", + subtitle: "Vintage Property Security", + content: "Vintage 24-unit, 3-story apartment buildings present unique security challenges. Silverback AI provides proactive monitoring while respecting tenant privacy.", + bg: "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&q=80&w=1200" + }, + { + title: "AI Tracking Demo", + subtitle: "Front Door & Mailboxes", + isDemo: true, + content: "Our AI tracks activity at the front door and mailboxes. Tenants are identified only by numeric IDs. Owners only receive footage during 'outside normal operations' alerts.", + bg: "https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&q=80&w=1200" + }, + { + title: "Data Sovereignty", + subtitle: "Oakland Law Compliance", + content: "All footage is stored on a secure Virtual Machine (VM). Access is restricted: footage is only passed to police upon their official request for an incident, in accordance with Oakland laws and tenant rights.", + bg: "https://images.unsplash.com/photo-1558494949-ef010cbdcc51?auto=format&fit=crop&q=80&w=1200" + } + ]; + + const nextSlide = () => setCurrentSlide((currentSlide + 1) % slides.length); + const prevSlide = () => setCurrentSlide((currentSlide - 1 + slides.length) % slides.length); + + return ( +
+
+ +
+ + + + {/* Background Image */} +
+ Background +
+
+ +
+ + + {slides[currentSlide].subtitle} + +

+ {slides[currentSlide].title} +

+ + {slides[currentSlide].isDemo ? ( +
+ Lobby Demo + {/* AI Tracking Overlay */} +
+ {/* Person 1 */} + +
+ Person #042 - Tracking +
+ {/* Privacy Box (Shoulders Up) */} +
+ Privacy Shield +
+
+ + {/* Person 2 */} + +
+ Person #109 - Tracking +
+ {/* Privacy Box (Shoulders Up) */} +
+ Privacy Shield +
+
+ + {/* Scanning Line */} + +
+ +
+
+ Live Feed - Front Door & Mailboxes +
+
+ ) : ( +

+ {slides[currentSlide].content} +

+ )} + +
+
+ + + {/* Controls */} +
+
+ {slides.map((_, i) => ( +
+ ))} +
+ +
+ + +
+
+
+ ); +} + +// --- Error Handling --- +function handleFirestoreError(error: unknown, operationType: OperationType, path: string | null) { + const errInfo: FirestoreErrorInfo = { + error: error instanceof Error ? error.message : String(error), + authInfo: { + userId: auth.currentUser?.uid, + email: auth.currentUser?.email, + emailVerified: auth.currentUser?.emailVerified, + isAnonymous: auth.currentUser?.isAnonymous, + tenantId: auth.currentUser?.tenantId, + providerInfo: auth.currentUser?.providerData.map(provider => ({ + providerId: provider.providerId, + displayName: provider.displayName, + email: provider.email, + photoUrl: provider.photoURL + })) || [] + }, + operationType, + path + }; + console.error('Firestore Error: ', JSON.stringify(errInfo)); + throw new Error(JSON.stringify(errInfo)); +} + +// --- Components --- +function NotificationToast({ notification, onClose }: { notification: EventLog, onClose: () => void, key?: string | number }) { + return ( + +
+
+
+ +
+
+
+ High Severity Alert + +
+

+ {notification.type.replace('_', ' ')} +

+

+ {notification.description} +

+
+
+ + {notification.location || 'Unknown Location'} +
+ + {new Date(notification.timestamp).toLocaleTimeString()} + +
+
+
+ + {/* Animated Scanning Bar */} + + + ); +} + +function ErrorBoundary({ children }: { children: React.ReactNode }) { + const [error, setError] = useState(null); + + useEffect(() => { + const handleError = (e: ErrorEvent) => { + try { + const parsed = JSON.parse(e.message); + if (parsed.error) { + setError(`Security Error: ${parsed.error} during ${parsed.operationType} on ${parsed.path}`); + } + } catch { + setError(e.message); + } + }; + window.addEventListener('error', handleError); + return () => window.removeEventListener('error', handleError); + }, []); + + if (error) { + return ( +
+
+ +

System Error

+

{error}

+ +
+
+ ); + } + return <>{children}; +} + +export default function App() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [reports, setReports] = useState([]); + const [events, setEvents] = useState([]); + const [isGenerating, setIsGenerating] = useState(false); + const [isAnalyzing, setIsAnalyzing] = useState(null); + const [showDashboard, setShowDashboard] = useState(false); + const [dashboardTab, setDashboardTab] = useState<'overview' | 'weirdness' | 'branding' | 'config'>('overview'); + const [isGeneratingAsset, setIsGeneratingAsset] = useState(false); + const [generatedAssetUrl, setGeneratedAssetUrl] = useState(null); + const [selectedAssetType, setSelectedAssetType] = useState('App Icon'); + const [selectedStyle, setSelectedStyle] = useState('High-Tech'); + const [weirdnessConfig, setWeirdnessConfig] = useState({ + normalHours: Array.from({ length: 16 }, (_, i) => i + 6), // 6am-10pm + motionThreshold: 0.05, + lingeringThreshold: 30, + emailTo: "bryan@norcalcarbmobile.com", + smtpServer: "smtp.gmail.com", + rtspUrl: "", + rtspUsername: "", + rtspPassword: "" + }); + + const [view, setView] = useState<'dashboard' | 'presentation'>('dashboard'); + const [currentSlide, setCurrentSlide] = useState(0); + const [notifications, setNotifications] = useState([]); + + // --- WebSocket Connection --- + useEffect(() => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}`; + const socket = new WebSocket(wsUrl); + + socket.onopen = () => { + console.log('Connected to real-time notification server'); + }; + + socket.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === 'ALERT') { + const alert = data.payload as EventLog; + setNotifications(prev => [alert, ...prev]); + + // Play alert sound if possible + try { + const audio = new Audio('https://assets.mixkit.co/active_storage/sfx/2869/2869-preview.mp3'); + audio.volume = 0.5; + audio.play().catch(() => {}); + } catch (e) { + console.warn("Audio playback failed", e); + } + + // Auto-remove notification after 10 seconds + setTimeout(() => { + setNotifications(prev => prev.filter(n => n.id !== alert.id)); + }, 10000); + } + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }; + + socket.onclose = () => { + console.log('Disconnected from notification server'); + }; + + return () => socket.close(); + }, []); + + // --- Auth --- + useEffect(() => { + const unsubscribe = onAuthStateChanged(auth, async (currentUser) => { + setUser(currentUser); + if (currentUser) { + // Sync user profile to Firestore + const userRef = doc(db, 'users', currentUser.uid); + try { + const userSnap = await getDoc(userRef); + if (!userSnap.exists()) { + await setDoc(userRef, { + uid: currentUser.uid, + email: currentUser.email, + role: currentUser.email === "bryan@norcalcarbmobile.com" ? 'admin' : 'viewer' + }); + } + } catch (error) { + console.error("Error syncing user profile:", error); + } + } + setLoading(false); + }); + return () => unsubscribe(); + }, []); + + // --- Data Fetching --- + useEffect(() => { + if (!user) return; + + const reportsQuery = query(collection(db, 'security_reports'), orderBy('generatedAt', 'desc'), limit(10)); + const eventsQuery = query(collection(db, 'event_logs'), orderBy('timestamp', 'desc'), limit(20)); + + const unsubReports = onSnapshot(reportsQuery, (snapshot) => { + setReports(snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as SecurityReport))); + }, (error) => handleFirestoreError(error, OperationType.LIST, 'security_reports')); + + const unsubEvents = onSnapshot(eventsQuery, (snapshot) => { + setEvents(snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as EventLog))); + }, (error) => handleFirestoreError(error, OperationType.LIST, 'event_logs')); + + const configRef = doc(db, 'config', 'weirdness'); + const unsubConfig = onSnapshot(configRef, (snapshot) => { + if (snapshot.exists()) { + setWeirdnessConfig(snapshot.data() as WeirdnessConfig); + } + }, (error) => handleFirestoreError(error, OperationType.GET, 'config/weirdness')); + + return () => { + unsubReports(); + unsubEvents(); + unsubConfig(); + }; + }, [user]); + + const saveWeirdnessConfig = async (newConfig: Partial) => { + if (!user) return; + const configRef = doc(db, 'config', 'weirdness'); + try { + await setDoc(configRef, { ...weirdnessConfig, ...newConfig }); + } catch (error) { + handleFirestoreError(error, OperationType.WRITE, 'config/weirdness'); + } + }; + + const analyzeEvent = async (event: EventLog) => { + if (!user || !event.id) return; + setIsAnalyzing(event.id); + + try { + const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); + const response = await ai.models.generateContent({ + model: "gemini-3-flash-preview", + contents: `Analyze this security event clip description and provide a more detailed, professional security assessment. + Event Type: ${event.type} + Initial Description: ${event.description} + Severity: ${event.severity} + + Provide a detailed breakdown of what might be happening, potential risks, and recommended actions. Keep it concise but professional.`, + }); + + const analysis = response.text || "No analysis available."; + + const eventRef = doc(db, 'event_logs', event.id); + await updateDoc(eventRef, { aiAnalysis: analysis }); + } catch (error) { + console.error("AI Analysis failed:", error); + handleFirestoreError(error, OperationType.UPDATE, `event_logs/${event.id}`); + } finally { + setIsAnalyzing(null); + } + }; + + const generateBrandingAsset = async () => { + if (isGeneratingAsset) return; + + setIsGeneratingAsset(true); + try { + // Check for API key + if (!(await (window as any).aistudio.hasSelectedApiKey())) { + await (window as any).aistudio.openSelectKey(); + } + + const ai = new GoogleGenAI({ apiKey: (process.env as any).API_KEY }); + const prompt = `A professional, modern, and minimalist ${selectedAssetType} for an AI security company named "Silverback AI". The style should be ${selectedStyle}. The logo should feature a powerful and sleek silverback gorilla head integrated with digital or circuit patterns to represent AI. Use a color palette of silver, charcoal gray, and a vibrant security orange. The style should be clean, high-tech, and authoritative. No text in the image.`; + + const response = await ai.models.generateContent({ + model: 'gemini-2.5-flash-image', + contents: { + parts: [{ text: prompt }], + }, + config: { + imageConfig: { + aspectRatio: "1:1", + }, + }, + }); + + let foundImage = false; + for (const part of response.candidates?.[0]?.content?.parts || []) { + if (part.inlineData) { + const base64Data = part.inlineData.data; + setGeneratedAssetUrl(`data:image/png;base64,${base64Data}`); + foundImage = true; + break; + } + } + + if (!foundImage) { + console.warn("No image data found in response"); + } + } catch (error) { + console.error("Error generating branding asset:", error); + // If requested entity not found, reset key + if (error instanceof Error && error.message.includes("Requested entity was not found")) { + await (window as any).aistudio.openSelectKey(); + } + } finally { + setIsGeneratingAsset(false); + } + }; + + const downloadReportCSV = (report: SecurityReport) => { + const reportDate = new Date(report.generatedAt); + let startDate = new Date(reportDate); + + if (report.period === 'daily') { + startDate.setDate(reportDate.getDate() - 1); + } else if (report.period === 'weekly') { + startDate.setDate(reportDate.getDate() - 7); + } else if (report.period === 'monthly') { + startDate.setMonth(reportDate.getMonth() - 1); + } + + const filteredEvents = events.filter(event => { + const eventDate = new Date(event.timestamp); + return eventDate >= startDate && eventDate <= reportDate; + }); + + if (filteredEvents.length === 0) { + alert("No events found for this report period."); + return; + } + + const headers = ["ID", "Timestamp", "Type", "Severity", "Description", "Location", "AI Analysis"]; + const csvContent = [ + headers.join(","), + ...filteredEvents.map(e => [ + `"${(e.id || "").replace(/"/g, '""')}"`, + `"${e.timestamp.replace(/"/g, '""')}"`, + `"${e.type.replace(/"/g, '""')}"`, + `"${e.severity.replace(/"/g, '""')}"`, + `"${e.description.replace(/"/g, '""')}"`, + `"${(e.location || "").replace(/"/g, '""')}"`, + `"${(e.aiAnalysis || "").replace(/"/g, '""')}"` + ].join(",")) + ].join("\n"); + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.setAttribute("href", url); + link.setAttribute("download", `Silverback_Report_${report.period}_${report.generatedAt.split('T')[0]}.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleLogin = async () => { + try { + await signInWithPopup(auth, googleProvider); + } catch (error) { + console.error("Login failed:", error); + } + }; + + const handleLogout = () => signOut(auth); + + const generateReport = async (period: 'daily' | 'weekly' | 'monthly') => { + if (!user) return; + setIsGenerating(true); + + try { + // Simulate AI processing + await new Promise(resolve => setTimeout(resolve, 2000)); + + const highSeverityCount = events.filter(e => e.severity === 'high').length; + + const newReport: Omit = { + generatedAt: new Date().toISOString(), + period, + summary: `AI Analysis complete for 3875 Ruby St. Detected ${events.length} total events. ${highSeverityCount} high-priority alerts require immediate attention. Perimeter integrity remains stable.`, + eventCount: events.length, + highSeverityCount, + status: 'generated' + }; + + await addDoc(collection(db, 'security_reports'), newReport); + } catch (error) { + handleFirestoreError(error, OperationType.CREATE, 'security_reports'); + } finally { + setIsGenerating(false); + } + }; + + const addMockEvent = async (typeOverride?: EventLog['type']) => { + if (!user) return; + const types: EventLog['type'][] = ['theft', 'break-in', 'sublease_violation', 'general_activity', 'weirdness_alert']; + const severities: EventLog['severity'][] = ['low', 'medium', 'high', 'critical']; + + const type = typeOverride || types[Math.floor(Math.random() * types.length)]; + const isWeird = type === 'weirdness_alert'; + + const mockEvent: Omit = { + timestamp: new Date().toISOString(), + type: type, + description: isWeird + ? "WEIRD: Lingering stranger detected near the rear entrance for >30s." + : "AI detected suspicious movement near the rear entrance of 3875 Ruby St.", + severity: isWeird ? 'high' : severities[Math.floor(Math.random() * severities.length)], + location: "Rear Entrance" + }; + + try { + const docRef = await addDoc(collection(db, 'event_logs'), mockEvent); + + // If high severity, trigger real-time alert via server + if (mockEvent.severity === 'high' || mockEvent.severity === 'critical') { + fetch('/api/trigger-alert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...mockEvent, id: docRef.id }) + }).catch(err => console.error("Failed to trigger server alert:", err)); + } + } catch (error) { + handleFirestoreError(error, OperationType.CREATE, 'event_logs'); + } + }; + + if (view === 'presentation') { + return ( + setView('dashboard')} + currentSlide={currentSlide} + setCurrentSlide={setCurrentSlide} + /> + ); + } + + if (loading) { + return ( +
+ +
+ ); + } + + return ( + +
+ {/* Real-time Notifications */} + + {notifications.map((notification, idx) => ( + setNotifications(prev => prev.filter(n => (n.id || n.timestamp) !== (notification.id || notification.timestamp)))} + /> + ))} + + + {/* Navigation */} + + + {!showDashboard ? ( + <> + {/* Hero Section */} +
+
+
+
+
+ +
+ +
+ + SILVERBACK AI - SECURITY: Secure and Smart +
+

+ Advanced AI Security for 3875 Ruby St. +

+

+ Protecting Oakland property with intelligent camera recognition. + Stop theft, prevent break-ins, and verify subleases without compromising resident privacy. +

+
+ {user ? ( + + ) : ( + + )} + +
+
+
+
+ + {/* Stats/Location Bar */} +
+
+
+
+ +
+
+
Deployment Site
+
3875 Ruby St, Oakland
+
+
+
+
+ +
+
+
AI Engine
+
Silverback Edge V2.0
+
+
+
+
+ +
+
+
Status
+
Active & Monitoring
+
+
+
+
+ + {/* Features Grid */} +
+
+
+

Built for Modern Security

+

+ Traditional cameras just record. Silverback AI understands. + Our software identifies patterns and anomalies in real-time. +

+
+ +
+ {[ + { + icon: , + title: "Theft Prevention", + description: "Real-time AI monitoring to detect and deter theft before it happens." + }, + { + icon: , + title: "Break-in Protection", + description: "Smart perimeter alerts that distinguish between residents and intruders." + }, + { + icon: , + title: "Sublease Verification", + description: "Identify unauthorized occupants and prove sublease violations with visual evidence." + }, + { + icon: , + title: "Weirdness Detection", + description: "Proprietary algorithm to detect 'weird' behavior like lingering strangers or high-speed motion after hours." + }, + { + icon: , + title: "Privacy First", + description: "Edge-processed AI ensures recognition happens locally. No data leaks, total privacy." + } + ].map((feature, idx) => ( + +
+ {feature.icon} +
+

{feature.title}

+

+ {feature.description} +

+
+ ))} +
+
+
+ + {/* Hardware Strategy Section */} +
+
+
+
+

The Hardware Strategy

+

+ Silverback AI is designed to run on what you already own. While many systems require expensive proprietary hubs, + we recommend the "Old Laptop" approach for 3875 Ruby St. +

+ +
+
+

Why Old Laptops?

+
    +
  • • Zero cost (repurpose old gear)
  • +
  • • Built-in battery backup (UPS)
  • +
  • • Superior cooling for garage environments
  • +
  • • Integrated screen for easy debugging
  • +
+
+
+

The Tapo Advantage

+
    +
  • • Tapo C120: High-res, low-light
  • +
  • • RTSP support for AI integration
  • +
  • • USB-C power simplicity
  • +
  • • Magnetic mount for quick positioning
  • +
+
+
+
+ +
+

+ + Deployment Checklist +

+
+ {[ + { step: "01", title: "Prep the Base", desc: "Dust off that old laptop, plug it in a secure garage corner." }, + { step: "02", title: "Install Core", desc: "Python + OpenCV, NumPy, and Schedule. Takes less than 5 mins." }, + { step: "03", title: "Mount Camera", desc: "Place Tapo C120 high near mailboxes. Hide cord behind trim." }, + { step: "04", title: "Enable RTSP", desc: "Get the URL from the Tapo app. Note the local IP address." }, + { step: "05", title: "Run Silverback", desc: "Paste RTSP URL into the script and launch the AI engine." } + ].map((item, i) => ( +
+ {item.step} +
+
{item.title}
+

{item.desc}

+
+
+ ))} +
+
+
+ +

+ PRO TIP: Ensure the laptop is set to "Never Sleep" when lid is closed for 24/7 monitoring. +

+
+
+
+
+
+
+ + {/* Privacy Section */} +
+
+
+

Security Without Surveillance

+

+ We believe privacy is a human right. Silverback AI uses advanced anonymization + techniques to track events and behaviors without storing personal identity data + unless a security breach is detected. +

+
    + {[ + "Local-only data processing", + "Automatic face blurring for non-events", + "Encrypted evidence storage", + "GDPR & CCPA compliant architecture" + ].map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+
+
+
+ +
SYSTEM_LOG: ENCRYPTED
+
+ Privacy Shield Active.
+ Monitoring 3875 Ruby St. +
+
+
+
+
+
+ + {/* CTA Section */} +
+
+
+
+

+ Ready to secure your property? +

+

+ Join the future of Oakland property management. Secure, smart, and privacy-focused. +

+ +
+
+
+ + ) : ( + /* Dashboard Section */ + +
+
+

Security Dashboard

+

Manage reports and monitor events for 3875 Ruby St.

+
+
+ {(['overview', 'weirdness', 'branding', 'config'] as const).map((tab) => ( + + ))} +
+
+ + +
+ +
+ {(['daily', 'weekly', 'monthly'] as const).map(p => ( + + ))} +
+
+
+
+ + {dashboardTab === 'overview' && ( +
+ {/* Reports List */} +
+

+ + Recent Reports +

+
+ + {reports.map((report) => ( + +
+
+
+ +
+
+

{report.period} Security Summary

+

{new Date(report.generatedAt).toLocaleString()}

+
+
+
+ + +
+
+

{report.summary}

+
+
+ {report.eventCount} Events +
+
+ {report.highSeverityCount} High Priority +
+
+
+ ))} +
+ {reports.length === 0 && ( +
+ +

No reports generated yet.

+
+ )} +
+
+ + {/* Live Events Feed */} +
+

+ + Live Event Feed +

+
+
+ {events.map((event) => ( +
+
+
+
+ + {event.type.replace('_', ' ')} + + + {new Date(event.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + +
+

{event.description}

+ + {event.aiAnalysis ? ( +
+
+ + AI Analysis +
+

+ {event.aiAnalysis} +

+
+ ) : ( + + )} +
+
+ ))} + {events.length === 0 && ( +

Waiting for events...

+ )} +
+
+
+
+ )} + + {dashboardTab === 'weirdness' && ( +
+
+
+
+

+ + Weirdness Monitor +

+
+ + Algorithm Active +
+
+ +
+
+
Current Activity Level
+
0.024%
+
Normal baseline: < 0.03%
+
+
+
Last Detection
+
None
+
No weirdness in last 24h
+
+
+ +
+

Active Detection Rules

+
+
+
+ + Off-Hours Motion (>5% change) +
+ ACTIVE +
+
+
+ + Lingering Stranger (>30s detection) +
+ ACTIVE +
+
+
+
+ +
+

Algorithm Logic (weird_detector.py)

+
+{`def is_weird(frame, timestamp):
+    # ... processing ...
+    motion_level = cv2.countNonZero(thresh) / total_pixels
+    
+    # "Weird" rules:
+    if motion_level > 0.05 and hour not in NORMAL_HOURS:
+        return True, "Fast motion after hours"
+    if motion_level > 0.03 and lingering_time > 30:
+        return True, "Lingering stranger"
+    return False, None`}
+                    
+
+
+ +
+

+ + Weirdness Alerts +

+
+
+ +
+

No weird events detected in the current session.

+ +
+
+
+ )} + + {dashboardTab === 'branding' && ( +
+
+
+
+

+ + Silverback Branding Studio +

+

Generate high-quality branding assets for your Silverback security systems.

+
+
+ + Powered by Nano Banana +
+
+ +
+
+
+ +
+ {['App Icon', 'Security Sign', 'Vehicle Decal', 'Uniform Patch'].map(type => ( + + ))} +
+
+ +
+ +
+ {['High-Tech', 'Minimalist', 'Aggressive', 'Professional', 'Futuristic'].map(style => ( + + ))} +
+
+ +
+ +

+ Estimated generation time: 12.4s +

+
+
+ +
+
+
+ {generatedAssetUrl ? ( +
+ Generated Branding Asset +
+ + +
+
+ ) : ( + <> +
+ +
+

Ready to Generate

+

Select an asset type and style to begin the AI branding process.

+ + )} +
+
+
+
+ +
+
+
Active Palette
+
+
+
+
+
+
+
+
+
+
Brand Font
+
Inter Display
+
+
Aa
+
+
+
+
Mono Font
+
JetBrains
+
+
01
+
+
+
+ )} + + {dashboardTab === 'config' && ( +
+
+

+ + Algorithm Configuration +

+ +
+
+
+ + { + const start = parseInt(e.target.value); + const end = weirdnessConfig.normalHours[weirdnessConfig.normalHours.length - 1]; + const newHours = Array.from({ length: end - start + 1 }, (_, i) => i + start); + setWeirdnessConfig({ ...weirdnessConfig, normalHours: newHours }); + }} + className="w-full bg-black border border-white/10 rounded-lg px-4 py-3 text-white focus:border-orange-500 outline-none transition-all" + /> +
+
+ + { + const end = parseInt(e.target.value); + const start = weirdnessConfig.normalHours[0]; + const newHours = Array.from({ length: end - start + 1 }, (_, i) => i + start); + setWeirdnessConfig({ ...weirdnessConfig, normalHours: newHours }); + }} + className="w-full bg-black border border-white/10 rounded-lg px-4 py-3 text-white focus:border-orange-500 outline-none transition-all" + /> +
+
+ +
+ + setWeirdnessConfig({ ...weirdnessConfig, motionThreshold: parseInt(e.target.value) / 100 })} + className="w-full accent-orange-500" + /> +
+ 1% (Sensitive) + {Math.round(weirdnessConfig.motionThreshold * 100)}% (Current) + 20% (Low) +
+
+ +
+ + setWeirdnessConfig({ ...weirdnessConfig, emailTo: e.target.value })} + className="w-full bg-black border border-white/10 rounded-lg px-4 py-3 text-white focus:border-orange-500 outline-none transition-all" + /> +
+ +
+

Camera Stream Configuration

+
+
+ + setWeirdnessConfig({ ...weirdnessConfig, rtspUrl: e.target.value })} + className="w-full bg-black border border-white/10 rounded-lg px-4 py-3 text-white focus:border-orange-500 outline-none transition-all" + /> +
+
+
+ + setWeirdnessConfig({ ...weirdnessConfig, rtspUsername: e.target.value })} + className="w-full bg-black border border-white/10 rounded-lg px-4 py-3 text-white focus:border-orange-500 outline-none transition-all" + /> +
+
+ + setWeirdnessConfig({ ...weirdnessConfig, rtspPassword: e.target.value })} + className="w-full bg-black border border-white/10 rounded-lg px-4 py-3 text-white focus:border-orange-500 outline-none transition-all" + /> +
+
+
+
+ +
+ +
+
+
+
+ )} + + {/* Privacy & Compliance Section */} +
+
+ +

Privacy & Compliance

+
+
+
+

+ Tenant Privacy: Tenants are identified only by anonymized numeric IDs. No facial data or PII is stored in the primary detection database. +

+

+ Data Sovereignty: All footage is stored on a secure, isolated Virtual Machine (VM). Owners only receive footage alerts for "outside normal operations" events. +

+
+
+

+ Law Enforcement: Footage is only shared with police upon official request for specific incidents, ensuring full compliance with Oakland surveillance ordinances. +

+

+ Rights: Our systems are designed to respect tenant rights and local laws while maintaining property security. +

+
+
+
+ + )} + + {/* Footer */} +
+
+
+
+ SAI +
+
+ SILVERBACK AI + Security +
+
+
+ © 2026 SILVERBACK AI - SECURITY. Secure and Smart. 3875 Ruby St, Oakland.
+ Compliant with Oakland Surveillance Ordinances & Tenant Rights. +
+ +
+
+
+ + ); +} diff --git a/workers/silverback-ai-studio/src/firebase.ts b/workers/silverback-ai-studio/src/firebase.ts new file mode 100644 index 0000000..ccef822 --- /dev/null +++ b/workers/silverback-ai-studio/src/firebase.ts @@ -0,0 +1,12 @@ +import { initializeApp } from 'firebase/app'; +import { getAuth, GoogleAuthProvider, signInWithPopup, signOut, onAuthStateChanged, User } from 'firebase/auth'; +import { getFirestore, collection, addDoc, getDocs, query, orderBy, limit, onSnapshot, doc, getDoc, setDoc, updateDoc, Timestamp, where } from 'firebase/firestore'; +import firebaseConfig from '../firebase-applet-config.json'; + +const app = initializeApp(firebaseConfig); +export const auth = getAuth(app); +export const db = getFirestore(app, firebaseConfig.firestoreDatabaseId); +export const googleProvider = new GoogleAuthProvider(); + +export { signInWithPopup, signOut, onAuthStateChanged, collection, addDoc, getDocs, query, orderBy, limit, onSnapshot, doc, getDoc, setDoc, updateDoc, Timestamp, where }; +export type { User }; diff --git a/workers/silverback-ai-studio/src/index.css b/workers/silverback-ai-studio/src/index.css new file mode 100644 index 0000000..b4e9ccb --- /dev/null +++ b/workers/silverback-ai-studio/src/index.css @@ -0,0 +1,27 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap'); +@import "tailwindcss"; + +@theme { + --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace; + + --color-brand-orange: #f97316; + --color-brand-silver: #94a3b8; + --color-brand-charcoal: #1e293b; + --color-brand-black: #020617; +} + +@layer base { + body { + @apply bg-brand-black text-zinc-100; + } +} + +.glass-card { + @apply bg-white/5 backdrop-blur-xl border border-white/10 rounded-2xl; +} + +.security-gradient { + background: radial-gradient(circle at top right, rgba(249, 115, 22, 0.15), transparent 40%), + radial-gradient(circle at bottom left, rgba(148, 163, 184, 0.05), transparent 40%); +} diff --git a/workers/silverback-ai-studio/src/main.tsx b/workers/silverback-ai-studio/src/main.tsx new file mode 100644 index 0000000..080dac3 --- /dev/null +++ b/workers/silverback-ai-studio/src/main.tsx @@ -0,0 +1,10 @@ +import {StrictMode} from 'react'; +import {createRoot} from 'react-dom/client'; +import App from './App.tsx'; +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/workers/silverback-ai-studio/tsconfig.json b/workers/silverback-ai-studio/tsconfig.json new file mode 100644 index 0000000..d88f175 --- /dev/null +++ b/workers/silverback-ai-studio/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "allowJs": true, + "jsx": "react-jsx", + "paths": { + "@/*": [ + "./*" + ] + }, + "allowImportingTsExtensions": true, + "noEmit": true + } +} diff --git a/workers/silverback-ai-studio/vite.config.ts b/workers/silverback-ai-studio/vite.config.ts new file mode 100644 index 0000000..6b1fbc3 --- /dev/null +++ b/workers/silverback-ai-studio/vite.config.ts @@ -0,0 +1,22 @@ +import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; +import {defineConfig, loadEnv} from 'vite'; + +export default defineConfig(({mode}) => { + const env = loadEnv(mode, '.', ''); + return { + plugins: [react(), tailwindcss()], + define: { + 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY), + }, + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + }, + }, + server: { + hmr: process.env.DISABLE_HMR !== 'true', + }, + }; +}); diff --git a/workers/silverbackai-toolkit/worker.js b/workers/silverbackai-toolkit/worker.js new file mode 100644 index 0000000..f50ccf5 --- /dev/null +++ b/workers/silverbackai-toolkit/worker.js @@ -0,0 +1,830 @@ +// worker.js - Silverback AI Toolkit +var worker_default = { + async fetch(request) { + const url = new URL(request.url); + + // Toolkit hub page + const html = ` + + + + + + + + + + + Silverback AI Toolkit — AI Tools Ready to Deploy + + + + + + + + + + +
+ +
+ +
+
+

The Toolkit

+

+ Pre-built AI tools ready to deploy. Pick one, plug it into your + workflow, and start saving hours immediately. +

+ +
+
+ +
+
+ + + + + + +
+
+ +
+
+
+ +
+
+
⚖️
+
Live
+
+

Contract Analyzer

+

Upload a contract and get a plain-English summary of key terms, obligations, deadlines, and red flags. Built for solo attorneys and small firms.

+ +
+ +
+
+
📚
+
Live
+
+

Case Research Assistant

+

AI-powered case law research. Describe your situation in plain language and get relevant precedents, statutes, and arguments. Saves hours of research time.

+ +
+ +
+
+
🏢
+
Live
+
+

Tenant Communication Bot

+

Automated tenant messaging for maintenance requests, lease renewals, and announcements. Handles routine inquiries 24/7 so you don't have to.

+ +
+ +
+
+
📋
+
Live
+
+

Lease Analyzer

+

Upload any lease agreement and get instant analysis of terms, rent escalation clauses, liability issues, and renewal conditions. Compare across your portfolio.

+ +
+ +
+
+
🔧
+
Beta
+
+

Maintenance Router

+

AI triages incoming maintenance requests by urgency, assigns to the right vendor, and tracks resolution. Reduces response time and keeps tenants happy.

+ +
+ +
+
+
🌐
+
Live
+
+

Site Builder AI

+

Describe your business and get a professional website generated with copy, layout, and SEO built in. Deploy to Cloudflare in minutes, not weeks.

+ +
+ +
+
+
📝
+
Live
+
+

Content Generator

+

Generate blog posts, landing page copy, social media content, and email campaigns tailored to your brand voice. Batch create weeks of content in one session.

+ +
+ +
+
+
🤖
+
Beta
+
+

Workflow Automator

+

Connect your existing tools with AI-powered automation. Email to spreadsheet, form to CRM, invoice to accounting — set it once and forget it.

+ +
+ +
+
+
📧
+
Live
+
+

Smart Inbox

+

AI reads, categorizes, and drafts replies to your email. Flags what needs your attention, handles the rest. Works with Gmail and Outlook.

+ +
+ +
+
+
📊
+
Beta
+
+

Data Insight Engine

+

Upload spreadsheets or connect databases and ask questions in plain English. Get charts, summaries, and trend analysis without writing a single formula.

+ +
+ +
+
+
📄
+
Coming Soon
+
+

Document OCR + Extract

+

Scan physical documents, receipts, or handwritten notes and extract structured data. Feed it into your existing systems automatically.

+ +
+ +
+
+
🔌
+
Coming Soon
+
+

API Bridge

+

Connect any API to any other API with a natural language interface. Tell it what you want to happen, and it builds the integration. No code required.

+ +
+ +
+ +
+

Need Something Custom?

+

+ Don't see what you need? We build custom AI tools for your + exact workflow. Tell us what you're trying to solve. +

+ Request a Custom Tool +
+
+
+ + + +