From 51936a0742dbf3b752e1d70e49c4c56789b85e6a Mon Sep 17 00:00:00 2001 From: Krishna Agarwal Date: Tue, 7 Apr 2026 17:17:33 +0800 Subject: [PATCH 1/5] Migration wing-command --- wing-command/.env.example | 18 +- wing-command/app/api/deals/route.ts | 166 ----- wing-command/app/api/discover/route.ts | 47 ++ wing-command/app/api/menu/route.ts | 174 ----- wing-command/app/api/scout/route.ts | 563 ++++----------- wing-command/app/page.tsx | 155 +--- wing-command/hooks/useWingSearch.ts | 109 +++ wing-command/lib/cache.ts | 537 -------------- wing-command/lib/chain-prices.ts | 70 -- wing-command/lib/deals.ts | 396 ----------- wing-command/lib/env.ts | 80 +-- wing-command/lib/geocode.ts | 465 +++--------- wing-command/lib/menu.ts | 736 ------------------- wing-command/lib/seed-data.ts | 288 -------- wing-command/lib/supabase.ts | 198 ------ wing-command/lib/tinyfish-scraper.ts | 546 --------------- wing-command/lib/types.ts | 371 ++-------- wing-command/package-lock.json | 932 ++++++++++++++----------- wing-command/package.json | 25 +- wing-command/render.yaml | 71 -- wing-command/scraper/requirements.txt | 2 - wing-command/scraper/scrape_wings.py | 267 ------- wing-command/scripts/cache-warmer.ts | 464 ------------ wing-command/supabase/schema.sql | 152 ---- 24 files changed, 1053 insertions(+), 5779 deletions(-) delete mode 100644 wing-command/app/api/deals/route.ts create mode 100644 wing-command/app/api/discover/route.ts delete mode 100644 wing-command/app/api/menu/route.ts create mode 100644 wing-command/hooks/useWingSearch.ts delete mode 100644 wing-command/lib/cache.ts delete mode 100644 wing-command/lib/chain-prices.ts delete mode 100644 wing-command/lib/deals.ts delete mode 100644 wing-command/lib/menu.ts delete mode 100644 wing-command/lib/seed-data.ts delete mode 100644 wing-command/lib/supabase.ts delete mode 100644 wing-command/lib/tinyfish-scraper.ts delete mode 100644 wing-command/render.yaml delete mode 100644 wing-command/scraper/requirements.txt delete mode 100644 wing-command/scraper/scrape_wings.py delete mode 100644 wing-command/scripts/cache-warmer.ts delete mode 100644 wing-command/supabase/schema.sql diff --git a/wing-command/.env.example b/wing-command/.env.example index 7244fa3f9..f2a8d757d 100644 --- a/wing-command/.env.example +++ b/wing-command/.env.example @@ -1,17 +1,7 @@ -# =========================================== -# Wing Command — Environment Variables -# =========================================== +# Wing Command v4 — Environment Variables -# Supabase (required) — https://supabase.com/dashboard -NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co -NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key -SUPABASE_SERVICE_ROLE_KEY=your-service-role-key +# Groq (required) — https://console.groq.com +GROQ_API_KEY=your-groq-api-key -# TinyFish Scraper (required) — https://docs.tinyfish.ai +# TinyFish (required) — https://docs.tinyfish.ai TINYFISH_API_KEY=your-tinyfish-api-key -# TINYFISH_API_URL=https://agent.tinyfish.ai/v1/automation/run # sync (default) -# TINYFISH_API_URL=https://agent.tinyfish.ai/v1/automation/run-sse # SSE streaming - -# Upstash Redis (optional, recommended) — https://upstash.com -# UPSTASH_REDIS_REST_URL=https://your-redis.upstash.io -# UPSTASH_REDIS_REST_TOKEN=your-redis-token diff --git a/wing-command/app/api/deals/route.ts b/wing-command/app/api/deals/route.ts deleted file mode 100644 index 9bd58cd78..000000000 --- a/wing-command/app/api/deals/route.ts +++ /dev/null @@ -1,166 +0,0 @@ -// =========================================== -// Wing Scout — Super Bowl Deals API Endpoint -// Aggregator-first: check global deals cache → fuzzy match → fallback -// =========================================== - -import { NextRequest, NextResponse } from 'next/server'; -import { createServerClient } from '@/lib/supabase'; -import { - getCachedDeals, - cacheDeals, - getCachedAggregatorDeals, - setAggregatorScoutingLock, - isAggregatorScoutingInProgress, - setDealsScoutingLock, - isDealsScoutingInProgress, -} from '@/lib/cache'; -import { - startBackgroundAggregatorScrape, - startBackgroundDealsScrape, - matchDealsToSpot, -} from '@/lib/deals'; -import { DealsResponse } from '@/lib/types'; - -export const runtime = 'nodejs'; -export const maxDuration = 300; // 5 minutes — Railway has no limit, but set generous max - -export async function GET(request: NextRequest) { - const searchParams = request.nextUrl.searchParams; - const spotId = searchParams.get('spot_id'); - const isPoll = searchParams.get('poll') === 'true'; - - if (!spotId) { - return NextResponse.json( - { success: false, deals: [], cached: false, message: 'spot_id is required' }, - { status: 400 } - ); - } - - try { - // =========================================== - // Stage 1: Check per-spot Redis cache (30-min TTL) - // =========================================== - const cachedDeals = await getCachedDeals(spotId); - if (cachedDeals) { - console.log(`Deals cache hit for ${spotId}: ${cachedDeals.length} deals`); - return NextResponse.json({ - success: true, - deals: cachedDeals, - cached: true, - message: cachedDeals.length > 0 - ? `${cachedDeals.length} Super Bowl deal(s) (cached)` - : 'No Super Bowl specials found (cached)', - }); - } - - // =========================================== - // Stage 2: Look up spot details from Supabase - // =========================================== - const supabase = createServerClient(); - const { data: spot, error: spotError } = await supabase - .from('wing_spots') - .select('name, address, platform_ids') - .eq('id', spotId) - .single(); - - if (!spot || spotError) { - console.log(`Deals: spot not found: ${spotId}`); - return NextResponse.json( - { success: false, deals: [], cached: false, message: 'Spot not found' }, - { status: 404 } - ); - } - - // =========================================== - // Stage 3: Check global aggregator cache → fuzzy match - // =========================================== - const aggregatorDeals = await getCachedAggregatorDeals(); - if (aggregatorDeals && aggregatorDeals.length > 0) { - // Aggregator data exists — try to match this spot - const matchedDeals = matchDealsToSpot(spot.name, aggregatorDeals); - - if (matchedDeals.length > 0) { - // Chain match found — cache per-spot and return - console.log(`Aggregator match for ${spotId} (${spot.name}): ${matchedDeals.length} deals`); - await cacheDeals(spotId, matchedDeals); - return NextResponse.json({ - success: true, - deals: matchedDeals, - cached: false, - message: `${matchedDeals.length} Super Bowl deal(s) found`, - }); - } - - // No aggregator match — this is likely a local restaurant. - // Fall through to Stage 5 (website-only fallback) below. - console.log(`No aggregator match for ${spotId} (${spot.name}) — trying website fallback`); - } - - // =========================================== - // Stage 4: Poll handling - // =========================================== - if (isPoll) { - // Check if either aggregator or per-spot scouting is in progress - const aggScouting = await isAggregatorScoutingInProgress(); - const spotScouting = await isDealsScoutingInProgress(spotId); - const anyScouting = aggScouting || spotScouting; - - return NextResponse.json({ - success: false, - deals: [], - cached: false, - scouting: anyScouting, - message: anyScouting - ? 'Still scouting Super Bowl deals...' - : 'No Super Bowl specials found', - }); - } - - // =========================================== - // Stage 5: Trigger background scrapes - // =========================================== - - // If no aggregator cache at all → trigger global aggregator scrape - if (!aggregatorDeals) { - const gotAggLock = await setAggregatorScoutingLock(); - if (gotAggLock) { - console.log('Launching background aggregator scrape (first request)'); - startBackgroundAggregatorScrape(); - } else { - console.log('Aggregator scrape already in progress'); - } - - return NextResponse.json({ - success: false, - deals: [], - cached: false, - scouting: true, - message: 'Scouting Super Bowl deals...', - }); - } - - // Aggregator cache exists but no match (local restaurant) - // → trigger website-only fallback for this specific spot - const gotSpotLock = await setDealsScoutingLock(spotId); - if (gotSpotLock) { - console.log(`Launching website-only fallback for ${spotId}: ${spot.name}`); - startBackgroundDealsScrape(spotId, spot.name, spot.address, spot.platform_ids); - } else { - console.log(`Website fallback already in progress for ${spotId}`); - } - - return NextResponse.json({ - success: false, - deals: [], - cached: false, - scouting: true, - message: 'Scouting website for deals...', - }); - } catch (error) { - console.error('Deals API error:', error); - return NextResponse.json( - { success: false, deals: [], cached: false, message: 'Failed to fetch deals' }, - { status: 500 } - ); - } -} diff --git a/wing-command/app/api/discover/route.ts b/wing-command/app/api/discover/route.ts new file mode 100644 index 000000000..b6305a257 --- /dev/null +++ b/wing-command/app/api/discover/route.ts @@ -0,0 +1,47 @@ +// ============================================= +// Wing Command v4 — /api/discover +// Uses Groq to find real wing-spot sources +// for a given zip code / city. +// Mirrors the scholarship-finder discover route. +// ============================================= + +import { NextRequest, NextResponse } from 'next/server'; +import Groq from 'groq-sdk'; + +const groq = new Groq({ apiKey: process.env.GROQ_API_KEY }); + +export async function POST(req: NextRequest) { + const { zipCode, city, state, flavor } = await req.json(); + + const locationHint = city && state ? `${city}, ${state}` : `zip code ${zipCode}`; + const flavorHint = flavor ? ` The user prefers "${flavor}" style wings.` : ''; + + const prompt = `You are a food research expert. Find 5-7 real URLs of pages that list chicken wing restaurants for ${locationHint}.${flavorHint} + +Return ONLY a JSON array of objects with this shape: +[{ "url": "https://...", "siteName": "Site Name", "source": "doordash|ubereats|grubhub|google|yelp" }] + +Rules: +- Use real, currently active pages (search result pages, not homepages) +- Mix of DoorDash, UberEats, Grubhub city-level chicken-wings pages AND a Google search URL +- For delivery platforms: use their city/category search URLs like https://www.doordash.com/food-delivery/CITY-STATE-restaurants/chicken-wings/ +- For Google: use https://www.google.com/search?q=best+chicken+wings+near+${zipCode} +- No markdown, no explanation — just the JSON array`; + + const completion = await groq.chat.completions.create({ + model: 'llama-3.3-70b-versatile', + messages: [{ role: 'user', content: prompt }], + temperature: 0.3, + max_tokens: 800, + }); + + const text = completion.choices[0].message.content || '[]'; + const clean = text.replace(/```json|```/g, '').trim(); + + try { + const urls = JSON.parse(clean); + return NextResponse.json({ urls }); + } catch { + return NextResponse.json({ urls: [] }); + } +} diff --git a/wing-command/app/api/menu/route.ts b/wing-command/app/api/menu/route.ts deleted file mode 100644 index 4f64b883f..000000000 --- a/wing-command/app/api/menu/route.ts +++ /dev/null @@ -1,174 +0,0 @@ -// =========================================== -// Wing Scout - Menu API Endpoint -// Redis-based dedup, background scraping, poll support -// =========================================== - -import { NextRequest, NextResponse } from 'next/server'; -import { createServerClient } from '@/lib/supabase'; -import { - getCachedMenu, cacheMenu, - getCachedChainMenu, cacheChainMenu, - setScoutingLock, isScoutingInProgress, -} from '@/lib/cache'; -import { startBackgroundMenuScrape } from '@/lib/menu'; -import { MenuResponse, Menu } from '@/lib/types'; - -export const runtime = 'nodejs'; -export const maxDuration = 60; - -export async function GET(request: NextRequest) { - const searchParams = request.nextUrl.searchParams; - const spotId = searchParams.get('spot_id'); - const isPoll = searchParams.get('poll') === 'true'; - - // Validate spot_id parameter - if (!spotId) { - return NextResponse.json( - { success: false, menu: null, cached: false, message: 'spot_id is required' }, - { status: 400 } - ); - } - - // Seed data spots have no real restaurants — skip TinyFish scraping entirely - if (spotId.startsWith('seed-')) { - return NextResponse.json({ - success: false, - menu: null, - cached: false, - message: 'Menu not available for demo restaurants. Search with a real zip code to see live menus!', - }); - } - - try { - // 1. Check Redis cache first (1-hour TTL) - const cachedMenu = await getCachedMenu(spotId); - if (cachedMenu) { - console.log(`Menu cache hit for ${spotId}`); - return NextResponse.json({ - success: true, - menu: { ...cachedMenu, source: 'cached' } as Menu, - cached: true, - message: 'Menu loaded from cache', - source_url: cachedMenu.source_url, - }); - } - - // 2. Check Supabase for persisted menu - const supabase = createServerClient(); - const { data: dbMenu, error: dbError } = await supabase - .from('menus') - .select('*') - .eq('spot_id', spotId) - .single(); - - if (dbMenu && !dbError) { - // Check if menu is fresh (less than 24 hours old) - const fetchedAt = new Date(dbMenu.fetched_at); - const ageHours = (Date.now() - fetchedAt.getTime()) / (1000 * 60 * 60); - - if (ageHours < 24) { - const menu: Menu = { - spot_id: dbMenu.spot_id, - sections: dbMenu.sections, - fetched_at: dbMenu.fetched_at, - source: 'cached', - has_wings: dbMenu.has_wings, - wing_section_index: dbMenu.wing_section_index, - source_url: dbMenu.source_url, - }; - await cacheMenu(spotId, menu); - console.log(`Menu loaded from database for ${spotId}`); - return NextResponse.json({ - success: true, - menu, - cached: true, - message: 'Menu loaded from database', - source_url: menu.source_url, - }); - } - } - - // 3. Fetch spot details for menu lookup - const { data: spot, error: spotError } = await supabase - .from('wing_spots') - .select('name, address, platform_ids') - .eq('id', spotId) - .single(); - - if (!spot || spotError) { - console.log(`Spot not found: ${spotId}`); - return NextResponse.json( - { success: false, menu: null, cached: false, message: 'Spot not found' }, - { status: 404 } - ); - } - - const sourceUrl = spot.platform_ids?.source_url || undefined; - - // 4. Check chain-level cache (shared across all locations of same restaurant) - const chainMenu = await getCachedChainMenu(spot.name); - if (chainMenu) { - console.log(`Chain cache hit for "${spot.name}" (spot ${spotId})`); - const spotMenu: Menu = { ...chainMenu, spot_id: spotId, source: 'cached', source_url: sourceUrl }; - await cacheMenu(spotId, spotMenu); - return NextResponse.json({ - success: true, - menu: spotMenu, - cached: true, - message: `Menu loaded from chain cache (${spot.name})`, - source_url: sourceUrl, - }); - } - - // 5. If this is a POLL request, just check if scouting is still running - // Poll requests NEVER trigger new scrapes — only cache checks above - if (isPoll) { - const scouting = await isScoutingInProgress(spotId); - return NextResponse.json({ - success: false, - menu: null, - cached: false, - scouting, - message: scouting - ? 'Still scouting wing items...' - : 'Menu not available. Try again.', - source_url: sourceUrl, - }); - } - - // 6. Initial request — acquire Redis scouting lock (atomic SET NX) - const gotLock = await setScoutingLock(spotId); - if (!gotLock) { - // Another Railway instance is already scraping this spot - console.log(`Scouting lock already held for ${spotId}`); - return NextResponse.json({ - success: false, - menu: null, - cached: false, - scouting: true, - message: 'Menu is being scouted. Check back in a moment!', - source_url: sourceUrl, - }); - } - - // 7. Launch background scrape (fire-and-forget) and return immediately - // This responds in <500ms instead of blocking for 45-120s - console.log(`Launching background wing scrape for ${spotId}: ${spot.name}`); - startBackgroundMenuScrape(spotId, spot.name, spot.address, spot.platform_ids); - - return NextResponse.json({ - success: false, - menu: null, - cached: false, - scouting: true, - message: 'Scouting wing items from the menu...', - source_url: sourceUrl, - }); - } catch (error) { - console.error('Menu API error:', error); - return NextResponse.json( - { success: false, menu: null, cached: false, message: 'Failed to fetch menu' }, - { status: 500 } - ); - } -} diff --git a/wing-command/app/api/scout/route.ts b/wing-command/app/api/scout/route.ts index 9c5d92a87..a414663de 100644 --- a/wing-command/app/api/scout/route.ts +++ b/wing-command/app/api/scout/route.ts @@ -1,440 +1,165 @@ import { NextRequest, NextResponse } from 'next/server'; -import { createServerClient, getWingSpotsByZip, upsertWingSpots, deleteWingSpotsByZip } from '@/lib/supabase'; -import { getCachedWingSpots, cacheWingSpots, checkRateLimit, getCachedScrapeResult, cacheScrapeResult, purgeZipCache, setScoutingLock, getCachedMenu } from '@/lib/cache'; +import Groq from 'groq-sdk'; +import { TinyFish, EventType, RunStatus } from '@tiny-fish/sdk'; import { geocodeZipCode } from '@/lib/geocode'; -import { scrapeAllSources } from '@/lib/tinyfish-scraper'; -import { generateSeedData } from '@/lib/seed-data'; -import { isValidZipCode, cleanZipCode, calculateAvailability } from '@/lib/utils'; -import { startBackgroundMenuScrape, getCheapestWingPrice } from '@/lib/menu'; -import { ScoutResponse, FlavorPersona, WingSpot, MenuSection } from '@/lib/types'; -import { getChainPriceEstimate } from '@/lib/chain-prices'; +import type { WingSpot, WingSource, ScoutResponse } from '@/lib/types'; -// Render.com: No timeout limit for Web Services (unlimited runtime) -// Setting Node.js runtime explicitly export const runtime = 'nodejs'; +export const maxDuration = 120; -// Render Web Services have no timeout constraint — we set a generous max here -// for Next.js route handler purposes. Render won't kill long-running requests. -export const maxDuration = 300; - -// In-flight request deduplication -const inFlightRequests = new Map>(); -const INFLIGHT_CLEANUP_INTERVAL = 5 * 60 * 1000; -let lastCleanup = Date.now(); - -function cleanupInFlightRequests() { - const now = Date.now(); - if (now - lastCleanup > INFLIGHT_CLEANUP_INTERVAL) { - inFlightRequests.clear(); - lastCleanup = now; - } +function deriveStatus(spot: Partial): 'green' | 'yellow' | 'red' { + if (!spot.isOpen) return 'red'; + if (spot.rating && spot.rating >= 4.0) return 'green'; + return 'yellow'; } -const VALID_FLAVORS: FlavorPersona[] = ['face-melter', 'classicist', 'sticky-finger']; -const MAX_AUTO_SCRAPES = 10; // Auto-scrape top 10 spots for price data - -/** - * Enrich spots with wing prices from multiple sources: - * 1. Redis menu cache (fastest) - * 2. Supabase menus table (if Redis misses) - * 3. Supabase wing_spots table (if background scrape already wrote price_per_wing) - */ -async function enrichSpotsWithPrices(spots: WingSpot[]): Promise { - const enriched = [...spots]; - const allIds = enriched.map((s, i) => ({ id: s.id, idx: i })); - const missingPriceIds = allIds.filter(({ idx }) => enriched[idx].price_per_wing === null && enriched[idx].cheapest_item_price === null); - const missingPhoneIds = allIds.filter(({ idx }) => !enriched[idx].phone); - - if (missingPriceIds.length === 0 && missingPhoneIds.length === 0) return enriched; - - // Step 1: Try Redis menu cache first for prices (parallel) - if (missingPriceIds.length > 0) { - const redisPromises = missingPriceIds.map(async ({ id, idx }) => { - try { - const cachedMenu = await getCachedMenu(id); - if (cachedMenu?.sections) { - const result = getCheapestWingPrice(cachedMenu.sections); - if (result.price_per_wing !== null || result.cheapest_item_price !== null) { - enriched[idx] = { - ...enriched[idx], - price_per_wing: result.price_per_wing ?? enriched[idx].price_per_wing, - cheapest_item_price: result.cheapest_item_price ?? enriched[idx].cheapest_item_price, - }; - } - } - } catch { /* ignore */ } - }); - await Promise.all(redisPromises); - } - - // Step 2: Check Supabase wing_spots for prices AND phone numbers - const needsPriceFromDb = missingPriceIds.filter(({ idx }) => enriched[idx].price_per_wing === null && enriched[idx].cheapest_item_price === null); - const idsToQuery = new Set([ - ...needsPriceFromDb.map(m => m.id), - ...missingPhoneIds.map(m => m.id), - ]); - - if (idsToQuery.size > 0) { - try { - const supabase = createServerClient(); - const { data: dbRows } = await supabase - .from('wing_spots') - .select('id, price_per_wing, phone, address') - .in('id', Array.from(idsToQuery)); - - if (dbRows) { - const dbMap = new Map(dbRows.map(d => [d.id, d])); - for (const { id, idx } of allIds) { - const dbRow = dbMap.get(id); - if (!dbRow) continue; - // Enrich per-wing price - if (enriched[idx].price_per_wing === null && dbRow.price_per_wing !== null) { - enriched[idx] = { ...enriched[idx], price_per_wing: dbRow.price_per_wing }; - } - // Enrich phone - if (!enriched[idx].phone && dbRow.phone) { - enriched[idx] = { ...enriched[idx], phone: dbRow.phone }; - } - // Enrich address (if currently empty) - if (!enriched[idx].address && dbRow.address) { - enriched[idx] = { ...enriched[idx], address: dbRow.address }; - } - } - } - } catch { /* ignore */ } - } - - // Step 3: For STILL remaining price nulls, check Supabase menus table - const stillMissing2 = missingPriceIds.filter(({ idx }) => enriched[idx].price_per_wing === null && enriched[idx].cheapest_item_price === null); - if (stillMissing2.length > 0 && stillMissing2.length <= 10) { - try { - const supabase = createServerClient(); - const { data: dbMenus } = await supabase - .from('menus') - .select('spot_id, sections') - .in('spot_id', stillMissing2.map(m => m.id)); - - if (dbMenus) { - for (const dbMenu of dbMenus) { - const match = stillMissing2.find(m => m.id === dbMenu.spot_id); - if (match && dbMenu.sections) { - const result = getCheapestWingPrice(dbMenu.sections as MenuSection[]); - if (result.price_per_wing !== null || result.cheapest_item_price !== null) { - enriched[match.idx] = { - ...enriched[match.idx], - price_per_wing: result.price_per_wing ?? enriched[match.idx].price_per_wing, - cheapest_item_price: result.cheapest_item_price ?? enriched[match.idx].cheapest_item_price, - }; - } - } - } - } - } catch { /* ignore */ } - } - - return enriched; -} - -/** - * Fire-and-forget: trigger background menu scrapes for top non-red spots. - * Uses Redis SET NX lock to prevent duplicates. - */ -function autoTriggerMenuScrapes(spots: WingSpot[]): void { - const eligible = spots - .filter(s => s.status !== 'red') - .slice(0, MAX_AUTO_SCRAPES); - - for (const spot of eligible) { - (async () => { - try { - const gotLock = await setScoutingLock(spot.id); - if (gotLock) { - console.log(`Auto-triggering menu scrape for ${spot.id}: ${spot.name}`); - startBackgroundMenuScrape(spot.id, spot.name, spot.address, spot.platform_ids); - } - } catch { - // Ignore lock/scrape errors — non-critical - } - })(); - } -} - -/** - * Estimate prices for spots that still have no price data after enrichment. - * Hybrid approach: - * 1. Chain lookup: if the restaurant is a known chain, use hardcoded price midpoint - * 2. Zip-code average: for unknowns, average all real + chain prices in this batch - */ -function estimateMissingPrices(spots: WingSpot[]): WingSpot[] { - const result = [...spots]; - - // Step 1: Collect real per-wing prices - const realPrices: number[] = []; - for (const spot of result) { - if (spot.price_per_wing != null) { - realPrices.push(spot.price_per_wing); - } - } - - // Step 2: For spots with no price data, try chain lookup - for (let i = 0; i < result.length; i++) { - const spot = result[i]; - if (spot.price_per_wing != null || spot.cheapest_item_price != null) continue; - - const chainEst = getChainPriceEstimate(spot.name); - if (chainEst) { - const midpoint = Math.round(((chainEst.min + chainEst.max) / 2) * 100) / 100; - result[i] = { ...spot, estimated_price_per_wing: midpoint, is_price_estimated: true }; - realPrices.push(midpoint); // Include in zip average - } - } - - // Step 3: Calculate zip average (need >= 2 data points) - if (realPrices.length >= 2) { - const avg = Math.round( - (realPrices.reduce((sum, p) => sum + p, 0) / realPrices.length) * 100 - ) / 100; - - // Step 4: For remaining no-price spots, use zip average - for (let i = 0; i < result.length; i++) { - const spot = result[i]; - if ( - spot.price_per_wing == null && - spot.cheapest_item_price == null && - spot.estimated_price_per_wing == null - ) { - result[i] = { ...spot, estimated_price_per_wing: avg, is_price_estimated: true }; - } - } - } - - return result; +function parseSpots(raw: unknown[], source: string, siteName: string): WingSpot[] { + return (raw || []) + .map((r: unknown, i: number) => { + const item = r as Record; + const spot: WingSpot = { + id: `${source}-${Date.now()}-${i}`, + name: String(item.name || ''), + address: String(item.address || ''), + rating: item.rating ? Number(item.rating) : undefined, + deliveryTime: item.deliveryTime ? String(item.deliveryTime) : undefined, + deliveryFee: item.deliveryFee ? String(item.deliveryFee) : undefined, + isOpen: item.isOpen !== false, + imageUrl: item.imageUrl ? String(item.imageUrl) : undefined, + sourceUrl: item.sourceUrl ? String(item.sourceUrl) : undefined, + phone: item.phone ? String(item.phone) : undefined, + priceRange: item.priceRange ? String(item.priceRange) : undefined, + source: (source || 'google') as WingSource, + siteName, + status: 'yellow', + }; + spot.status = deriveStatus(spot); + return spot; + }) + .filter((s) => s.name && s.name.trim() !== ''); } -export async function GET(request: NextRequest) { - const t0 = Date.now(); - const log = (msg: string) => console.log(`[scout ${Date.now() - t0}ms] ${msg}`); - - const searchParams = request.nextUrl.searchParams; - const rawZip = searchParams.get('zip'); - const rawFlavor = searchParams.get('flavor'); - const forceRefresh = searchParams.get('refresh') === 'true'; - const purge = searchParams.get('purge') === 'true'; - - log(`START zip=${rawZip} flavor=${rawFlavor}${purge ? ' PURGE=true' : ''}`); - - // Validate zip code - if (!rawZip || !isValidZipCode(rawZip)) { - return NextResponse.json( - { success: false, spots: [], cached: false, message: 'Valid 5-digit US zip code required' }, - { status: 400 } - ); - } - - const zipCode = cleanZipCode(rawZip); - const flavor: FlavorPersona | undefined = rawFlavor && VALID_FLAVORS.includes(rawFlavor as FlavorPersona) - ? rawFlavor as FlavorPersona - : undefined; - - // Rate limiting - log('checking rate limit...'); - const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || 'unknown'; - const rateLimit = await checkRateLimit(ip, 20, 60); - log(`rate limit: allowed=${rateLimit.allowed} remaining=${rateLimit.remaining}`); - - if (!rateLimit.allowed) { - return NextResponse.json( - { success: false, spots: [], cached: false, message: `Rate limited. Try again in ${rateLimit.resetIn}s` }, - { status: 429 } - ); - } - - cleanupInFlightRequests(); - - // Skip in-flight deduplication — it can cause deadlocks in dev mode - // where HMR restarts leave stale promises in memory - - try { - // 0. Purge stale/incorrect data if requested - if (purge) { - log('PURGE: clearing Redis cache + Supabase data for zip...'); - const supabasePurge = createServerClient(); - await Promise.all([ - purgeZipCache(zipCode), - deleteWingSpotsByZip(supabasePurge, zipCode), - ]); - log('PURGE: done'); +// SSE GET — streams spots as each agent completes +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const zip = searchParams.get('zip') || ''; + const flavor = searchParams.get('flavor') || ''; + + if (!zip || zip.length !== 5) { + return NextResponse.json({ success: false, spots: [], cached: false, message: 'Invalid zip code.' }); + } + + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + const send = (data: object) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); + }; + + try { + // Step 1 — geocode + const geo = await geocodeZipCode(zip); + if (!geo) { + send({ type: 'ERROR', message: `Could not geocode zip ${zip}` }); + controller.close(); + return; } - // 1. Check Redis cache first (skip if purging or force-refreshing) - if (!forceRefresh && !purge) { - log('checking Redis scrapeResult cache...'); - const cachedResult = await getCachedScrapeResult(zipCode); - if (cachedResult) { - log(`HIT scrapeResult cache: ${cachedResult.spots.length} spots`); - const enrichedSpots = estimateMissingPrices(await enrichSpotsWithPrices(cachedResult.spots)); - return NextResponse.json({ - ...cachedResult, - spots: enrichedSpots, - cached: true, - flavor, - message: `Cached data (${cachedResult.spots.length} spots)`, - }); - } - log('MISS scrapeResult cache'); + const locationHint = `${geo.city}, ${geo.state}`; + send({ type: 'LOCATION', location: { city: geo.city, state: geo.state } }); - log('checking Redis wingSpots cache...'); - const cachedSpots = await getCachedWingSpots(zipCode); - if (cachedSpots && cachedSpots.length > 0) { - log(`HIT wingSpots cache: ${cachedSpots.length} spots`); - const enrichedSpots = estimateMissingPrices(await enrichSpotsWithPrices(cachedSpots)); - const stats = calculateAvailability(enrichedSpots); - return NextResponse.json({ - success: true, - spots: enrichedSpots, - cached: true, - flavor, - message: `Cached ${cachedSpots.length} spots (${stats.percentage}% available)`, - }); - } - log('MISS wingSpots cache'); - } + // Step 2 — Groq discovers URLs + const groq = new Groq({ apiKey: process.env.GROQ_API_KEY }); + const prompt = `Find 5-7 real URLs of pages listing chicken wing restaurants for ${locationHint}.${flavor ? ` User prefers "${flavor}" wings.` : ''} - // 2. Check Supabase for recent data - log('checking Supabase...'); - const supabase = createServerClient(); - const { data: dbSpots } = await getWingSpotsByZip(supabase, zipCode); - log(`Supabase: ${dbSpots?.length ?? 0} rows`); +Return ONLY a JSON array: +[{ "url": "https://...", "siteName": "Site Name", "source": "doordash|ubereats|grubhub|google|yelp" }] - if (dbSpots && dbSpots.length > 0 && !forceRefresh && !purge) { - const timestamps = dbSpots.map(s => new Date(s.last_updated).getTime()).filter(t => !isNaN(t)); - if (timestamps.length === 0) timestamps.push(0); - const latestUpdate = new Date(Math.max(...timestamps)); - const ageMinutes = (Date.now() - latestUpdate.getTime()) / (1000 * 60); - log(`Supabase data age: ${ageMinutes.toFixed(1)} min`); +Rules: +- Real currently active search result pages (not homepages) +- Mix of DoorDash, UberEats, Grubhub city chicken-wings pages + a Google search URL +- DoorDash: https://www.doordash.com/food-delivery/CITY-STATE-restaurants/chicken-wings/ +- Google: https://www.google.com/search?q=best+chicken+wings+near+${zip} +- JSON only, no markdown`; - if (ageMinutes < 60) { // 1 hour — restaurant data (hours, menu, location) doesn't change fast - const enrichedDbSpots = estimateMissingPrices(await enrichSpotsWithPrices(dbSpots)); - await cacheWingSpots(zipCode, enrichedDbSpots); - const stats = calculateAvailability(enrichedDbSpots); - return NextResponse.json({ - success: true, - spots: enrichedDbSpots, - cached: true, - flavor, - message: `Fresh data: ${enrichedDbSpots.length} spots (${stats.percentage}% available)`, - }); - } - } - - // 3. Geocode zip code - log('geocoding...'); - const location = await geocodeZipCode(zipCode); - log(`geocode: ${location ? `${location.city}, ${location.state}` : 'FAILED'}`); - - if (!location) { - if (dbSpots && dbSpots.length > 0) { - const estimated = estimateMissingPrices(await enrichSpotsWithPrices(dbSpots)); - return NextResponse.json({ - success: true, - spots: estimated, - cached: true, - flavor, - message: 'Could not geocode zip, showing cached data', - }); - } - return NextResponse.json( - { success: false, spots: [], cached: false, message: 'Could not geocode zip code. Please try again.' }, - { status: 502 } - ); - } - - // 4. Scrape all sources in parallel - log('starting scrapers...'); - let scrapedSpots = await scrapeAllSources(zipCode, location.lat, location.lng, flavor, location.city, location.state); - log(`scrapers done: ${scrapedSpots.length} spots`); - - if (scrapedSpots.length === 0) { - if (dbSpots && dbSpots.length > 0) { - log('using stale DB data as fallback'); - const estimated = estimateMissingPrices(await enrichSpotsWithPrices(dbSpots)); - return NextResponse.json({ - success: true, - spots: estimated, - cached: true, - flavor, - message: 'No new data found, showing cached results', - }); - } + const completion = await groq.chat.completions.create({ + model: 'llama-3.3-70b-versatile', + messages: [{ role: 'user', content: prompt }], + temperature: 0.3, + max_tokens: 800, + }); - // Fallback: Generate seed data so the app has something to display - log('generating seed data...'); - scrapedSpots = generateSeedData( - zipCode, - location.lat, - location.lng, - location.city, - location.state, - flavor, - ); - log(`seed data: ${scrapedSpots.length} spots`); + const text = completion.choices[0].message.content || '[]'; + let urls: { url: string; siteName: string; source: string }[] = []; + try { + const match = text.replace(/```json|```/g, '').trim().match(/\[[\s\S]*\]/); + if (match) urls = JSON.parse(match[0]); + } catch { urls = []; } + + if (!urls.length) { + send({ type: 'DONE', spots: [], message: 'No sources found.' }); + controller.close(); + return; } - // 5. Save to Supabase - log('saving to Supabase...'); - await upsertWingSpots(supabase, scrapedSpots); - log('saved'); - - // 6. Cache results + estimate missing prices - log('caching results...'); - await cacheWingSpots(zipCode, scrapedSpots); - const estimatedSpots = estimateMissingPrices(await enrichSpotsWithPrices(scrapedSpots)); - - const result: ScoutResponse = { - success: true, - spots: estimatedSpots, - cached: false, - flavor, - message: `Found ${scrapedSpots.length} wing spots`, - location, - }; - - await cacheScrapeResult(zipCode, result); - log(`DONE: ${scrapedSpots.length} spots in ${Date.now() - t0}ms`); - - // 7. Auto-trigger background menu scrapes for top spots (any non-red spot) - // This populates price_per_wing data without the user needing to open menus - autoTriggerMenuScrapes(scrapedSpots); - log(`Auto-triggered menu scrapes for up to ${MAX_AUTO_SCRAPES} spots`); - - return NextResponse.json(result); - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - log(`ERROR: ${errorMessage}`); - console.error('Scout API error:', errorMessage); - - // Fallback to stale data - try { - const supabase = createServerClient(); - const { data: fallbackSpots } = await getWingSpotsByZip(supabase, zipCode); - if (fallbackSpots && fallbackSpots.length > 0) { - const estimated = estimateMissingPrices(await enrichSpotsWithPrices(fallbackSpots)); - return NextResponse.json({ - success: true, - spots: estimated, - cached: true, - flavor, - message: 'Error occurred, showing cached data', - }); + send({ type: 'SOURCES', count: urls.length }); + + // Step 3 — fire all TinyFish agents in parallel, stream spots as each finishes + const client = new TinyFish({ apiKey: process.env.TINYFISH_API_KEY }); + + const goal = `Scout chicken wing restaurants in ${locationHint}.${flavor ? ` Flavor: "${flavor}".` : ''} + +Extract all chicken wing restaurants visible on this page. +RULES: No scrolling beyond one scroll. No navigation. Up to 8 restaurants. Wings only. + +Return ONLY valid JSON: { "restaurants": [{ "name", "address", "rating", "deliveryTime", "deliveryFee", "isOpen", "imageUrl", "sourceUrl", "phone", "priceRange" }] }`; + + const agentPromises = urls.map(async ({ url, siteName, source }) => { + try { + const tfStream = await client.agent.stream({ url, goal }); + for await (const evt of tfStream) { + const e = evt as Record; + if (e.type === EventType.COMPLETE) { + if (e.status === RunStatus.COMPLETED) { + const result = e.result as Record | null; + let restaurants: unknown[] = []; + if (Array.isArray(result?.restaurants)) { + restaurants = result.restaurants; + } else if (typeof result === 'string') { + try { + const p = JSON.parse((result as string).replace(/```json|```/g, '').trim()); + restaurants = p.restaurants || []; + } catch { restaurants = []; } + } + const spots = parseSpots(restaurants, source, siteName); + // Send spots immediately as this agent finishes + if (spots.length > 0) { + send({ type: 'SPOTS', spots }); + } + } + break; + } } - } catch (fallbackError) { - console.error('Fallback error:', fallbackError instanceof Error ? fallbackError.message : 'Unknown'); - } + } catch { /* agent failed silently */ } + }); - return NextResponse.json( - { success: false, spots: [], cached: false, message: 'An error occurred while fetching data' }, - { status: 500 } - ); - } + await Promise.allSettled(agentPromises); + send({ type: 'DONE', message: `Scouted ${urls.length} sources near ${locationHint}` }); + + } catch (err) { + send({ type: 'ERROR', message: err instanceof Error ? err.message : 'Search failed' }); + } finally { + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); } diff --git a/wing-command/app/page.tsx b/wing-command/app/page.tsx index 5d1308c66..a9dbc4626 100644 --- a/wing-command/app/page.tsx +++ b/wing-command/app/page.tsx @@ -1,7 +1,6 @@ 'use client'; import React, { useState, useEffect, useCallback } from 'react'; -import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { motion, AnimatePresence } from 'framer-motion'; import { Trophy, Users } from 'lucide-react'; import { GlassBlitzEntrance } from '@/components/GlassBlitzEntrance'; @@ -11,31 +10,15 @@ import { TrashTalkTicker } from '@/components/TrashTalkTicker'; import { TradingCardGrid } from '@/components/TradingCardGrid'; import { CompareBar } from '@/components/CompareBar'; import { CompareModal } from '@/components/CompareModal'; -import { FlavorPersona, ScoutResponse, AvailabilityStats } from '@/lib/types'; +import { FlavorPersona, AvailabilityStats } from '@/lib/types'; import { calculateAvailability } from '@/lib/utils'; +import { useWingSearch } from '@/hooks/useWingSearch'; const LAST_ZIP_KEY = 'wing-command-last-zip'; const LAST_FLAVOR_KEY = 'wing-command-last-flavor'; -const CACHE_DURATION_MS = 30 * 60 * 1000; // 30 min — discovery app, not inventory tracking -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: CACHE_DURATION_MS, - gcTime: 60 * 60 * 1000, // 1 hour — keep query data in memory longer - retry: 1, - refetchOnWindowFocus: false, - refetchOnMount: false, - }, - }, -}); - -// =========================================== -// Stats Bar — bright theme -// =========================================== function StatsBar({ stats, locationName }: { stats: AvailabilityStats; locationName: string }) { if (stats.total === 0) return null; - return (
{locationName.toUpperCase()}
)} -
-
{stats.green} @@ -72,9 +53,7 @@ function StatsBar({ stats, locationName }: { stats: AvailabilityStats; locationN {stats.red} CLOSED
-
-
{stats.total} TOTAL @@ -84,12 +63,9 @@ function StatsBar({ stats, locationName }: { stats: AvailabilityStats; locationN ); } -// =========================================== -// Coach Wing speech bubbles — sunny comedy twist -// =========================================== function getCoachSpeech(flavor: FlavorPersona | null, isSearching: boolean, hasResults: boolean): string | undefined { if (flavor === 'face-melter') { - if (isSearching) return "Scouting the hottest spots... this sunshine ain't helping! \uD83D\uDD25"; + if (isSearching) return "Scouting the hottest spots... this sunshine ain't helping! 🔥"; if (hasResults) return "Now THAT'S a roster! Pick your starter."; return "You chose violence. On a sunny day. Bold."; } @@ -99,7 +75,7 @@ function getCoachSpeech(flavor: FlavorPersona | null, isSearching: boolean, hasR return "Smart play. The classics never miss."; } if (flavor === 'sticky-finger') { - if (isSearching) return "Tracking down the sauciest spots... \uD83E\uDD24"; + if (isSearching) return "Tracking down the sauciest spots... 🤤"; if (hasResults) return "Now THAT'S a roster! Pick your starter."; return "Napkins? Where we're going, we don't need napkins."; } @@ -107,109 +83,59 @@ function getCoachSpeech(flavor: FlavorPersona | null, isSearching: boolean, hasR return undefined; } -// =========================================== -// Main Wing Command Content -// =========================================== function WingCommandContent() { const [zipCode, setZipCode] = useState(''); const [flavor, setFlavor] = useState(null); - const [isHydrated, setIsHydrated] = useState(false); const [bannerDone, setBannerDone] = useState(false); const [compareIds, setCompareIds] = useState>(new Set()); const [isCompareOpen, setIsCompareOpen] = useState(false); + // Use the streaming hook instead of React Query + const { spots, isSearching, isDone, location, message, search, reset } = useWingSearch(); + const toggleCompare = useCallback((id: string) => { setCompareIds(prev => { const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else if (next.size < 4) { - next.add(id); - } + if (next.has(id)) next.delete(id); + else if (next.size < 4) next.add(id); return next; }); }, []); - const clearCompare = useCallback(() => { - setCompareIds(new Set()); - }, []); + const clearCompare = useCallback(() => setCompareIds(new Set()), []); + + // Restore last search from session storage useEffect(() => { const savedZip = sessionStorage.getItem(LAST_ZIP_KEY); const savedFlavor = sessionStorage.getItem(LAST_FLAVOR_KEY) as FlavorPersona | null; if (savedZip && savedZip.length === 5) setZipCode(savedZip); if (savedFlavor) setFlavor(savedFlavor); - setIsHydrated(true); }, []); - const { data, isLoading, isFetching, refetch } = useQuery({ - queryKey: ['scout', zipCode, flavor], - queryFn: async ({ signal }) => { - if (!zipCode || !flavor) return { success: true, spots: [], cached: false, message: '' }; - - // Only abort if the user changed zip/flavor (new queryKey = new signal) - // Don't use our own abort — let React Query's signal handle cancellation - const params = new URLSearchParams({ zip: zipCode, flavor }); - const res = await fetch(`/api/scout?${params.toString()}`, { - signal, - }); - - if (!res.ok) { - const errorData = await res.json().catch(() => ({})); - throw new Error(errorData.message || `HTTP ${res.status}`); - } - - return res.json(); - }, - enabled: zipCode.length === 5 && flavor !== null, - retry: (failureCount, error) => { - // Don't retry geocoding failures — all server-side fallbacks already exhausted - if (error instanceof Error && error.message.includes('Could not geocode')) return false; - // Don't retry rate limits - if (error instanceof Error && error.message.includes('Rate limited')) return false; - // Retry other transient errors up to 2 times - return failureCount < 2; - }, - retryDelay: 3000, - refetchInterval: CACHE_DURATION_MS, - refetchIntervalInBackground: false, - // Scraping can take up to 3 mins — don't kill stale queries early - staleTime: CACHE_DURATION_MS, - }); - - // Re-fetch at 45s and 120s to pick up price data from background menu scrapes. - // Background scrapes take 30-120s; two refetches catch both fast and slow completions. + // Kick off search when both zip and flavor are set useEffect(() => { - if (data && data.spots.length > 0) { - const hasMissingPrices = data.spots.some(s => s.price_per_wing == null && s.cheapest_item_price == null); - if (hasMissingPrices) { - const timer45 = setTimeout(() => refetch(), 45_000); - const timer120 = setTimeout(() => refetch(), 120_000); - return () => { - clearTimeout(timer45); - clearTimeout(timer120); - }; - } + if (zipCode.length === 5 && flavor) { + search(zipCode, flavor); } - }, [data, refetch]); + }, [zipCode, flavor, search]); - const spots = data?.spots || []; const stats = calculateAvailability(spots); - const locationName = data?.location ? `${data.location.city}, ${data.location.state}` : ''; + const locationName = location ? `${location.city}, ${location.state}` : ''; const hasResults = spots.length > 0; - const isSearching = isLoading || isFetching; + const coachSpeech = getCoachSpeech(flavor, isSearching, hasResults); const handleSearch = useCallback((zip: string) => { sessionStorage.setItem(LAST_ZIP_KEY, zip); setZipCode(zip); + setCompareIds(new Set()); }, []); const handleFlavorSelect = useCallback((f: FlavorPersona) => { sessionStorage.setItem(LAST_FLAVOR_KEY, f); setFlavor(f); + setCompareIds(new Set()); }, []); - const coachSpeech = getCoachSpeech(flavor, isSearching, hasResults); - return ( setBannerDone(true)} >
- {/* ===== Grass Field Background — the MAIN page bg behind dashboard ===== */}
{/* eslint-disable-next-line @next/next/no-img-element */} - - {/* Sunny washed-out overlay so UI is readable */} +
- {/* ===== Command Jumbotron — bright header ===== */} - {/* ===== Hero Section — Coach Wing + Playbook ===== */} - {/* NO opaque wrapper — field shows through directly */} - {/* ===== Loading State — Trash Talk Ticker ===== */} + {/* Loading ticker */} {isSearching && ( - {/* ===== Results — Scouting Report (in frosted glass) ===== */} + {/* Results — shown as soon as ANY agent returns spots */} {(hasResults || isSearching) && ( + {isSearching && hasResults && ( + + 🔍 {spots.length} spot{spots.length !== 1 ? 's' : ''} found so far — more loading... + + )} + - {!isSearching && spots.length === 0 && data?.message && ( + {isDone && spots.length === 0 && message && ( ☀️ -

{data.message}

+

{message}

Coach Wing says: "Even the sun can't find wings here. Try another zip!"

@@ -315,7 +243,6 @@ function WingCommandContent() { )}
- {/* ===== Footer ===== */}
- {/* ===== Compare Mode ===== */} setIsCompareOpen(true)} @@ -346,13 +272,6 @@ function WingCommandContent() { ); } -// =========================================== -// Root Page Component -// =========================================== export default function Home() { - return ( - - - - ); + return ; } diff --git a/wing-command/hooks/useWingSearch.ts b/wing-command/hooks/useWingSearch.ts new file mode 100644 index 000000000..958598d10 --- /dev/null +++ b/wing-command/hooks/useWingSearch.ts @@ -0,0 +1,109 @@ +'use client'; + +import { useState, useCallback, useRef } from 'react'; +import { WingSpot, FlavorPersona } from '@/lib/types'; + +export interface WingSearchState { + spots: WingSpot[]; + isSearching: boolean; + isDone: boolean; + location: { city: string; state: string } | null; + message: string; + sourceCount: number; +} + +const initialState: WingSearchState = { + spots: [], + isSearching: false, + isDone: false, + location: null, + message: '', + sourceCount: 0, +}; + +export function useWingSearch() { + const [state, setState] = useState(initialState); + const abortRef = useRef(null); + + const search = useCallback(async (zip: string, flavor: FlavorPersona | null) => { + // Cancel any in-flight search + abortRef.current?.abort(); + const abort = new AbortController(); + abortRef.current = abort; + + setState({ ...initialState, isSearching: true, message: 'Scouting wing spots...' }); + + try { + const params = new URLSearchParams({ zip, ...(flavor ? { flavor } : {}) }); + const res = await fetch(`/api/scout?${params}`, { signal: abort.signal }); + + if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.body) throw new Error('No response body'); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + try { + const data = JSON.parse(line.slice(6)); + + if (data.type === 'LOCATION') { + setState((prev) => ({ ...prev, location: data.location })); + } + if (data.type === 'SOURCES') { + setState((prev) => ({ ...prev, sourceCount: data.count })); + } + // Stream spots in immediately as each agent completes + if (data.type === 'SPOTS') { + setState((prev) => ({ + ...prev, + spots: [...prev.spots, ...data.spots], + })); + } + if (data.type === 'DONE') { + setState((prev) => ({ + ...prev, + isSearching: false, + isDone: true, + message: data.message || '', + })); + } + if (data.type === 'ERROR') { + setState((prev) => ({ + ...prev, + isSearching: false, + isDone: true, + message: data.message || 'Search failed', + })); + } + } catch { /* ignore parse errors */ } + } + } + } catch (err) { + if ((err as Error).name === 'AbortError') return; + setState((prev) => ({ + ...prev, + isSearching: false, + isDone: true, + message: err instanceof Error ? err.message : 'Search failed', + })); + } + }, []); + + const reset = useCallback(() => { + abortRef.current?.abort(); + setState(initialState); + }, []); + + return { ...state, search, reset }; +} diff --git a/wing-command/lib/cache.ts b/wing-command/lib/cache.ts deleted file mode 100644 index 733af08fc..000000000 --- a/wing-command/lib/cache.ts +++ /dev/null @@ -1,537 +0,0 @@ -// =========================================== -// Wing Scout - Upstash Redis Cache -// =========================================== - -import { Redis } from '@upstash/redis'; -import { WingSpot, GeocodedLocation, ScrapeResponse, Menu, SuperBowlDeal, AggregatorDeal } from './types'; - -// Validate Redis environment variables -const redisUrl = process.env.UPSTASH_REDIS_REST_URL; -const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN; - -if (!redisUrl || !redisToken) { - console.warn('Warning: Redis environment variables not set. Caching will be disabled.'); -} - -// Initialize Redis client (may be null if env vars missing) -const redis = redisUrl && redisToken - ? new Redis({ url: redisUrl, token: redisToken }) - : null; - -// Cache TTL in seconds (2 hours — discovery app, restaurant data doesn't change fast) -const DEFAULT_TTL = 2 * 60 * 60; -const GEOCODE_TTL = 60 * 60 * 24 * 365; // 1 year for geocode (permanent) - -/** - * Cache key generators - */ -const keys = { - wingSpots: (zip: string) => `wing_spots:${zip}`, - geocode: (zip: string) => `geocode:${zip}`, - scrapeResult: (zip: string) => `scrape_result:${zip}`, - rateLimit: (ip: string) => `rate_limit:${ip}`, - menuScouting: (spotId: string) => `menu:scouting:${spotId}`, -}; - -/** - * Get cached wing spots for a zip code - */ -export async function getCachedWingSpots(zipCode: string): Promise { - if (!redis) return null; - try { - const data = await redis.get(keys.wingSpots(zipCode)); - return data; - } catch (error) { - console.error('Redis getCachedWingSpots error:', error); - return null; - } -} - -/** - * Cache wing spots for a zip code - */ -export async function cacheWingSpots( - zipCode: string, - spots: WingSpot[], - ttlSeconds: number = DEFAULT_TTL -): Promise { - if (!redis) return; - try { - await redis.set(keys.wingSpots(zipCode), spots, { ex: ttlSeconds }); - } catch (error) { - console.error('Redis cacheWingSpots error:', error); - } -} - -/** - * Get cached geocode data - */ -export async function getCachedGeocode(zipCode: string): Promise { - if (!redis) return null; - try { - const data = await redis.get(keys.geocode(zipCode)); - return data; - } catch (error) { - console.error('Redis getCachedGeocode error:', error); - return null; - } -} - -/** - * Cache geocode data (permanent) - */ -export async function cacheGeocode(geocode: GeocodedLocation): Promise { - if (!redis) return; - try { - await redis.set(keys.geocode(geocode.zip_code), geocode, { ex: GEOCODE_TTL }); - } catch (error) { - console.error('Redis cacheGeocode error:', error); - } -} - -/** - * Purge all cached data for a zip code (for clearing stale/incorrect data) - */ -export async function purgeZipCache(zipCode: string): Promise { - if (!redis) return; - try { - await redis.del(keys.wingSpots(zipCode)); - await redis.del(keys.scrapeResult(zipCode)); - console.log(`Purged Redis cache for zip: ${zipCode}`); - } catch (error) { - console.error('Redis purgeZipCache error:', error); - } -} - -/** - * Get cached scrape result - */ -export async function getCachedScrapeResult(zipCode: string): Promise { - if (!redis) return null; - try { - const data = await redis.get(keys.scrapeResult(zipCode)); - return data; - } catch (error) { - console.error('Redis getCachedScrapeResult error:', error); - return null; - } -} - -/** - * Cache scrape result - */ -export async function cacheScrapeResult( - zipCode: string, - result: ScrapeResponse, - ttlSeconds: number = DEFAULT_TTL -): Promise { - if (!redis) return; - try { - await redis.set(keys.scrapeResult(zipCode), result, { ex: ttlSeconds }); - } catch (error) { - console.error('Redis cacheScrapeResult error:', error); - } -} - -/** - * Rate limiting check - * Returns true if request is allowed, false if rate limited - * SECURITY: Denies on error to prevent DoS attacks when Redis is down - */ -export async function checkRateLimit( - ip: string, - maxRequests: number = 10, - windowSeconds: number = 60 -): Promise<{ allowed: boolean; remaining: number; resetIn: number }> { - // If Redis is not configured, allow requests (graceful degradation for dev) - if (!redis) { - return { allowed: true, remaining: maxRequests, resetIn: 0 }; - } - - try { - const key = keys.rateLimit(ip); - const current = await redis.incr(key); - - if (current === 1) { - // First request, set expiry - await redis.expire(key, windowSeconds); - } - - const ttl = await redis.ttl(key); - const allowed = current <= maxRequests; - const remaining = Math.max(0, maxRequests - current); - - return { allowed, remaining, resetIn: ttl }; - } catch (error) { - console.error('Redis checkRateLimit error:', error); - // SECURITY: Deny on error to prevent rate limit bypass attacks - return { allowed: false, remaining: 0, resetIn: windowSeconds }; - } -} - -/** - * Invalidate cache for a zip code - */ -export async function invalidateZipCache(zipCode: string): Promise { - if (!redis) return; - try { - await redis.del(keys.wingSpots(zipCode), keys.scrapeResult(zipCode)); - } catch (error) { - console.error('Redis invalidateZipCache error:', error); - } -} - -/** - * Get cache stats for monitoring - */ -export async function getCacheStats(): Promise<{ - connected: boolean; - info: string; -}> { - if (!redis) { - return { - connected: false, - info: 'Redis not configured', - }; - } - try { - const pingResult = await redis.ping(); - return { - connected: pingResult === 'PONG', - info: 'Redis connected', - }; - } catch (error) { - return { - connected: false, - info: `Redis error: ${error instanceof Error ? error.message : 'Unknown error'}`, - }; - } -} - -// =========================================== -// Menu Caching -// =========================================== - -// Menu cache TTL (1 hour for individual spots) -const MENU_TTL = 60 * 60; - -// Chain menu cache TTL (6 hours — chain menus rarely change) -const CHAIN_MENU_TTL = 6 * 60 * 60; - -/** - * Cache key for menus (per-spot) - */ -const menuKey = (spotId: string) => `menu:${spotId}`; - -/** - * Cache key for chain menus (shared across all locations of the same chain) - */ -const chainMenuKey = (name: string) => `menu:chain:${normalizeChainName(name)}`; - -/** - * Normalize restaurant name for chain-level cache matching - * "Buffalo Wild Wings" → "buffalo wild wings" - * "Wingstop #1234" → "wingstop" - * "The Original Hot Wings" → "original hot wings" - */ -function normalizeChainName(name: string): string { - return name - .toLowerCase() - .trim() - .replace(/\s*#\d+.*$/, '') // Strip "#1234" store numbers - .replace(/\s*-\s*.*$/, '') // Strip " - Downtown" suffixes - .replace(/^the\s+/, '') // Strip leading "The" - .replace(/['']/g, '') // Strip apostrophes - .replace(/\s+/g, ' ') // Collapse whitespace - .trim(); -} - -/** - * Get cached menu for a spot - */ -export async function getCachedMenu(spotId: string): Promise { - if (!redis) return null; - try { - return await redis.get(menuKey(spotId)); - } catch (error) { - console.error('Redis getCachedMenu error:', error); - return null; - } -} - -/** - * Cache menu for a spot (1-hour TTL) - */ -export async function cacheMenu(spotId: string, menu: Menu): Promise { - if (!redis) return; - try { - await redis.set(menuKey(spotId), menu, { ex: MENU_TTL }); - } catch (error) { - console.error('Redis cacheMenu error:', error); - } -} - -/** - * Invalidate cached menu - */ -export async function invalidateMenuCache(spotId: string): Promise { - if (!redis) return; - try { - await redis.del(menuKey(spotId)); - } catch (error) { - console.error('Redis invalidateMenuCache error:', error); - } -} - -// =========================================== -// Menu Scouting Lock (Redis-based deduplication) -// =========================================== - -// Scouting lock TTL: 3 minutes (covers full TinyFish scrape run + buffer) -const SCOUTING_LOCK_TTL = 3 * 60; - -/** - * Acquire a scouting lock for a spot (SET NX — atomic set-if-not-exists). - * Returns true if WE acquired the lock (first request). - * Returns false if another instance is already scouting this spot. - */ -export async function setScoutingLock(spotId: string): Promise { - if (!redis) return true; // No Redis = allow (dev mode) - try { - const result = await redis.set( - keys.menuScouting(spotId), - Date.now().toString(), - { nx: true, ex: SCOUTING_LOCK_TTL } - ); - return result === 'OK'; - } catch (error) { - console.error('Redis setScoutingLock error:', error); - return true; // Allow on error (graceful degradation) - } -} - -/** - * Check if a scouting lock exists (another instance is scraping). - */ -export async function isScoutingInProgress(spotId: string): Promise { - if (!redis) return false; - try { - const val = await redis.get(keys.menuScouting(spotId)); - return val !== null; - } catch (error) { - console.error('Redis isScoutingInProgress error:', error); - return false; - } -} - -/** - * Clear the scouting lock after scrape completes (success or failure). - */ -export async function clearScoutingLock(spotId: string): Promise { - if (!redis) return; - try { - await redis.del(keys.menuScouting(spotId)); - } catch (error) { - console.error('Redis clearScoutingLock error:', error); - } -} - -// =========================================== -// Chain-Level Menu Caching -// =========================================== - -/** - * Get cached menu for a chain restaurant by name - * e.g., any "Wingstop" location shares the same cached menu - */ -export async function getCachedChainMenu(name: string): Promise { - if (!redis) return null; - try { - const key = chainMenuKey(name); - return await redis.get(key); - } catch (error) { - console.error('Redis getCachedChainMenu error:', error); - return null; - } -} - -/** - * Cache menu under the chain name (6-hour TTL) - * All locations of the same restaurant chain share this cache - */ -export async function cacheChainMenu(name: string, menu: Menu): Promise { - if (!redis) return; - try { - const key = chainMenuKey(name); - await redis.set(key, menu, { ex: CHAIN_MENU_TTL }); - console.log(`Cached chain menu: ${key}`); - } catch (error) { - console.error('Redis cacheChainMenu error:', error); - } -} - -// =========================================== -// Super Bowl Deals Caching -// =========================================== - -const DEALS_TTL = 30 * 60; // 30 minutes -const DEALS_SCOUTING_LOCK_TTL = 5 * 60; // 5 minutes (deals scrapes can take 200-400s for slow sites) -const dealsKey = (spotId: string) => `deals:${spotId}`; -const dealsScoutingKey = (spotId: string) => `deals:scouting:${spotId}`; - -/** - * Get cached Super Bowl deals for a spot - */ -export async function getCachedDeals(spotId: string): Promise { - if (!redis) return null; - try { - return await redis.get(dealsKey(spotId)); - } catch (error) { - console.error('Redis getCachedDeals error:', error); - return null; - } -} - -/** - * Cache Super Bowl deals for a spot (30-min TTL) - */ -export async function cacheDeals(spotId: string, deals: SuperBowlDeal[]): Promise { - if (!redis) return; - try { - await redis.set(dealsKey(spotId), deals, { ex: DEALS_TTL }); - } catch (error) { - console.error('Redis cacheDeals error:', error); - } -} - -// =========================================== -// Deals Scouting Lock (Redis-based deduplication) -// =========================================== - -/** - * Acquire a deals scouting lock (SET NX — atomic set-if-not-exists). - * Returns true if WE acquired the lock (first request). - * Returns false if another instance is already scouting deals for this spot. - */ -export async function setDealsScoutingLock(spotId: string): Promise { - if (!redis) return true; // No Redis = allow (dev mode) - try { - const result = await redis.set( - dealsScoutingKey(spotId), - Date.now().toString(), - { nx: true, ex: DEALS_SCOUTING_LOCK_TTL } - ); - return result === 'OK'; - } catch (error) { - console.error('Redis setDealsScoutingLock error:', error); - return true; // Allow on error (graceful degradation) - } -} - -/** - * Check if a deals scouting lock exists (another instance is scraping deals). - */ -export async function isDealsScoutingInProgress(spotId: string): Promise { - if (!redis) return false; - try { - const val = await redis.get(dealsScoutingKey(spotId)); - return val !== null; - } catch (error) { - console.error('Redis isDealsScoutingInProgress error:', error); - return false; - } -} - -/** - * Clear the deals scouting lock after scrape completes (success or failure). - */ -export async function clearDealsScoutingLock(spotId: string): Promise { - if (!redis) return; - try { - await redis.del(dealsScoutingKey(spotId)); - } catch (error) { - console.error('Redis clearDealsScoutingLock error:', error); - } -} - -// =========================================== -// Global Aggregator Deals Cache -// One scrape of deal roundup pages covers ALL chain restaurants -// =========================================== - -const AGGREGATOR_TTL = 2 * 60 * 60; // 2 hours (aggregator pages rarely change intraday) -const AGGREGATOR_SCOUTING_LOCK_TTL = 5 * 60; // 5 minutes (covers parallel scrape of 3 pages) -const AGGREGATOR_KEY = 'deals:aggregator'; -const AGGREGATOR_SCOUTING_KEY = 'deals:aggregator:scouting'; - -/** - * Get cached aggregator deals (global — not per-spot) - */ -export async function getCachedAggregatorDeals(): Promise { - if (!redis) return null; - try { - return await redis.get(AGGREGATOR_KEY); - } catch (error) { - console.error('Redis getCachedAggregatorDeals error:', error); - return null; - } -} - -/** - * Cache aggregator deals globally (2-hour TTL) - */ -export async function cacheAggregatorDeals(deals: AggregatorDeal[]): Promise { - if (!redis) return; - try { - await redis.set(AGGREGATOR_KEY, deals, { ex: AGGREGATOR_TTL }); - } catch (error) { - console.error('Redis cacheAggregatorDeals error:', error); - } -} - -/** - * Acquire global aggregator scouting lock (SET NX). - * Only one Railway instance scrapes aggregator pages at a time. - */ -export async function setAggregatorScoutingLock(): Promise { - if (!redis) return true; - try { - const result = await redis.set( - AGGREGATOR_SCOUTING_KEY, - Date.now().toString(), - { nx: true, ex: AGGREGATOR_SCOUTING_LOCK_TTL } - ); - return result === 'OK'; - } catch (error) { - console.error('Redis setAggregatorScoutingLock error:', error); - return true; - } -} - -/** - * Check if aggregator scouting is in progress. - */ -export async function isAggregatorScoutingInProgress(): Promise { - if (!redis) return false; - try { - const val = await redis.get(AGGREGATOR_SCOUTING_KEY); - return val !== null; - } catch (error) { - console.error('Redis isAggregatorScoutingInProgress error:', error); - return false; - } -} - -/** - * Clear the aggregator scouting lock. - */ -export async function clearAggregatorScoutingLock(): Promise { - if (!redis) return; - try { - await redis.del(AGGREGATOR_SCOUTING_KEY); - } catch (error) { - console.error('Redis clearAggregatorScoutingLock error:', error); - } -} - -export { redis }; diff --git a/wing-command/lib/chain-prices.ts b/wing-command/lib/chain-prices.ts deleted file mode 100644 index 37df1f2fd..000000000 --- a/wing-command/lib/chain-prices.ts +++ /dev/null @@ -1,70 +0,0 @@ -// =========================================== -// Wing Scout — Chain Wing Price Lookup -// Hardcoded typical per-wing price ranges for major chains. -// Used as estimation when no scraped price is available. -// =========================================== - -export interface ChainPriceRange { - min: number; // typical low $/wing - max: number; // typical high $/wing -} - -/** - * Known chain wing price ranges (per bone-in wing). - * Keys are lowercase normalized chain identifiers (substrings to match against). - * Prices reflect typical 2025-2026 US averages for bone-in traditional wings. - */ -const CHAIN_PRICE_MAP: Record = { - 'wingstop': { min: 1.20, max: 1.60 }, - 'buffalo wild wings': { min: 1.40, max: 1.90 }, - 'bdubs': { min: 1.40, max: 1.90 }, - 'hooters': { min: 1.50, max: 1.80 }, - 'popeyes': { min: 1.00, max: 1.40 }, - 'kfc': { min: 1.10, max: 1.50 }, - 'raising cane': { min: 1.30, max: 1.70 }, - 'zaxby': { min: 1.20, max: 1.60 }, - 'bonchon': { min: 1.60, max: 2.20 }, - 'bb.q chicken': { min: 1.50, max: 2.00 }, - 'bbq chicken': { min: 1.50, max: 2.00 }, - 'daves hot chicken': { min: 1.40, max: 1.90 }, - 'wing zone': { min: 1.20, max: 1.60 }, - 'slim chickens': { min: 1.20, max: 1.60 }, - 'atomic wings': { min: 1.30, max: 1.70 }, - 'wing it on': { min: 1.20, max: 1.60 }, - 'domino': { min: 1.20, max: 1.60 }, - 'pizza hut': { min: 1.10, max: 1.50 }, - 'papa john': { min: 1.20, max: 1.50 }, - 'applebee': { min: 1.30, max: 1.70 }, - 'chilis': { min: 1.30, max: 1.70 }, - 'pluckers': { min: 1.40, max: 1.80 }, - 'hurricane grill': { min: 1.50, max: 2.00 }, - 'wing shack': { min: 1.10, max: 1.50 }, - 'roosters': { min: 1.20, max: 1.60 }, - 'golden chick': { min: 1.10, max: 1.50 }, - 'churchs chicken': { min: 1.00, max: 1.40 }, - 'el pollo loco': { min: 1.10, max: 1.50 }, -}; - -/** - * Look up estimated price range for a restaurant by name. - * Uses substring matching (same approach as getRestaurantType in ScoutingReportCard). - * Returns null if no chain match is found. - */ -export function getChainPriceEstimate(restaurantName: string): ChainPriceRange | null { - const normalized = restaurantName - .toLowerCase() - .trim() - .replace(/\s*#\d+.*$/, '') // Strip "#1234" store numbers - .replace(/\s*-\s*.*$/, '') // Strip suffixes like " - Downtown" - .replace(/^the\s+/, '') // Strip leading "The" - .replace(/['']/g, '') // Normalize apostrophes - .replace(/\s+/g, ' ') // Collapse whitespace - .trim(); - - for (const [chain, priceRange] of Object.entries(CHAIN_PRICE_MAP)) { - if (normalized.includes(chain)) { - return priceRange; - } - } - return null; -} diff --git a/wing-command/lib/deals.ts b/wing-command/lib/deals.ts deleted file mode 100644 index c164123ec..000000000 --- a/wing-command/lib/deals.ts +++ /dev/null @@ -1,396 +0,0 @@ -// =========================================== -// Wing Scout — Super Bowl Deals Scraper -// Aggregator-first approach: scrape deal roundup pages for ALL chains -// then fuzzy-match to specific WingSpots -// Falls back to website-only scrape for local restaurants -// =========================================== - -import { SuperBowlDeal, AggregatorDeal, PlatformIds, TinyFishResponse } from './types'; -import { runTinyFishScrape } from './tinyfish-scraper'; -import { - cacheDeals, - cacheAggregatorDeals, - clearAggregatorScoutingLock, - clearDealsScoutingLock, -} from './cache'; - -// Railway has no runtime limit — generous timeout for aggregator pages -const AGGREGATOR_SCRAPE_TIMEOUT = 180000; // 3 minutes per aggregator page -const FALLBACK_SCRAPE_TIMEOUT = 360000; // 6 minutes for direct website scrape (slow sites) - -// =========================================== -// Aggregator Sources — scraped in parallel -// =========================================== - -const AGGREGATOR_SOURCES = [ - { - name: 'KrazyCouponLady', - url: 'https://thekrazycouponlady.com/tips/money/super-bowl-restaurant-freebies', - }, - { - name: 'TODAY', - url: 'https://www.today.com/food/restaurants/super-bowl-food-deals-2026-rcna255970', - }, - { - name: 'BrandEating', - url: 'https://www.brandeating.com/2026/02/2026-super-bowl-deals-and-specials.html', - }, -]; - -// =========================================== -// Aggregator TinyFish Goal Prompt -// =========================================== - -const AGGREGATOR_GOAL = `Extract ALL restaurant Super Bowl deals and specials from this page. - -Return a JSON object with a "deals" array where each entry has: -- restaurant_name (the restaurant/chain name, e.g. "Buffalo Wild Wings", "Hooters", "Domino's") -- description (full deal text, e.g. "Get 20 free boneless wings with $40 purchase") -- promo_code (any promo/coupon code mentioned, e.g. "KICKOFF26") -- pre_order_deadline (any ordering deadline or validity dates, e.g. "Valid Feb 6-9") - -Extract EVERY restaurant deal listed on the page. Include all chains and restaurants mentioned. -If a restaurant has multiple deals, create a separate entry for each deal. -Return the JSON object ONLY, no other text. - -If no deals found on this page, return {"deals": []}.`; - -// =========================================== -// Name Normalization & Fuzzy Matching -// =========================================== - -/** - * Normalize a restaurant name for fuzzy matching. - * "Buffalo Wild Wings - Downtown #1234" → "buffalo wild wings" - * "Hooters of Tampa" → "hooters" - * "Wingstop #456" → "wingstop" - * "Domino's Pizza" → "dominos pizza" - */ -export function normalizeRestaurantName(name: string): string { - return name - .toLowerCase() - .trim() - .replace(/\s*#\d+.*$/, '') // Strip "#1234" store numbers - .replace(/\s*-\s*.*$/, '') // Strip " - Downtown" suffixes - .replace(/\s+of\s+\w+.*$/i, '') // Strip "of Tampa", "of Chicago" - .replace(/^the\s+/i, '') // Strip leading "The" - .replace(/['''`]/g, '') // Strip apostrophes (Domino's → Dominos) - .replace(/[^\w\s]/g, '') // Strip non-alphanumeric (keep spaces) - .replace(/\s+/g, ' ') // Collapse whitespace - .trim(); -} - -/** - * Match aggregator deals to a specific WingSpot by restaurant name. - * Uses a 2-pass approach: - * 1. Exact normalized match - * 2. Substring containment (aggregator "Hooters" matches spot "Hooters of Tampa") - */ -export function matchDealsToSpot( - spotName: string, - aggregatorDeals: AggregatorDeal[] -): SuperBowlDeal[] { - const normalizedSpot = normalizeRestaurantName(spotName); - if (!normalizedSpot) return []; - - // Pass 1: exact normalized match - for (const entry of aggregatorDeals) { - const normalizedAgg = normalizeRestaurantName(entry.restaurant_name); - if (normalizedAgg === normalizedSpot) { - return entry.deals; - } - } - - // Pass 2: substring containment (either direction) - for (const entry of aggregatorDeals) { - const normalizedAgg = normalizeRestaurantName(entry.restaurant_name); - if (normalizedSpot.includes(normalizedAgg) || normalizedAgg.includes(normalizedSpot)) { - return entry.deals; - } - } - - return []; -} - -// =========================================== -// Aggregator Page Scraping -// =========================================== - -/** - * Scrape a single aggregator page and parse into AggregatorDeal[]. - */ -async function scrapeAggregatorPage( - sourceName: string, - url: string, -): Promise { - try { - console.log(`Aggregator: scraping ${sourceName}: ${url}`); - const result = await runTinyFishScrape(url, AGGREGATOR_GOAL, AGGREGATOR_SCRAPE_TIMEOUT); - return parseAggregatorResponse(result); - } catch (error) { - console.error(`Aggregator scrape error for ${sourceName}:`, error); - return []; - } -} - -/** - * Parse TinyFish response from aggregator page into AggregatorDeal[]. - * Groups deals by restaurant_name. - */ -function parseAggregatorResponse(result: TinyFishResponse): AggregatorDeal[] { - if (!result.success || !result.data) return []; - - try { - const data = result.data as { - deals?: Array<{ - restaurant_name?: string; - description?: string; - promo_code?: string; - pre_order_deadline?: string; - }>; - }; - - const rawDeals = data.deals || []; - if (rawDeals.length === 0) return []; - - // Group by restaurant name - const grouped = new Map(); - - for (const d of rawDeals) { - const restaurantName = d.restaurant_name ? String(d.restaurant_name).trim() : ''; - const description = d.description ? String(d.description).trim() : ''; - - if (!restaurantName || !description) continue; - - if (!grouped.has(restaurantName)) { - grouped.set(restaurantName, []); - } - - grouped.get(restaurantName)!.push({ - description, - source: 'aggregator', - promo_code: d.promo_code ? String(d.promo_code).trim() : undefined, - pre_order_deadline: d.pre_order_deadline ? String(d.pre_order_deadline).trim() : undefined, - }); - } - - // Convert to AggregatorDeal array - const aggregatorDeals: AggregatorDeal[] = []; - grouped.forEach((deals, restaurant_name) => { - aggregatorDeals.push({ restaurant_name, deals }); - }); - - console.log(`Aggregator: parsed ${rawDeals.length} deals across ${aggregatorDeals.length} restaurants`); - return aggregatorDeals; - } catch (error) { - console.error('Failed to parse aggregator response:', error); - return []; - } -} - -/** - * Merge AggregatorDeal[] from multiple sources. - * Deduplicates by normalized restaurant name, merging deals. - */ -function mergeAggregatorResults(sources: AggregatorDeal[][]): AggregatorDeal[] { - const merged = new Map(); - - for (const deals of sources) { - for (const entry of deals) { - const key = normalizeRestaurantName(entry.restaurant_name); - if (!key) continue; - - if (merged.has(key)) { - // Merge deals from this source into existing entry - const existing = merged.get(key)!; - const existingDescs = new Set( - existing.deals.map(d => d.description.toLowerCase().substring(0, 50)) - ); - - // Add non-duplicate deals - for (const deal of entry.deals) { - const descKey = deal.description.toLowerCase().substring(0, 50); - if (!existingDescs.has(descKey)) { - existing.deals.push(deal); - existingDescs.add(descKey); - } - } - } else { - merged.set(key, { ...entry }); - } - } - } - - return Array.from(merged.values()); -} - -// =========================================== -// Background Aggregator Scrape (fire-and-forget) -// =========================================== - -/** - * Scrape all 3 aggregator pages in parallel, merge results, cache globally. - * Called once per 2-hour window — covers ALL chain restaurants. - */ -export function startBackgroundAggregatorScrape(): void { - console.log('Starting background aggregator deals scrape (3 sources in parallel)'); - - (async () => { - try { - // Scrape all 3 aggregator pages simultaneously - const results = await Promise.allSettled( - AGGREGATOR_SOURCES.map(src => scrapeAggregatorPage(src.name, src.url)) - ); - - // Collect successful results - const successfulResults: AggregatorDeal[][] = []; - for (let i = 0; i < results.length; i++) { - const result = results[i]; - if (result.status === 'fulfilled' && result.value.length > 0) { - console.log(`Aggregator ${AGGREGATOR_SOURCES[i].name}: ${result.value.length} restaurants found`); - successfulResults.push(result.value); - } else if (result.status === 'rejected') { - console.error(`Aggregator ${AGGREGATOR_SOURCES[i].name}: failed`, result.reason); - } else { - console.log(`Aggregator ${AGGREGATOR_SOURCES[i].name}: 0 results`); - } - } - - // Merge + deduplicate across all sources - const merged = mergeAggregatorResults(successfulResults); - console.log(`Aggregator merge: ${merged.length} unique restaurants total`); - - // Cache globally (2-hour TTL) — even empty to prevent re-scraping - await cacheAggregatorDeals(merged); - - console.log(`Background aggregator scrape SUCCESS: ${merged.length} restaurants cached`); - } catch (err) { - console.error('Background aggregator scrape error:', err); - } finally { - await clearAggregatorScoutingLock(); - } - })(); -} - -// =========================================== -// Per-Restaurant Website-Only Fallback -// =========================================== - -/** - * Scrape a restaurant's website for Super Bowl specials. - * Used as fallback for local restaurants not found in aggregator data. - * Website-only (no Instagram) — 1 TinyFish call instead of 2. - */ -async function scrapeWebsiteForDeals( - name: string, - address: string, - websiteUrl?: string, -): Promise { - const url = websiteUrl || `https://www.google.com/search?q=${encodeURIComponent(`${name} ${address} super bowl specials game day deals`)}`; - - const goal = websiteUrl - ? `Visit this restaurant website and look for ANY Super Bowl specials, game day deals, pre-order information, party platters, catering specials, or limited-time promotions for Super Bowl Sunday (February 8, 2026). -Check: Homepage banners, popups, hero sections, Special/Events/Promotions pages, Catering or Party pages. -Look for: "Super Bowl", "game day", "big game", "SB", "special", "deal", "pre-order", "catering", "party platter", "wings special", "game day bundle". - -Return a JSON object with a "deals" array. Each deal should have: -- description (the full deal text, e.g. "$49.99 Big Game Special: 3 large pizzas + 20 wings") -- promo_code (any promo/coupon code if mentioned) -- pre_order_deadline (ordering deadline if mentioned, e.g. "Available through Sunday night") -- pre_order_url (URL to pre-order page if found) -- special_items (array of special menu item names if listed) - -If NO Super Bowl or game day deals are found, return {"deals": []}. -Return the JSON object ONLY, no other text.` - : `Search for Super Bowl deals, game day specials, or promotions for this restaurant. - -IMPORTANT: First, read the CURRENT page carefully — look at ALL visible content including: -- Google search result snippets and descriptions -- Social media post previews (Facebook, Instagram) visible in search results -- News article headlines and summaries -- Any mention of deals, specials, promo codes, party platters, or game day offers - -If you can see deal information on this page (even in snippets/previews), extract it immediately. -Only click through to another page if NO deal info is visible on the current page. - -DO NOT click on Facebook or Instagram links (they require login). -If clicking through, prefer the restaurant's own website, news articles, or food blogs. - -Return a JSON object with a "deals" array. Each deal should have: -- description (the full deal text, e.g. "$49.99 Big Game Special: 3 large pizzas + 20 wings") -- promo_code (any promo/coupon code if mentioned) -- pre_order_deadline (ordering deadline if mentioned, e.g. "Available through Sunday night") -- pre_order_url (URL to pre-order page if found) -- special_items (array of special menu item names if listed) - -If NO Super Bowl or game day deals are found, return {"deals": []}. -Return the JSON object ONLY, no other text.`; - - try { - console.log(`Deals fallback: scraping website for ${name}: ${url}`); - const result = await runTinyFishScrape(url, goal, FALLBACK_SCRAPE_TIMEOUT); - return parseFallbackDeals(result); - } catch (error) { - console.error(`Website deals fallback error for ${name}:`, error); - return []; - } -} - -/** - * Parse deals from a per-restaurant TinyFish scrape result. - */ -function parseFallbackDeals(result: TinyFishResponse): SuperBowlDeal[] { - if (!result.success || !result.data) return []; - - try { - const data = result.data as { deals?: Array> }; - const rawDeals = data.deals || []; - - return rawDeals - .filter(d => d.description && String(d.description).trim().length > 0) - .map(d => ({ - description: String(d.description).trim(), - source: 'website' as const, - promo_code: d.promo_code ? String(d.promo_code).trim() : undefined, - pre_order_deadline: d.pre_order_deadline ? String(d.pre_order_deadline).trim() : undefined, - pre_order_url: d.pre_order_url && String(d.pre_order_url).startsWith('http') - ? String(d.pre_order_url).trim() - : undefined, - special_menu_items: Array.isArray(d.special_items) - ? (d.special_items as string[]).map(s => String(s).trim()).filter(Boolean) - : undefined, - })); - } catch (error) { - console.error('Failed to parse fallback deals:', error); - return []; - } -} - -/** - * Fire-and-forget background website-only scrape for a single restaurant. - * Used as fallback for local restaurants not found in aggregator data. - */ -export function startBackgroundDealsScrape( - spotId: string, - name: string, - address: string, - platformIds?: PlatformIds -): void { - console.log(`Starting background website-only deals scrape for ${spotId}: ${name}`); - - (async () => { - try { - const websiteUrl = platformIds?.website_url; - const deals = await scrapeWebsiteForDeals(name, address, websiteUrl); - - // Cache in Redis (30-min TTL) — even empty arrays to prevent re-scraping - await cacheDeals(spotId, deals); - - console.log(`Background deals fallback SUCCESS for ${spotId}: ${deals.length} deal(s) cached`); - } catch (err) { - console.error(`Background deals fallback error for ${spotId}:`, err); - } finally { - await clearDealsScoutingLock(spotId); - } - })(); -} diff --git a/wing-command/lib/env.ts b/wing-command/lib/env.ts index 3a1bab867..ef48f7c5f 100644 --- a/wing-command/lib/env.ts +++ b/wing-command/lib/env.ts @@ -1,73 +1,9 @@ -// =========================================== -// Wing Scout v2 — Environment Variable Validation -// =========================================== - -const requiredServerEnvVars = [ - 'SUPABASE_SERVICE_ROLE_KEY', - 'TINYFISH_API_KEY', -] as const; - -const requiredClientEnvVars = [ - 'NEXT_PUBLIC_SUPABASE_URL', - 'NEXT_PUBLIC_SUPABASE_ANON_KEY', -] as const; - -const optionalEnvVars = [ - 'UPSTASH_REDIS_REST_URL', - 'UPSTASH_REDIS_REST_TOKEN', - 'TINYFISH_API_URL', -] as const; - -export function validateEnv(): { - valid: boolean; - missing: string[]; - warnings: string[]; -} { - const missing: string[] = []; - const warnings: string[] = []; - - for (const envVar of requiredServerEnvVars) { - if (!process.env[envVar]) missing.push(envVar); - } - - for (const envVar of requiredClientEnvVars) { - if (!process.env[envVar]) missing.push(envVar); - } - - for (const envVar of optionalEnvVars) { - if (!process.env[envVar]) warnings.push(`Optional: ${envVar} not set`); - } - - const hasRedisUrl = !!process.env.UPSTASH_REDIS_REST_URL; - const hasRedisToken = !!process.env.UPSTASH_REDIS_REST_TOKEN; - if (hasRedisUrl !== hasRedisToken) { - warnings.push('Redis: Both UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN must be set together'); - } - - return { valid: missing.length === 0, missing, warnings }; -} - -export function getEnv(key: string, defaultValue?: string): string { - const value = process.env[key]; - if (value === undefined) { - if (defaultValue !== undefined) return defaultValue; - throw new Error(`Environment variable ${key} is not set`); - } - return value; -} - -export function getOptionalEnv(key: string): string | undefined { - return process.env[key]; -} - -export function logEnvValidation(): void { - const result = validateEnv(); - if (!result.valid) { - console.error('MISSING REQUIRED ENVIRONMENT VARIABLES:'); - result.missing.forEach(v => console.error(` - ${v}`)); - } - if (result.warnings.length > 0) { - console.warn('Environment warnings:'); - result.warnings.forEach(w => console.warn(` - ${w}`)); - } +// Wing Command v4 — Environment validation + +export function validateEnv() { + const required = ['GROQ_API_KEY', 'TINYFISH_API_KEY']; + const missing = required.filter((key) => !process.env[key]); + if (missing.length > 0) { + console.warn(`Missing env vars: ${missing.join(', ')}`); + } } diff --git a/wing-command/lib/geocode.ts b/wing-command/lib/geocode.ts index d12cacb61..eedab968e 100644 --- a/wing-command/lib/geocode.ts +++ b/wing-command/lib/geocode.ts @@ -1,369 +1,128 @@ -// =========================================== -// Wing Scout - Geocoding Service -// Fallback chain: Nominatim → Hardcoded → Zippopotam.us -// =========================================== - -import axios from 'axios'; -import { GeocodedLocation } from './types'; -import { getCachedGeocode as getCachedGeocodeRedis, cacheGeocode as cacheGeocodeRedis } from './cache'; -import { createServerClient, getCachedGeocode as getCachedGeocodeSupabase, cacheGeocode as cacheGeocodeSupabase } from './supabase'; - -// Nominatim API (OpenStreetMap - free, no key required) -const NOMINATIM_BASE_URL = 'https://nominatim.openstreetmap.org'; - -// User agent for Nominatim (required by their policy) -const USER_AGENT = 'WingScout/1.0 (super-bowl-wing-tracker)'; +// ============================================= +// Wing Command v4 — Geocoding +// No Redis. No Supabase. Pure fetch. +// Fallback chain: hardcoded table → Nominatim → Zippopotam.us +// ============================================= + +export interface GeocodedLocation { + zip_code: string; + city: string; + state: string; + lat: number; + lng: number; +} -// ─── Hardcoded zip lookup table ──────────────────────────────────────────── -// Covers Super Bowl host cities, top US metros, and known-failing zips. -// Zero external dependency, instant response. +// ─── Hardcoded fast-path ────────────────────────────────────────────────── const ZIP_COORDS: Record = { - // Super Bowl LX & recent host cities - '70112': { city: 'New Orleans', state: 'Louisiana', lat: 29.9544, lng: -90.0703 }, - '70113': { city: 'New Orleans', state: 'Louisiana', lat: 29.9486, lng: -90.0812 }, - '70116': { city: 'New Orleans', state: 'Louisiana', lat: 29.9621, lng: -90.0589 }, - '70119': { city: 'New Orleans', state: 'Louisiana', lat: 29.9782, lng: -90.0888 }, - '70130': { city: 'New Orleans', state: 'Louisiana', lat: 29.9348, lng: -90.0854 }, - '33101': { city: 'Miami', state: 'Florida', lat: 25.7751, lng: -80.1947 }, - '33109': { city: 'Miami Beach', state: 'Florida', lat: 25.7617, lng: -80.1340 }, - '33130': { city: 'Miami', state: 'Florida', lat: 25.7672, lng: -80.2042 }, - '33139': { city: 'Miami Beach', state: 'Florida', lat: 25.7828, lng: -80.1342 }, - '85001': { city: 'Phoenix', state: 'Arizona', lat: 33.4484, lng: -112.0773 }, - '85003': { city: 'Phoenix', state: 'Arizona', lat: 33.4510, lng: -112.0820 }, - '85281': { city: 'Tempe', state: 'Arizona', lat: 33.4148, lng: -111.9093 }, - '85284': { city: 'Tempe', state: 'Arizona', lat: 33.3831, lng: -111.9093 }, - '91301': { city: 'Agoura Hills', state: 'California', lat: 34.1362, lng: -118.7606 }, - '90001': { city: 'Los Angeles', state: 'California', lat: 33.9425, lng: -118.2551 }, - '90012': { city: 'Los Angeles', state: 'California', lat: 34.0622, lng: -118.2406 }, - '90015': { city: 'Los Angeles', state: 'California', lat: 34.0393, lng: -118.2650 }, - '90210': { city: 'Beverly Hills', state: 'California', lat: 34.0901, lng: -118.4065 }, - '90301': { city: 'Inglewood', state: 'California', lat: 33.9562, lng: -118.3468 }, - '90401': { city: 'Santa Monica', state: 'California', lat: 34.0171, lng: -118.4964 }, - - // Top US metros - '10001': { city: 'New York', state: 'New York', lat: 40.7484, lng: -73.9967 }, - '10019': { city: 'New York', state: 'New York', lat: 40.7654, lng: -73.9855 }, - '10036': { city: 'New York', state: 'New York', lat: 40.7590, lng: -73.9891 }, - '11201': { city: 'Brooklyn', state: 'New York', lat: 40.6934, lng: -73.9893 }, - '60601': { city: 'Chicago', state: 'Illinois', lat: 41.8862, lng: -87.6186 }, - '60614': { city: 'Chicago', state: 'Illinois', lat: 41.9219, lng: -87.6490 }, - '60657': { city: 'Chicago', state: 'Illinois', lat: 41.9400, lng: -87.6530 }, - '77001': { city: 'Houston', state: 'Texas', lat: 29.7543, lng: -95.3536 }, - '77002': { city: 'Houston', state: 'Texas', lat: 29.7545, lng: -95.3596 }, - '77030': { city: 'Houston', state: 'Texas', lat: 29.7071, lng: -95.4013 }, - '75201': { city: 'Dallas', state: 'Texas', lat: 32.7875, lng: -96.7985 }, - '75202': { city: 'Dallas', state: 'Texas', lat: 32.7830, lng: -96.7998 }, - '78201': { city: 'San Antonio', state: 'Texas', lat: 29.4654, lng: -98.5253 }, - '78205': { city: 'San Antonio', state: 'Texas', lat: 29.4241, lng: -98.4936 }, - '92101': { city: 'San Diego', state: 'California', lat: 32.7199, lng: -117.1628 }, - '94102': { city: 'San Francisco', state: 'California', lat: 37.7793, lng: -122.4193 }, - '94103': { city: 'San Francisco', state: 'California', lat: 37.7726, lng: -122.4113 }, - '94110': { city: 'San Francisco', state: 'California', lat: 37.7488, lng: -122.4153 }, - '95101': { city: 'San Jose', state: 'California', lat: 37.3361, lng: -121.8906 }, - '78701': { city: 'Austin', state: 'Texas', lat: 30.2672, lng: -97.7431 }, - '32801': { city: 'Orlando', state: 'Florida', lat: 28.5383, lng: -81.3792 }, - '32803': { city: 'Orlando', state: 'Florida', lat: 28.5560, lng: -81.3560 }, - '33602': { city: 'Tampa', state: 'Florida', lat: 27.9516, lng: -82.4588 }, - '30301': { city: 'Atlanta', state: 'Georgia', lat: 33.7627, lng: -84.3892 }, - '30303': { city: 'Atlanta', state: 'Georgia', lat: 33.7527, lng: -84.3904 }, - '30309': { city: 'Atlanta', state: 'Georgia', lat: 33.7890, lng: -84.3833 }, - '98101': { city: 'Seattle', state: 'Washington', lat: 47.6101, lng: -122.3421 }, - '98109': { city: 'Seattle', state: 'Washington', lat: 47.6319, lng: -122.3472 }, - '80202': { city: 'Denver', state: 'Colorado', lat: 39.7530, lng: -105.0001 }, - '80203': { city: 'Denver', state: 'Colorado', lat: 39.7312, lng: -104.9827 }, - '02101': { city: 'Boston', state: 'Massachusetts', lat: 42.3601, lng: -71.0589 }, - '02116': { city: 'Boston', state: 'Massachusetts', lat: 42.3503, lng: -71.0775 }, - '19101': { city: 'Philadelphia', state: 'Pennsylvania', lat: 39.9526, lng: -75.1652 }, - '19103': { city: 'Philadelphia', state: 'Pennsylvania', lat: 39.9529, lng: -75.1727 }, - '55401': { city: 'Minneapolis', state: 'Minnesota', lat: 44.9858, lng: -93.2690 }, - '55402': { city: 'Minneapolis', state: 'Minnesota', lat: 44.9758, lng: -93.2748 }, - '48201': { city: 'Detroit', state: 'Michigan', lat: 42.3389, lng: -83.0500 }, - '48226': { city: 'Detroit', state: 'Michigan', lat: 42.3297, lng: -83.0454 }, - '63101': { city: 'St. Louis', state: 'Missouri', lat: 38.6270, lng: -90.1994 }, - '21201': { city: 'Baltimore', state: 'Maryland', lat: 39.2904, lng: -76.6122 }, - '20001': { city: 'Washington', state: 'District of Columbia', lat: 38.9072, lng: -77.0169 }, - '20003': { city: 'Washington', state: 'District of Columbia', lat: 38.8818, lng: -76.9905 }, - '28201': { city: 'Charlotte', state: 'North Carolina', lat: 35.2271, lng: -80.8431 }, - '37201': { city: 'Nashville', state: 'Tennessee', lat: 36.1627, lng: -86.7816 }, - '46201': { city: 'Indianapolis', state: 'Indiana', lat: 39.7684, lng: -86.1581 }, - '64101': { city: 'Kansas City', state: 'Missouri', lat: 39.1006, lng: -94.5783 }, - '89101': { city: 'Las Vegas', state: 'Nevada', lat: 36.1699, lng: -115.1398 }, - '89109': { city: 'Las Vegas', state: 'Nevada', lat: 36.1281, lng: -115.1614 }, - '89119': { city: 'Las Vegas', state: 'Nevada', lat: 36.0840, lng: -115.1499 }, - '15201': { city: 'Pittsburgh', state: 'Pennsylvania', lat: 40.4783, lng: -79.9550 }, - '15222': { city: 'Pittsburgh', state: 'Pennsylvania', lat: 40.4498, lng: -80.0000 }, - '45201': { city: 'Cincinnati', state: 'Ohio', lat: 39.1031, lng: -84.5120 }, - '53201': { city: 'Milwaukee', state: 'Wisconsin', lat: 43.0389, lng: -87.9065 }, - '84101': { city: 'Salt Lake City', state: 'Utah', lat: 40.7608, lng: -111.8910 }, - '44101': { city: 'Cleveland', state: 'Ohio', lat: 41.4993, lng: -81.6944 }, - '43201': { city: 'Columbus', state: 'Ohio', lat: 39.9862, lng: -83.0032 }, - - // Known-failing zip: Portland, OR - '97201': { city: 'Portland', state: 'Oregon', lat: 45.5189, lng: -122.6868 }, - '97202': { city: 'Portland', state: 'Oregon', lat: 45.4834, lng: -122.6370 }, - '97210': { city: 'Portland', state: 'Oregon', lat: 45.5267, lng: -122.7003 }, - '97214': { city: 'Portland', state: 'Oregon', lat: 45.5134, lng: -122.6430 }, + '70112': { city: 'New Orleans', state: 'Louisiana', lat: 29.9544, lng: -90.0703 }, + '70130': { city: 'New Orleans', state: 'Louisiana', lat: 29.9348, lng: -90.0854 }, + '33101': { city: 'Miami', state: 'Florida', lat: 25.7751, lng: -80.1947 }, + '33130': { city: 'Miami', state: 'Florida', lat: 25.7672, lng: -80.2042 }, + '85001': { city: 'Phoenix', state: 'Arizona', lat: 33.4484, lng: -112.0773 }, + '85281': { city: 'Tempe', state: 'Arizona', lat: 33.4148, lng: -111.9093 }, + '90001': { city: 'Los Angeles', state: 'California', lat: 33.9425, lng: -118.2551 }, + '90015': { city: 'Los Angeles', state: 'California', lat: 34.0393, lng: -118.2650 }, + '90210': { city: 'Beverly Hills', state: 'California', lat: 34.0901, lng: -118.4065 }, + '90301': { city: 'Inglewood', state: 'California', lat: 33.9562, lng: -118.3468 }, + '10001': { city: 'New York', state: 'New York', lat: 40.7484, lng: -73.9967 }, + '10019': { city: 'New York', state: 'New York', lat: 40.7654, lng: -73.9855 }, + '11201': { city: 'Brooklyn', state: 'New York', lat: 40.6934, lng: -73.9893 }, + '60601': { city: 'Chicago', state: 'Illinois', lat: 41.8862, lng: -87.6186 }, + '60614': { city: 'Chicago', state: 'Illinois', lat: 41.9219, lng: -87.6490 }, + '77001': { city: 'Houston', state: 'Texas', lat: 29.7543, lng: -95.3536 }, + '75201': { city: 'Dallas', state: 'Texas', lat: 32.7875, lng: -96.7985 }, + '78701': { city: 'Austin', state: 'Texas', lat: 30.2672, lng: -97.7431 }, + '78201': { city: 'San Antonio', state: 'Texas', lat: 29.4654, lng: -98.5253 }, + '92101': { city: 'San Diego', state: 'California', lat: 32.7199, lng: -117.1628 }, + '94102': { city: 'San Francisco', state: 'California', lat: 37.7793, lng: -122.4193 }, + '94110': { city: 'San Francisco', state: 'California', lat: 37.7488, lng: -122.4153 }, + '95101': { city: 'San Jose', state: 'California', lat: 37.3361, lng: -121.8906 }, + '32801': { city: 'Orlando', state: 'Florida', lat: 28.5383, lng: -81.3792 }, + '33602': { city: 'Tampa', state: 'Florida', lat: 27.9516, lng: -82.4588 }, + '30301': { city: 'Atlanta', state: 'Georgia', lat: 33.7627, lng: -84.3892 }, + '30309': { city: 'Atlanta', state: 'Georgia', lat: 33.7890, lng: -84.3833 }, + '98101': { city: 'Seattle', state: 'Washington', lat: 47.6101, lng: -122.3421 }, + '80202': { city: 'Denver', state: 'Colorado', lat: 39.7530, lng: -105.0001 }, + '02101': { city: 'Boston', state: 'Massachusetts', lat: 42.3601, lng: -71.0589 }, + '02116': { city: 'Boston', state: 'Massachusetts', lat: 42.3503, lng: -71.0775 }, + '19101': { city: 'Philadelphia', state: 'Pennsylvania', lat: 39.9526, lng: -75.1652 }, + '55401': { city: 'Minneapolis', state: 'Minnesota', lat: 44.9858, lng: -93.2690 }, + '48201': { city: 'Detroit', state: 'Michigan', lat: 42.3389, lng: -83.0500 }, + '20001': { city: 'Washington', state: 'District of Columbia', lat: 38.9072, lng: -77.0169 }, + '28201': { city: 'Charlotte', state: 'North Carolina', lat: 35.2271, lng: -80.8431 }, + '37201': { city: 'Nashville', state: 'Tennessee', lat: 36.1627, lng: -86.7816 }, + '46201': { city: 'Indianapolis', state: 'Indiana', lat: 39.7684, lng: -86.1581 }, + '89101': { city: 'Las Vegas', state: 'Nevada', lat: 36.1699, lng: -115.1398 }, + '89109': { city: 'Las Vegas', state: 'Nevada', lat: 36.1281, lng: -115.1614 }, + '15201': { city: 'Pittsburgh', state: 'Pennsylvania', lat: 40.4783, lng: -79.9550 }, + '43201': { city: 'Columbus', state: 'Ohio', lat: 39.9862, lng: -83.0032 }, + '97201': { city: 'Portland', state: 'Oregon', lat: 45.5189, lng: -122.6868 }, + '97214': { city: 'Portland', state: 'Oregon', lat: 45.5134, lng: -122.6430 }, + '84101': { city: 'Salt Lake City', state: 'Utah', lat: 40.7608, lng: -111.8910 }, }; -// ─── Nominatim geocoder with retry ───────────────────────────────────────── - +// ─── Nominatim ──────────────────────────────────────────────────────────── async function geocodeWithNominatim(zipCode: string): Promise { - const MAX_ATTEMPTS = 2; - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - try { - const response = await axios.get(`${NOMINATIM_BASE_URL}/search`, { - params: { - postalcode: zipCode, - country: 'United States', - format: 'json', - addressdetails: 1, - limit: 1, - }, - headers: { 'User-Agent': USER_AGENT }, - timeout: 10000, - }); - - if (!response.data || response.data.length === 0) { - // Valid response, just no results — don't retry - console.warn(`No Nominatim results for zip: ${zipCode}`); - return null; - } - - const result = response.data[0]; - return { - zip_code: zipCode, - city: result.address?.city - || result.address?.town - || result.address?.village - || result.address?.hamlet - || result.address?.municipality - || 'Unknown', - state: result.address?.state || 'Unknown', - lat: parseFloat(result.lat), - lng: parseFloat(result.lon), - cached_at: new Date().toISOString(), - }; - } catch (error) { - // Log full error details for debugging - if (axios.isAxiosError(error)) { - console.error(`Nominatim attempt ${attempt}/${MAX_ATTEMPTS} failed for ${zipCode}:`, { - message: error.message || '(empty)', - code: error.code, - status: error.response?.status, - statusText: error.response?.statusText, - }); - } else { - console.error(`Nominatim attempt ${attempt}/${MAX_ATTEMPTS} failed for ${zipCode}:`, error); - } - - if (attempt < MAX_ATTEMPTS) { - console.warn(`Retrying Nominatim for ${zipCode} in 1.5s...`); - await new Promise(r => setTimeout(r, 1500)); - } - // On final attempt, fall through to return null - } - } - return null; -} - -// ─── Hardcoded zip lookup ────────────────────────────────────────────────── - -function lookupHardcodedZip(zipCode: string): GeocodedLocation | null { - const entry = ZIP_COORDS[zipCode]; - if (!entry) return null; + try { + const url = new URL('https://nominatim.openstreetmap.org/search'); + url.searchParams.set('postalcode', zipCode); + url.searchParams.set('country', 'United States'); + url.searchParams.set('format', 'json'); + url.searchParams.set('addressdetails', '1'); + url.searchParams.set('limit', '1'); + + const res = await fetch(url.toString(), { + headers: { 'User-Agent': 'WingCommand/4.0' }, + signal: AbortSignal.timeout(8000), + }); + const data = await res.json(); + if (!data?.length) return null; + + const r = data[0]; return { - zip_code: zipCode, - city: entry.city, - state: entry.state, - lat: entry.lat, - lng: entry.lng, - cached_at: new Date().toISOString(), + zip_code: zipCode, + city: r.address?.city || r.address?.town || r.address?.village || 'Unknown', + state: r.address?.state || 'Unknown', + lat: parseFloat(r.lat), + lng: parseFloat(r.lon), }; + } catch { + return null; + } } -// ─── Zippopotam.us fallback (free, no key, zip-code-optimized) ───────────── - +// ─── Zippopotam.us ──────────────────────────────────────────────────────── async function geocodeWithZippopotamus(zipCode: string): Promise { - try { - const response = await axios.get(`https://api.zippopotam.us/us/${zipCode}`, { - timeout: 10000, - headers: { 'User-Agent': USER_AGENT }, - }); - - const place = response.data?.places?.[0]; - if (!place) { - console.warn(`No Zippopotam.us results for zip: ${zipCode}`); - return null; - } - - return { - zip_code: zipCode, - city: place['place name'] || 'Unknown', - state: place.state || 'Unknown', - lat: parseFloat(place.latitude), - lng: parseFloat(place.longitude), - cached_at: new Date().toISOString(), - }; - } catch (error) { - if (axios.isAxiosError(error)) { - console.error(`Zippopotam.us failed for ${zipCode}:`, { - message: error.message || '(empty)', - code: error.code, - status: error.response?.status, - }); - } else { - console.error(`Zippopotam.us failed for ${zipCode}:`, error); - } - return null; - } + try { + const res = await fetch(`https://api.zippopotam.us/us/${zipCode}`, { + signal: AbortSignal.timeout(8000), + }); + if (!res.ok) return null; + const data = await res.json(); + const place = data?.places?.[0]; + if (!place) return null; + return { + zip_code: zipCode, + city: place['place name'] || 'Unknown', + state: place.state || 'Unknown', + lat: parseFloat(place.latitude), + lng: parseFloat(place.longitude), + }; + } catch { + return null; + } } -// ─── Main geocoding function with fallback chain ──────────────────────────── - -/** - * Geocode a US zip code using fallback chain: - * 1. Redis cache - * 2. Supabase cache - * 3. Nominatim (with 1 retry) - * 4. Hardcoded ZIP_COORDS table - * 5. Zippopotam.us API - */ +// ─── Main export ────────────────────────────────────────────────────────── export async function geocodeZipCode(zipCode: string): Promise { - // 1. Check Redis cache - const redisCache = await getCachedGeocodeRedis(zipCode); - if (redisCache) { - console.log(`Geocode cache hit (Redis): ${zipCode}`); - return redisCache; - } + // 1. Hardcoded table (instant) + const entry = ZIP_COORDS[zipCode]; + if (entry) return { zip_code: zipCode, ...entry }; - // 2. Check Supabase cache - try { - const supabase = createServerClient(); - const { data: supabaseCache } = await getCachedGeocodeSupabase(supabase, zipCode); - if (supabaseCache) { - console.log(`Geocode cache hit (Supabase): ${zipCode}`); - await cacheGeocodeRedis(supabaseCache); - return supabaseCache; - } - } catch (error) { - console.error('Supabase geocode cache check failed:', error); - } + // 2. Nominatim + const nominatim = await geocodeWithNominatim(zipCode); + if (nominatim) return nominatim; - // 3. Cache miss — run fallback chain - console.log(`Geocoding zip code: ${zipCode} (cache miss — trying providers)`); - - // Attempt 1: Nominatim (with retry) - let geocoded = await geocodeWithNominatim(zipCode); - if (geocoded) { - console.log(`Nominatim success for ${zipCode}: ${geocoded.city}, ${geocoded.state}`); - } - - // Attempt 2: Hardcoded lookup table - if (!geocoded) { - console.log(`Trying hardcoded lookup for ${zipCode}...`); - geocoded = lookupHardcodedZip(zipCode); - if (geocoded) console.log(`Hardcoded hit: ${geocoded.city}, ${geocoded.state}`); - } - - // Attempt 3: Zippopotam.us - if (!geocoded) { - console.log(`Trying Zippopotam.us for ${zipCode}...`); - geocoded = await geocodeWithZippopotamus(zipCode); - if (geocoded) console.log(`Zippopotam.us hit: ${geocoded.city}, ${geocoded.state}`); - } - - // All providers failed - if (!geocoded) { - console.error(`All geocoding providers failed for ${zipCode}`); - return null; - } - - // Cache the result regardless of which provider succeeded - await cacheGeocodeRedis(geocoded); - try { - const supabase = createServerClient(); - await cacheGeocodeSupabase(supabase, geocoded); - } catch (error) { - console.error('Failed to cache geocode in Supabase:', error); - } - - return geocoded; -} - -/** - * Batch geocode multiple zip codes - * Respects Nominatim rate limit (1 req/sec) - */ -export async function batchGeocodeZipCodes( - zipCodes: string[], - delayMs: number = 1100 -): Promise> { - const results = new Map(); - - for (const zip of zipCodes) { - const geocoded = await geocodeZipCode(zip); - if (geocoded) { - results.set(zip, geocoded); - } - // Rate limit - wait between requests - await new Promise(resolve => setTimeout(resolve, delayMs)); - } - - return results; -} - -/** - * Get city name for a zip code (cached) - */ -export async function getCityForZip(zipCode: string): Promise { - const geocoded = await geocodeZipCode(zipCode); - if (geocoded) { - return `${geocoded.city}, ${geocoded.state}`; - } - return 'Unknown Location'; -} - -/** - * Calculate distance between two coordinates (Haversine formula) - * Returns distance in miles - */ -export function calculateDistance( - lat1: number, - lng1: number, - lat2: number, - lng2: number -): number { - const R = 3959; // Earth's radius in miles - const dLat = toRad(lat2 - lat1); - const dLng = toRad(lng2 - lng1); - const a = - Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * - Math.sin(dLng / 2) * Math.sin(dLng / 2); - const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - return R * c; -} - -function toRad(deg: number): number { - return deg * (Math.PI / 180); -} - -/** - * Get bounding box for a radius around a point - */ -export function getBoundingBox( - lat: number, - lng: number, - radiusMiles: number -): { north: number; south: number; east: number; west: number } { - // Approximate degrees per mile at given latitude - const latDegPerMile = 1 / 69.0; - const lngDegPerMile = 1 / (69.0 * Math.cos(toRad(lat))); - - return { - north: lat + (radiusMiles * latDegPerMile), - south: lat - (radiusMiles * latDegPerMile), - east: lng + (radiusMiles * lngDegPerMile), - west: lng - (radiusMiles * lngDegPerMile), - }; + // 3. Zippopotam.us + return geocodeWithZippopotamus(zipCode); } diff --git a/wing-command/lib/menu.ts b/wing-command/lib/menu.ts deleted file mode 100644 index 23e7e1f7f..000000000 --- a/wing-command/lib/menu.ts +++ /dev/null @@ -1,736 +0,0 @@ -// =========================================== -// Wing Scout - Menu Fetching Service -// Wings-only scraping with background fetch -// Resilient parser for non-standard TinyFish responses -// =========================================== - -import axios from 'axios'; -import { Menu, MenuSection, MenuItem, PlatformIds, TinyFishResponse, WingPriceResult } from './types'; -import { executeTinyFishMenuScrape, executeTinyFishScrape } from './tinyfish-scraper'; -import { cacheMenu, cacheChainMenu, clearScoutingLock } from './cache'; -import { createServerClient } from './supabase'; - -/** - * Result from a menu scrape, including contact info extracted from the page. - * phone/address are only available when scraping direct platform URLs (DoorDash, etc.) - */ -export interface MenuScrapeResult { - sections: MenuSection[]; - phone?: string; - address?: string; -} - -/** - * Main menu fetching function with fallback chain - * Priority: 1. Yelp Fusion API 2. TinyFish scraping (45s timeout) - */ -export async function fetchMenu( - spotId: string, - name: string, - address: string, - platformIds?: PlatformIds -): Promise { - // 1. Try Yelp Fusion API (5k/day free tier) - const yelpMenu = await fetchYelpMenu(name, address); - if (yelpMenu) { - return buildMenu(spotId, yelpMenu, 'yelp', platformIds?.source_url); - } - - // 2. Fallback to TinyFish scraping (wings-only, 45s timeout) - const scrapeResult = await scrapeMenuWithTinyFish(name, address, platformIds); - if (scrapeResult) { - return buildMenu(spotId, scrapeResult.sections, 'tinyfish_scrape', platformIds?.source_url); - } - - return null; -} - -/** - * Fetch menu from Yelp Fusion API - * Note: Yelp free API doesn't include menu items directly, - * but provides business info and menu_url for potential scraping - */ -async function fetchYelpMenu( - name: string, - address: string -): Promise { - const apiKey = process.env.YELP_API_KEY; - if (!apiKey) { - console.log('Yelp API key not configured, skipping Yelp menu fetch'); - return null; - } - - try { - // Search for the business - const searchResponse = await axios.get('https://api.yelp.com/v3/businesses/search', { - params: { - term: name, - location: address, - limit: 1, - }, - headers: { - Authorization: `Bearer ${apiKey}`, - }, - timeout: 5000, - }); - - const business = searchResponse.data.businesses?.[0]; - if (!business?.id) { - console.log('Yelp: Business not found'); - return null; - } - - // Note: Yelp Fusion free API doesn't include menu items directly - // The menu_url field can be used for scraping if needed - console.log(`Yelp: Found business ${business.id}, but menu data not available in free API`); - return null; - } catch (error) { - console.error('Yelp menu fetch error:', error); - return null; - } -} - -// =========================================== -// Wings-Only TinyFish Goal Prompts (strict JSON) -// =========================================== - -function getWingsOnlyGoal(hasDirectUrl: boolean): string { - if (hasDirectUrl) { - return `Navigate to this restaurant page. Find the menu and extract chicken wing items with prices. - -IMPORTANT: Return ONLY a JSON object in this EXACT format: -{"sections": [{"name": "Wings", "items": [{"name": "10pc Wings", "price": 12.99, "quantity": 10}]}], "phone": "+11234567890", "address": "123 Main St"} - -Look for these items (in priority order): -1. Wings, buffalo wings, boneless wings, bone-in wings, hot wings, wing combo, wing bucket, wing platter -2. Tenders, chicken tenders, chicken strips, chicken fingers, nuggets, drumettes -3. ANY chicken item with a price (chicken sandwich, fried chicken, chicken basket, etc.) -4. If still nothing, get the cheapest appetizer or starter with a price - -Each item needs: name (string), price (number without $), quantity (number if mentioned like "10 pc"). -Group into sections by type (e.g. "Wings", "Tenders", "Chicken", "Appetizers"). - -For phone: Look for a phone number on the page. Include country code. -For address: Look for the restaurant's street address. -If phone/address not visible, omit those fields. - -If the menu is not visible at all, return: {"sections": [], "phone": "", "address": ""} -Return ONLY the JSON object. No notes, no descriptions.`; - } - - return `Find this restaurant on Google Maps. Click on it, look for a Menu tab/section or Overview with prices. - -Return ONLY a JSON object: -{"sections": [{"name": "Wings", "items": [{"name": "Buffalo Wings", "price": 12.99, "quantity": 10}]}]} - -Look for (priority order): -1. Wing items: wings, buffalo, boneless, bone-in, hot wings, wing platter -2. Chicken items: tenders, strips, nuggets, drumettes, fried chicken -3. ANY food item with a visible price - -Each item: name (string), price (number without $), quantity (number if listed). -If no menu/prices found, return: {"sections": []} -Return ONLY the JSON. Be fast.`; -} - -// =========================================== -// Resilient TinyFish Response Parser -// =========================================== - -/** - * Wing-related keywords for item detection - */ -const WING_ITEM_KEYWORDS = [ - 'wing', 'wings', 'buffalo', 'boneless', 'bone-in', 'bone in', - 'drumette', 'drumettes', 'tender', 'tenders', 'nugget', 'nuggets', - 'mcnugget', 'mcnuggets', -]; - -function isWingRelatedText(text: string): boolean { - const lower = text.toLowerCase(); - return WING_ITEM_KEYWORDS.some(kw => lower.includes(kw)); -} - -/** - * Try to extract menu sections from non-standard TinyFish response formats. - * Handles: flat items array, nested menu objects, descriptive summaries, etc. - */ -function extractFromAlternativeFormat(data: unknown): MenuSection[] | null { - if (!data || typeof data !== 'object') return null; - const obj = data as Record; - - // Format B: { items: [{ name, price }] } — flat items array - if (Array.isArray(obj.items) && obj.items.length > 0) { - console.log('TinyFish wing scrape: alternative format — flat items array'); - return [{ name: 'Wings', items: parseItemsArray(obj.items) }]; - } - - // Format B2: { menu_items: [...] } or { menu: [...] } - const menuItems = obj.menu_items || obj.menu; - if (Array.isArray(menuItems) && menuItems.length > 0) { - console.log('TinyFish wing scrape: alternative format — menu_items/menu array'); - return [{ name: 'Wings', items: parseItemsArray(menuItems) }]; - } - - // Format B3: { menu: { items: [...] } } or { menu: { sections: [...] } } - if (obj.menu && typeof obj.menu === 'object' && !Array.isArray(obj.menu)) { - const menuObj = obj.menu as Record; - if (Array.isArray(menuObj.sections) && menuObj.sections.length > 0) { - console.log('TinyFish wing scrape: alternative format — nested menu.sections'); - return parseSectionsArray(menuObj.sections); - } - if (Array.isArray(menuObj.items) && menuObj.items.length > 0) { - console.log('TinyFish wing scrape: alternative format — nested menu.items'); - return [{ name: 'Wings', items: parseItemsArray(menuObj.items) }]; - } - } - - // Format C: Descriptive summary like { restrictions: ["Chicken McNuggets", ...], ... } - // Try to extract wing-related items from any string arrays in the object - const wingItems: MenuItem[] = []; - for (const [key, value] of Object.entries(obj)) { - if (Array.isArray(value)) { - for (const entry of value) { - if (typeof entry === 'string' && isWingRelatedText(entry)) { - wingItems.push({ - name: entry, - price: null, - is_deal: detectDeal(entry, ''), - }); - } else if (typeof entry === 'object' && entry !== null) { - const entryObj = entry as Record; - if (entryObj.name && typeof entryObj.name === 'string' && isWingRelatedText(String(entryObj.name))) { - wingItems.push({ - name: String(entryObj.name), - description: entryObj.description ? String(entryObj.description) : undefined, - price: entryObj.price ? parseFloat(String(entryObj.price)) : null, - quantity: entryObj.quantity ? parseInt(String(entryObj.quantity)) : undefined, - is_deal: detectDeal(String(entryObj.name), String(entryObj.description || '')), - }); - } - } - } - } - // Also check string values that mention wing items - if (typeof value === 'string' && isWingRelatedText(value) && key !== 'status' && key !== 'note') { - // Could be "extracted_items": "Chicken McNuggets 10pc - $8.99" - const priceMatch = value.match(/\$?([\d.]+)/); - const qtyMatch = value.match(/(\d+)\s*(pc|piece|ct|count)/i); - wingItems.push({ - name: value.split(/[-–—,]/).map(s => s.trim()).filter(s => isWingRelatedText(s))[0] || value, - price: priceMatch ? parseFloat(priceMatch[1]) : null, - quantity: qtyMatch ? parseInt(qtyMatch[1]) : undefined, - is_deal: detectDeal(value, ''), - }); - } - } - - if (wingItems.length > 0) { - console.log(`TinyFish wing scrape: alternative format — extracted ${wingItems.length} wing items from descriptive response`); - return [{ name: 'Wings', items: wingItems }]; - } - - // Format D: Check for any array property with objects that have a "name" field - for (const value of Object.values(obj)) { - if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'object' && value[0] !== null) { - const firstItem = value[0] as Record; - if ('name' in firstItem) { - console.log('TinyFish wing scrape: alternative format — generic named items array'); - const items = parseItemsArray(value); - if (items.length > 0) return [{ name: 'Wings', items }]; - } - } - } - - return null; -} - -/** - * Parse an array of unknown items into MenuItem[] - */ -function parseItemsArray(items: unknown[]): MenuItem[] { - return items.map((item: unknown) => { - if (typeof item === 'string') { - return { - name: item, - price: null, - is_deal: detectDeal(item, ''), - }; - } - const itemObj = item as Record; - return { - name: String(itemObj.name || 'Unknown Item'), - description: itemObj.description ? String(itemObj.description) : undefined, - price: itemObj.price ? parseFloat(String(itemObj.price)) : null, - quantity: itemObj.quantity ? parseInt(String(itemObj.quantity)) : undefined, - price_per_wing: calculatePricePerWing(itemObj.price, itemObj.quantity, String(itemObj.name || '')), - is_deal: detectDeal(String(itemObj.name || ''), String(itemObj.description || '')), - }; - }); -} - -/** - * Parse a sections array from alternative format - */ -function parseSectionsArray(sections: unknown[]): MenuSection[] { - return sections.map((section: unknown) => { - const sectionObj = section as Record; - return { - name: String(sectionObj.name || 'Wings'), - items: Array.isArray(sectionObj.items) ? parseItemsArray(sectionObj.items) : [], - }; - }); -} - -/** - * Try to extract items from a free-form text string - */ -function extractItemsFromText(text: string): MenuItem[] { - const items: MenuItem[] = []; - // Split by common delimiters - const lines = text.split(/[,;\n]+/).map(s => s.trim()).filter(Boolean); - - for (const line of lines) { - if (isWingRelatedText(line)) { - const priceMatch = line.match(/\$?([\d]+\.[\d]{2})/); - const qtyMatch = line.match(/(\d+)\s*(pc|piece|ct|count|wings?)/i); - items.push({ - name: line.replace(/\$[\d.]+/g, '').trim() || line, - price: priceMatch ? parseFloat(priceMatch[1]) : null, - quantity: qtyMatch ? parseInt(qtyMatch[1]) : undefined, - is_deal: detectDeal(line, ''), - }); - } - } - return items; -} - -// =========================================== -// Main TinyFish Scraper -// =========================================== - -/** - * Extract phone and address from a parsed TinyFish response object. - * Returns cleaned values or undefined if not found. - */ -function extractContactInfo(data: unknown): { phone?: string; address?: string } { - if (!data || typeof data !== 'object') return {}; - const obj = data as Record; - - let phone: string | undefined; - let address: string | undefined; - - // Extract phone - if (obj.phone && typeof obj.phone === 'string') { - const rawPhone = obj.phone.trim(); - // Must look like a phone number (at least 7 digits) - const digits = rawPhone.replace(/\D/g, ''); - if (digits.length >= 7) { - phone = rawPhone; - } - } - - // Extract address - if (obj.address && typeof obj.address === 'string') { - const rawAddr = obj.address.trim(); - // Must be a real address (not empty, not a placeholder) - if (rawAddr.length > 5 && rawAddr.toLowerCase() !== 'n/a' && rawAddr !== '') { - address = rawAddr; - } - } - - return { phone, address }; -} - -/** - * Single scrape attempt: call TinyFish, parse the response using all available formats. - * Returns MenuScrapeResult (sections may be empty []) or null on API failure. - * Also extracts phone and address when available in the response. - */ -async function attemptScrape( - scrape: (url: string, goal: string) => Promise, - url: string, - goal: string -): Promise { - console.log(`TinyFish wing scrape: ${url}`); - const result = await scrape(url, goal); - - if (!result.success || !result.data) { - console.log('TinyFish wing scrape: No results'); - return null; - } - - // TinyFish can return result as a JSON string or a parsed object — handle both - let parsed: unknown = result.data; - console.log(`TinyFish wing scrape: result.data type = ${typeof parsed}`); - - if (typeof parsed === 'string') { - const trimmed = (parsed as string).trim(); - try { - parsed = JSON.parse(trimmed); - console.log('TinyFish wing scrape: Parsed string result to object'); - } catch { - // Not valid JSON — try to extract items from the text - console.log('TinyFish wing scrape: String is not JSON, trying text extraction'); - const textItems = extractItemsFromText(trimmed); - if (textItems.length > 0) { - console.log(`TinyFish wing scrape: Extracted ${textItems.length} items from text`); - return { sections: [{ name: 'Wings', items: textItems }] }; - } - console.log('TinyFish wing scrape: No extractable items from text response'); - return { sections: [] }; // Empty = "no wings found" (not null = "failed") - } - } - - // Extract contact info from the parsed response (phone, address) - const contact = extractContactInfo(parsed); - if (contact.phone) console.log(`TinyFish wing scrape: Found phone: ${contact.phone}`); - if (contact.address) console.log(`TinyFish wing scrape: Found address: ${contact.address.substring(0, 50)}`); - - // Standard format: { sections: [...] } - const data = parsed as { sections?: Array<{ name: string; items: unknown[] }> }; - if (data.sections && Array.isArray(data.sections)) { - if (data.sections.length === 0) { - console.log('TinyFish wing scrape: Returned empty sections array (no wings at this restaurant)'); - return { sections: [], ...contact }; // TinyFish explicitly said no wings - } - - // Parse and structure the menu sections - const sections: MenuSection[] = data.sections.map(section => ({ - name: String(section.name || 'Wings'), - items: (section.items || []).map((item: unknown) => { - const itemObj = item as Record; - return { - name: String(itemObj.name || 'Unknown Item'), - description: itemObj.description ? String(itemObj.description) : undefined, - price: itemObj.price ? parseFloat(String(itemObj.price)) : null, - quantity: itemObj.quantity ? parseInt(String(itemObj.quantity)) : undefined, - price_per_wing: calculatePricePerWing(itemObj.price, itemObj.quantity, String(itemObj.name || '')), - is_deal: detectDeal(String(itemObj.name || ''), String(itemObj.description || '')), - }; - }), - })); - - console.log(`TinyFish wing scrape: Found ${sections.length} sections (standard format)`); - return { sections, ...contact }; - } - - // Non-standard format — try alternative extraction - console.log('TinyFish wing scrape: No sections found, trying alternative formats...', - JSON.stringify(data).substring(0, 300)); - const altSections = extractFromAlternativeFormat(parsed); - if (altSections && altSections.length > 0) { - return { sections: altSections, ...contact }; - } - - // TinyFish returned data but nothing we can parse into wing items - console.log('TinyFish wing scrape: Could not extract wing items from response'); - return { sections: [], ...contact }; // Empty = "no wings found at this restaurant" -} - -/** - * Scrape wing items from restaurant using TinyFish. - * Accepts optional scrape function for timeout flexibility: - * - Default: executeTinyFishMenuScrape (45s timeout) for fast path - * - Background: executeTinyFishScrape (120s timeout) for background scrape - * - * Fallback chain: - * 1. Try platform URL (Grubhub/DoorDash/UberEats) if available - * 2. If platform URL returns empty → try Google search for "[name] menu wings" - * 3. If already on Google (no platform URL) → single attempt only (no loop) - * - * Returns MenuScrapeResult (with sections, phone, address) or null on total failure. - * sections may be empty [] meaning "no wings found" vs null meaning "API failure". - */ -export async function scrapeMenuWithTinyFish( - name: string, - address: string, - platformIds?: PlatformIds, - scrapeFn?: (url: string, goal: string) => Promise -): Promise { - const scrape = scrapeFn || executeTinyFishMenuScrape; - - // Determine best URL to scrape: platform URL > website URL > Google Maps fallback - const hasDirectUrl = !!platformIds?.source_url; - const hasWebsiteUrl = !!platformIds?.website_url; - const scrapeUrl = hasDirectUrl - ? platformIds!.source_url! - : hasWebsiteUrl - ? platformIds!.website_url! - : `https://www.google.com/maps/search/${encodeURIComponent(name + ' ' + address)}`; - const goal = getWingsOnlyGoal(hasDirectUrl || hasWebsiteUrl); - - try { - // First attempt: platform URL or Google Maps - const result = await attemptScrape(scrape, scrapeUrl, goal); - - // If platform URL returned empty sections, try Google search as fallback - // Only when we used a direct URL (don't loop if already on Google) - // But preserve contact info from the first attempt - if (result !== null && result.sections.length === 0 && (hasDirectUrl || hasWebsiteUrl)) { - console.log(`TinyFish wing scrape: platform URL returned empty, trying Google search for "${name}"...`); - const googleUrl = `https://www.google.com/search?q=${encodeURIComponent(name + ' menu wings')}`; - const googleGoal = getWingsOnlyGoal(false); - const googleResult = await attemptScrape(scrape, googleUrl, googleGoal); - if (googleResult && googleResult.sections.length > 0) { - console.log(`TinyFish wing scrape: Google fallback found ${googleResult.sections.length} sections!`); - // Merge: use Google's sections but keep contact info from platform page - return { - sections: googleResult.sections, - phone: result.phone || googleResult.phone, - address: result.address || googleResult.address, - }; - } - console.log('TinyFish wing scrape: Google fallback also empty — genuinely no wings'); - } - - return result; - } catch (error) { - console.error('TinyFish wing scrape error:', error); - return null; // null = actual failure, can retry - } -} - -/** - * Build a complete Menu object from sections - */ -function buildMenu( - spotId: string, - sections: MenuSection[], - source: 'yelp' | 'tinyfish_scrape', - sourceUrl?: string -): Menu { - return { - spot_id: spotId, - sections, - fetched_at: new Date().toISOString(), - source, - has_wings: detectWingItems(sections), - wing_section_index: findWingSectionIndex(sections), - source_url: sourceUrl, - }; -} - -/** - * Detect if menu sections contain wing items - */ -function detectWingItems(sections: MenuSection[]): boolean { - const wingKeywords = ['wing', 'wings', 'buffalo', 'boneless', 'drumette']; - - for (const section of sections) { - for (const item of section.items) { - const itemText = (item.name + ' ' + (item.description || '')).toLowerCase(); - if (wingKeywords.some(kw => itemText.includes(kw))) { - return true; - } - } - } - return false; -} - -/** - * Find the index of the section most likely to contain wings - */ -function findWingSectionIndex(sections: MenuSection[]): number | undefined { - const wingKeywords = ['wing', 'wings', 'buffalo']; - - // First, look for a section named "Wings" or similar - for (let i = 0; i < sections.length; i++) { - if (wingKeywords.some(kw => sections[i].name.toLowerCase().includes(kw))) { - return i; - } - } - - // Otherwise, find section with most wing items - let maxWingItems = 0; - let bestIndex: number | undefined; - - for (let i = 0; i < sections.length; i++) { - const wingCount = sections[i].items.filter(item => - wingKeywords.some(kw => item.name.toLowerCase().includes(kw)) - ).length; - - if (wingCount > maxWingItems) { - maxWingItems = wingCount; - bestIndex = i; - } - } - - return bestIndex; -} - -/** - * Calculate price per wing from item data - */ -function calculatePricePerWing( - price: unknown, - quantity: unknown, - name: string -): number | undefined { - const priceNum = typeof price === 'string' ? parseFloat(price) : (typeof price === 'number' ? price : undefined); - let quantityNum = typeof quantity === 'string' ? parseInt(quantity) : (typeof quantity === 'number' ? quantity : undefined); - - // Try to extract quantity from name if not provided - if (!quantityNum) { - const match = name.match(/(\d+)\s*(pc|piece|wing|ct|count)/i); - if (match) { - quantityNum = parseInt(match[1]); - } - } - - if (priceNum && quantityNum && quantityNum > 0) { - return Math.round((priceNum / quantityNum) * 100) / 100; - } - - return undefined; -} - -/** - * Detect if item appears to be a deal - */ -function detectDeal(name: string, description?: string): boolean { - const dealKeywords = ['deal', 'special', 'combo', 'bundle', 'meal', 'discount', 'off', 'save', 'value']; - const text = (name + ' ' + (description || '')).toLowerCase(); - return dealKeywords.some(kw => text.includes(kw)); -} - -/** - * Get the cheapest price per wing AND cheapest raw item price from menu sections. - * Returns both so we can display per-wing price when available, or raw item price as fallback. - */ -export function getCheapestWingPrice(sections: MenuSection[]): WingPriceResult { - const WING_KEYWORDS = ['wing', 'wings', 'buffalo', 'boneless', 'drumette', 'tender', 'nugget', 'chicken']; - let cheapestPerWing: number | null = null; - let cheapestItem: number | null = null; - - for (const section of sections) { - for (const item of section.items) { - // Track cheapest per-wing price (pre-calculated) - if (item.price_per_wing && item.price_per_wing > 0) { - if (cheapestPerWing === null || item.price_per_wing < cheapestPerWing) { - cheapestPerWing = item.price_per_wing; - } - } - - // Track any item with a price - if (item.price && item.price > 0 && item.price < 100) { - // For wing-related items, try quantity extraction for per-wing calc - const text = (item.name + ' ' + (item.description || '')).toLowerCase(); - if (WING_KEYWORDS.some(kw => text.includes(kw))) { - const match = item.name.match(/(\d+)\s*(pc|piece|wing|ct|count|pk)/i); - if (match) { - const qty = parseInt(match[1]); - if (qty > 0) { - const ppw = Math.round((item.price / qty) * 100) / 100; - if (ppw > 0 && ppw < 10) { - if (cheapestPerWing === null || ppw < cheapestPerWing) { - cheapestPerWing = ppw; - } - } - } - } - } - - // Always track raw item price as fallback - if (cheapestItem === null || item.price < cheapestItem) { - cheapestItem = item.price; - } - } - } - } - - return { price_per_wing: cheapestPerWing, cheapest_item_price: cheapestItem }; -} - -// =========================================== -// Background Menu Scraping -// =========================================== - -/** - * Fire-and-forget background wing scrape. - * Uses the full 120s TinyFish timeout via executeTinyFishScrape. - * On success, caches the result in Redis + chain cache + Supabase. - * Redis scouting lock prevents duplicates across serverless instances. - * - * Now also caches empty results (no wings) to prevent re-scraping - * restaurants that genuinely don't have wing items. - */ -export function startBackgroundMenuScrape( - spotId: string, - name: string, - address: string, - platformIds?: PlatformIds -): void { - console.log(`Starting background wing scrape for ${spotId}: ${name}`); - - // Fire-and-forget — reuses scrapeMenuWithTinyFish with full 120s timeout - (async () => { - try { - const scrapeResult = await scrapeMenuWithTinyFish(name, address, platformIds, executeTinyFishScrape); - - // null = total failure (API error, timeout, etc.) — don't cache, allow retry - if (scrapeResult === null) { - console.log(`Background scrape: failed for ${spotId} (will allow retry)`); - return; - } - - const { sections, phone: scrapedPhone, address: scrapedAddress } = scrapeResult; - - // Empty array = TinyFish found no wing items at this restaurant — cache to prevent re-scraping - const menu = buildMenu(spotId, sections, 'tinyfish_scrape', platformIds?.source_url); - - // Cache in Redis (per-spot + chain) - await cacheMenu(spotId, menu); - await cacheChainMenu(name, menu); - - // Persist to Supabase - try { - const supabase = createServerClient(); - await supabase - .from('menus') - .upsert({ - spot_id: spotId, - sections: menu.sections, - source: menu.source, - has_wings: menu.has_wings, - wing_section_index: menu.wing_section_index, - fetched_at: menu.fetched_at, - }, { onConflict: 'spot_id' }); - - // Build update payload for wing_spots: prices + phone + address - const priceResult = getCheapestWingPrice(sections); - const updatePayload: Record = {}; - - if (priceResult.price_per_wing !== null) { - updatePayload.price_per_wing = priceResult.price_per_wing; - } - // Note: cheapest_item_price is computed on-the-fly from menu cache, - // not persisted to Supabase (column may not exist yet) - if (scrapedPhone) { - updatePayload.phone = scrapedPhone; - } - if (scrapedAddress) { - updatePayload.address = scrapedAddress; - } - - if (Object.keys(updatePayload).length > 0) { - await supabase - .from('wing_spots') - .update(updatePayload) - .eq('id', spotId); - const fields = Object.keys(updatePayload).join(', '); - console.log(`Background scrape: Updated ${fields} for ${spotId}${priceResult.price_per_wing !== null ? ` (ppw=$${priceResult.price_per_wing.toFixed(2)})` : ''}${scrapedPhone ? ` (phone=${scrapedPhone})` : ''}`); - } - } catch (dbErr) { - console.error('Background scrape: Supabase persist error:', dbErr); - } - - console.log(`Background scrape SUCCESS for ${spotId}: ${sections.length} sections cached (has_wings: ${menu.has_wings})`); - } catch (err) { - console.error(`Background scrape error for ${spotId}:`, err); - } finally { - // ALWAYS clear the Redis scouting lock, even on failure - await clearScoutingLock(spotId); - } - })(); -} diff --git a/wing-command/lib/seed-data.ts b/wing-command/lib/seed-data.ts deleted file mode 100644 index 064e77343..000000000 --- a/wing-command/lib/seed-data.ts +++ /dev/null @@ -1,288 +0,0 @@ -// =========================================== -// Wing Scout — Seed Data Generator -// Realistic wing spot data for demo / fallback -// =========================================== - -import { WingSpot, FlavorPersona } from './types'; -import { calculateStatus, getFlavorPersona, scoreSpotFlavor } from './utils'; - -interface SeedRestaurant { - name: string; - type: 'chain' | 'local'; - source: 'doordash' | 'ubereats' | 'grubhub' | 'google'; - price_per_wing: number | null; - deal_text: string | null; - delivery_time_mins: number | null; - phone: string | null; - image_url: string | null; - flavor_tags: string[]; - is_open_now: boolean; - is_in_stock: boolean; -} - -// Pool of realistic restaurants — these get picked randomly per zip -const RESTAURANT_POOL: SeedRestaurant[] = [ - { - name: 'Buffalo Wild Wings', - type: 'chain', - source: 'doordash', - price_per_wing: 1.49, - deal_text: 'BOGO Wings on Game Day', - delivery_time_mins: 25, - phone: '(555) 100-2000', - image_url: 'https://images.unsplash.com/photo-1567620832903-9fc6debc209f?w=400&h=300&fit=crop', - flavor_tags: ['buffalo', 'hot', 'mild', 'blazin', 'honey bbq', 'garlic parmesan', 'mango habanero'], - is_open_now: true, - is_in_stock: true, - }, - { - name: 'Wingstop', - type: 'chain', - source: 'ubereats', - price_per_wing: 1.29, - deal_text: '70¢ Boneless Wings Thursday', - delivery_time_mins: 20, - phone: '(555) 200-3000', - image_url: 'https://images.unsplash.com/photo-1608039755401-742074f0548d?w=400&h=300&fit=crop', - flavor_tags: ['atomic', 'mango habanero', 'cajun', 'lemon pepper', 'garlic parmesan', 'hickory smoked bbq'], - is_open_now: true, - is_in_stock: true, - }, - { - name: "Tony's Sports Bar & Grill", - type: 'local', - source: 'google', - price_per_wing: 0.99, - deal_text: 'Game Day Special: 50 Wings for $39.99', - delivery_time_mins: null, - phone: '(555) 300-4000', - image_url: 'https://images.unsplash.com/photo-1569058242253-92a9c755a0ec?w=400&h=300&fit=crop', - flavor_tags: ['buffalo', 'hot', 'garlic', 'bbq', 'teriyaki'], - is_open_now: true, - is_in_stock: true, - }, - { - name: 'Atomic Wings', - type: 'local', - source: 'grubhub', - price_per_wing: 1.59, - deal_text: null, - delivery_time_mins: 35, - phone: '(555) 400-5000', - image_url: 'https://images.unsplash.com/photo-1527477396000-e27163b481c2?w=400&h=300&fit=crop', - flavor_tags: ['atomic', 'nuclear', 'ghost pepper', 'carolina reaper', 'inferno', 'habanero'], - is_open_now: true, - is_in_stock: true, - }, - { - name: "Hooters", - type: 'chain', - source: 'doordash', - price_per_wing: 1.69, - deal_text: 'All You Can Eat Wings $19.99', - delivery_time_mins: 30, - phone: '(555) 500-6000', - image_url: 'https://images.unsplash.com/photo-1614398751058-56b6c5e1c85b?w=400&h=300&fit=crop', - flavor_tags: ['buffalo', 'hot', 'mild', 'daytona beach', 'samurai', 'caribbean jerk'], - is_open_now: true, - is_in_stock: true, - }, - { - name: "Firehouse Wings", - type: 'local', - source: 'google', - price_per_wing: 1.15, - deal_text: '$1 Wings Happy Hour 4-7pm', - delivery_time_mins: null, - phone: '(555) 600-7000', - image_url: 'https://images.unsplash.com/photo-1585325701956-60dd9c8553bc?w=400&h=300&fit=crop', - flavor_tags: ['fire', 'extra hot', 'scorpion', 'honey', 'garlic butter', 'classic buffalo'], - is_open_now: true, - is_in_stock: true, - }, - { - name: 'Dominos Pizza', - type: 'chain', - source: 'ubereats', - price_per_wing: 1.39, - deal_text: '8pc Wings + Large Pizza $19.99', - delivery_time_mins: 22, - phone: '(555) 700-8000', - image_url: 'https://images.unsplash.com/photo-1626645738196-c2a7c87a8f58?w=400&h=300&fit=crop', - flavor_tags: ['plain', 'hot buffalo', 'sweet mango habanero', 'bbq', 'garlic parmesan'], - is_open_now: true, - is_in_stock: true, - }, - { - name: "Big Mike's Wing Shack", - type: 'local', - source: 'google', - price_per_wing: 0.89, - deal_text: 'Best Wings in Town — $8.99/dozen', - delivery_time_mins: null, - phone: '(555) 800-9000', - image_url: 'https://images.unsplash.com/photo-1599487488170-d11ec9c172f0?w=400&h=300&fit=crop', - flavor_tags: ['honey bbq', 'garlic parm', 'lemon pepper', 'sticky', 'glazed', 'korean'], - is_open_now: true, - is_in_stock: true, - }, - { - name: "Raising Cane's", - type: 'chain', - source: 'doordash', - price_per_wing: null, - deal_text: null, - delivery_time_mins: 18, - phone: '(555) 900-1000', - image_url: 'https://images.unsplash.com/photo-1562967914-608f82629710?w=400&h=300&fit=crop', - flavor_tags: ['classic', 'original'], - is_open_now: true, - is_in_stock: true, - }, - { - name: "Wing Zone", - type: 'chain', - source: 'grubhub', - price_per_wing: 1.35, - deal_text: 'Free delivery over $20', - delivery_time_mins: 28, - phone: '(555) 110-2200', - image_url: 'https://images.unsplash.com/photo-1632778149955-e80f8ceca2e8?w=400&h=300&fit=crop', - flavor_tags: ['nuclear', 'inferno', 'blazin', 'honey mustard', 'teriyaki', 'thai chili', 'sesame'], - is_open_now: true, - is_in_stock: true, - }, - { - name: "The Angry Chicken", - type: 'local', - source: 'google', - price_per_wing: 1.10, - deal_text: '20% off for Super Bowl Sunday', - delivery_time_mins: null, - phone: '(555) 330-4400', - image_url: 'https://images.unsplash.com/photo-1608039829572-78524f79c4c7?w=400&h=300&fit=crop', - flavor_tags: ['ghost pepper', 'carolina reaper', 'habanero', 'extra hot', 'fire'], - is_open_now: true, - is_in_stock: true, - }, - { - name: "Papa's Pizza & Wings", - type: 'local', - source: 'ubereats', - price_per_wing: 1.45, - deal_text: null, - delivery_time_mins: 40, - phone: '(555) 550-6600', - image_url: 'https://images.unsplash.com/photo-1565299624946-b28f40a0ae38?w=400&h=300&fit=crop', - flavor_tags: ['buffalo', 'plain', 'bbq', 'garlic', 'mild'], - is_open_now: false, - is_in_stock: true, - }, - { - name: "Golden Dragon Chinese", - type: 'local', - source: 'grubhub', - price_per_wing: 0.95, - deal_text: 'Lunch combo: 10 wings + fried rice $10.99', - delivery_time_mins: 32, - phone: '(555) 770-8800', - image_url: 'https://images.unsplash.com/photo-1525755662778-989d0524087e?w=400&h=300&fit=crop', - flavor_tags: ['sweet chili', 'sesame', 'teriyaki', 'honey garlic', 'korean', 'asian', 'sticky'], - is_open_now: true, - is_in_stock: true, - }, - { - name: "Popeyes Louisiana Kitchen", - type: 'chain', - source: 'doordash', - price_per_wing: 1.25, - deal_text: '5pc Wings $4.99 Tuesday', - delivery_time_mins: 15, - phone: '(555) 990-1100', - image_url: 'https://images.unsplash.com/photo-1626082927389-6cd097cdc6ec?w=400&h=300&fit=crop', - flavor_tags: ['cajun', 'spicy', 'mild', 'classic', 'original'], - is_open_now: true, - is_in_stock: true, - }, -]; - -// Realistic street name parts -const STREETS = [ - 'Main St', 'Broadway', 'Oak Ave', 'Elm St', 'Park Blvd', 'Market St', - 'Washington Ave', 'Lincoln Hwy', 'Maple Dr', 'Cedar Ln', 'Pine St', - 'Jackson Blvd', 'Lake Shore Dr', 'Highland Ave', 'Roosevelt Rd', -]; - -/** - * Generate seed wing spots for a given zip code - * Picks 8-12 random restaurants, assigns them addresses near the geocoded location - */ -export function generateSeedData( - zipCode: string, - lat: number, - lng: number, - cityName: string, - stateName: string, - flavor?: FlavorPersona -): WingSpot[] { - // Deterministic shuffle based on zip for consistency - const seed = parseInt(zipCode, 10); - const shuffled = [...RESTAURANT_POOL].sort((a, b) => { - const hashA = ((seed * 31 + a.name.charCodeAt(0)) % 1000) / 1000; - const hashB = ((seed * 31 + b.name.charCodeAt(0)) % 1000) / 1000; - return hashA - hashB; - }); - - // Pick 8-12 restaurants - const count = 8 + (seed % 5); // 8-12 - const selected = shuffled.slice(0, Math.min(count, shuffled.length)); - - const spots: WingSpot[] = selected.map((r, idx) => { - const streetNum = 100 + ((seed * (idx + 1)) % 9900); - const street = STREETS[(seed + idx) % STREETS.length]; - const address = `${streetNum} ${street}, ${cityName}, ${stateName}`; - - // Jitter lat/lng slightly for each spot - const jitterLat = (((seed * (idx + 3)) % 100) - 50) * 0.0004; - const jitterLng = (((seed * (idx + 7)) % 100) - 50) * 0.0004; - - const spot: WingSpot = { - id: `seed-${zipCode}-${idx}-${r.source}`, - name: r.name, - address, - lat: lat + jitterLat, - lng: lng + jitterLng, - price_per_wing: r.price_per_wing, - cheapest_item_price: null, - deal_text: r.deal_text, - delivery_time_mins: r.delivery_time_mins, - wait_time_mins: null, - is_in_stock: r.is_in_stock, - is_open_now: r.is_open_now, - opens_during_game: true, - hours_today: r.is_open_now ? '11:00 AM - 11:00 PM' : 'Closed - Opens 11 AM', - phone: r.phone, - image_url: r.image_url, - source: r.source, - status: 'yellow', // will be recalculated - zip_code: zipCode, - last_updated: new Date().toISOString(), - flavor_tags: r.flavor_tags, - }; - - spot.status = calculateStatus(spot); - - return spot; - }); - - // Apply flavor scoring if provided - if (flavor) { - const persona = getFlavorPersona(flavor); - return spots.map(spot => ({ - ...spot, - flavor_match: scoreSpotFlavor(spot, persona), - })); - } - - return spots; -} diff --git a/wing-command/lib/supabase.ts b/wing-command/lib/supabase.ts deleted file mode 100644 index 49da48987..000000000 --- a/wing-command/lib/supabase.ts +++ /dev/null @@ -1,198 +0,0 @@ -// =========================================== -// Wing Scout - Supabase Client -// =========================================== - -import { createClient, SupabaseClient } from '@supabase/supabase-js'; -import { WingSpot, GeocodedLocation } from './types'; - -// Environment variables -const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ''; -const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ''; -const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - -type SupabaseClientAny = SupabaseClient; - -/** - * Create browser-side Supabase client - */ -export function createBrowserClient(): SupabaseClientAny { - return createClient(supabaseUrl, supabaseAnonKey, { - auth: { persistSession: false }, - }); -} - -/** - * Create server-side Supabase client - */ -export function createServerClient(): SupabaseClientAny { - if (!supabaseServiceKey) { - throw new Error('SUPABASE_SERVICE_ROLE_KEY is not set'); - } - return createClient(supabaseUrl, supabaseServiceKey, { - auth: { persistSession: false, autoRefreshToken: false }, - }); -} - -/** - * Singleton browser client - */ -let browserClient: SupabaseClientAny | null = null; - -export function getSupabaseBrowserClient(): SupabaseClientAny { - if (!browserClient) { - browserClient = createBrowserClient(); - } - return browserClient; -} - -/** - * Get wing spots by zip code - */ -export async function getWingSpotsByZip( - client: SupabaseClientAny, - zipCode: string -): Promise<{ data: WingSpot[] | null; error: Error | null }> { - const { data, error } = await client - .from('wing_spots') - .select('*') - .eq('zip_code', zipCode) - .order('status', { ascending: true }) - .order('price_per_wing', { ascending: true, nullsFirst: false }); - - return { data, error: error as Error | null }; -} - -/** - * Delete all wing spots for a zip code (for purging stale/incorrect data) - */ -export async function deleteWingSpotsByZip( - client: SupabaseClientAny, - zipCode: string -): Promise<{ error: Error | null }> { - const { error } = await client - .from('wing_spots') - .delete() - .eq('zip_code', zipCode); - - if (error) { - console.error(`Failed to delete wing spots for zip ${zipCode}:`, error); - } else { - console.log(`Deleted wing spots for zip: ${zipCode}`); - } - - return { error: error as Error | null }; -} - -/** - * Get wing spots near a location (bounding box query) - */ -export async function getWingSpotsNearLocation( - client: SupabaseClientAny, - lat: number, - lng: number, - radiusMiles: number = 10 -): Promise<{ data: WingSpot[] | null; error: Error | null }> { - const latDegPerMile = 1 / 69.0; - const lngDegPerMile = 1 / (69.0 * Math.cos((lat * Math.PI) / 180)); - - const latRange = radiusMiles * latDegPerMile; - const lngRange = radiusMiles * lngDegPerMile; - - const { data, error } = await client - .from('wing_spots') - .select('*') - .gte('lat', lat - latRange) - .lte('lat', lat + latRange) - .gte('lng', lng - lngRange) - .lte('lng', lng + lngRange) - .order('status', { ascending: true }); - - return { data, error: error as Error | null }; -} - -/** - * Upsert wing spots - */ -export async function upsertWingSpots( - client: SupabaseClientAny, - spots: Omit[] -): Promise<{ error: Error | null }> { - // Strip in-memory-only fields that don't have Supabase columns - const sanitized = spots.map(({ cheapest_item_price: _cip, estimated_price_per_wing: _epw, is_price_estimated: _ipe, ...rest }) => rest); - const { error } = await client - .from('wing_spots') - .upsert(sanitized, { onConflict: 'id', ignoreDuplicates: false }); - - return { error: error as Error | null }; -} - -/** - * Get cached geocode data - */ -export async function getCachedGeocode( - client: SupabaseClientAny, - zipCode: string -): Promise<{ data: GeocodedLocation | null; error: Error | null }> { - const { data, error } = await client - .from('geocode_cache') - .select('*') - .eq('zip_code', zipCode) - .single(); - - return { data, error: error as Error | null }; -} - -/** - * Cache geocode data - */ -export async function cacheGeocode( - client: SupabaseClientAny, - geocode: GeocodedLocation -): Promise<{ error: Error | null }> { - const { error } = await client - .from('geocode_cache') - .upsert(geocode, { onConflict: 'zip_code' }); - - return { error: error as Error | null }; -} - -/** - * Add to scrape queue - */ -export async function addToScrapeQueue( - client: SupabaseClientAny, - zipCode: string -): Promise<{ data: { id: string } | null; error: Error | null }> { - const { data, error } = await client - .from('scrape_queue') - .insert({ zip_code: zipCode, status: 'pending', created_at: new Date().toISOString() }) - .select('id') - .single(); - - return { data, error: error as Error | null }; -} - -/** - * Get pending scrape queue count - */ -export async function getPendingScrapeCount( - client: SupabaseClientAny -): Promise { - const { count } = await client - .from('scrape_queue') - .select('*', { count: 'exact', head: true }) - .in('status', ['pending', 'processing']); - - return count || 0; -} - -/** - * Check if data is stale - */ -export function isDataStale(lastUpdated: string, maxAgeMinutes: number = 60): boolean { - const updated = new Date(lastUpdated); - const now = new Date(); - const diffMs = now.getTime() - updated.getTime(); - const diffMins = diffMs / (1000 * 60); - return diffMins > maxAgeMinutes; -} diff --git a/wing-command/lib/tinyfish-scraper.ts b/wing-command/lib/tinyfish-scraper.ts deleted file mode 100644 index 8cd476427..000000000 --- a/wing-command/lib/tinyfish-scraper.ts +++ /dev/null @@ -1,546 +0,0 @@ -// =========================================== -// Wing Scout v2 — TinyFish Web Scraper -// Flavor-aware parallel scraping engine -// Uses agent.tinyfish.ai sync endpoint -// =========================================== - -import { ScrapedRestaurant, WingSpot, TinyFishResponse, PlatformIds, FlavorPersona } from './types'; -import { calculateStatus, deduplicateWingSpots, getFlavorPersona, scoreSpotFlavor } from './utils'; - -// TinyFish API Configuration - -const TINYFISH_API_URL = process.env.TINYFISH_API_URL || 'https://agent.tinyfish.ai/v1/automation/run'; -const TINYFISH_API_KEY = process.env.TINYFISH_API_KEY || ''; - -if (!TINYFISH_API_KEY) { - console.warn('Warning: TINYFISH_API_KEY not set. Scraping will be disabled.'); -} - -// Timeout helper -async function withTimeout(promise: Promise, timeoutMs: number, fallback: T): Promise { - let timeoutId: NodeJS.Timeout; - const timeoutPromise = new Promise((resolve) => { - timeoutId = setTimeout(() => { - console.warn(`Operation timed out after ${timeoutMs}ms`); - resolve(fallback); - }, timeoutMs); - }); - return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeoutId)); -} - -// Render has unlimited runtime, but individual scraper calls still need per-source limits -const SCRAPER_TIMEOUT = 120000; // 120 seconds per source (restaurant discovery) -const MENU_SCRAPER_TIMEOUT = 45000; // 45 seconds for menu fetch (must fit inside maxDuration=60) - -interface TinyFishSyncResponse { - run_id: string; - status: 'COMPLETED' | 'FAILED' | 'CANCELLED'; - started_at: string; - finished_at: string; - num_of_steps: number; - result: unknown; - error: string | null; -} - -/** - * Core TinyFish scrape function with configurable timeout - * Exported for use by deals scraper - */ -export async function runTinyFishScrape(url: string, goal: string, timeoutMs: number): Promise { - if (!TINYFISH_API_KEY) { - console.error('TinyFish API key not configured'); - return { success: false, data: null, error: 'TinyFish API key not configured' }; - } - - try { - console.log(`TinyFish scraping (${timeoutMs}ms timeout): ${url}`); - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - - const response = await fetch(TINYFISH_API_URL, { - method: 'POST', - headers: { - 'X-API-Key': TINYFISH_API_KEY, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ url, goal }), - signal: controller.signal, - cache: 'no-store', - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errText = await response.text(); - console.error(`TinyFish HTTP ${response.status}:`, errText.substring(0, 200)); - return { success: false, data: null, error: `HTTP ${response.status}: ${errText.substring(0, 200)}` }; - } - - const data = await response.json() as TinyFishSyncResponse; - - if (data.error) { - console.error('TinyFish error:', data.error); - return { success: false, data: null, error: data.error }; - } - - if (data.status === 'COMPLETED' && data.result) { - console.log(`TinyFish COMPLETED (${data.num_of_steps} steps, ${data.run_id})`); - return { success: true, data: data.result }; - } - - console.error(`TinyFish status: ${data.status}, no result`); - return { success: false, data: null, error: `TinyFish status: ${data.status}` }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - if (errorMessage.includes('abort')) { - console.error(`TinyFish timeout after ${timeoutMs}ms for: ${url}`); - } else { - console.error('TinyFish API error:', errorMessage); - } - return { success: false, data: null, error: errorMessage }; - } -} - -/** - * Execute TinyFish scrape for restaurant discovery (120s timeout) - */ -export async function executeTinyFishScrape(url: string, goal: string): Promise { - return runTinyFishScrape(url, goal, SCRAPER_TIMEOUT); -} - -/** - * Execute TinyFish scrape for menu extraction (45s timeout) - * Shorter timeout to fit within the /api/menu maxDuration=60s limit - */ -export async function executeTinyFishMenuScrape(url: string, goal: string): Promise { - return runTinyFishScrape(url, goal, MENU_SCRAPER_TIMEOUT); -} - -// ===== DOORDASH SCRAPER ===== -export async function scrapeDoorDash(zipCode: string, city?: string, state?: string): Promise { - const restaurants: ScrapedRestaurant[] = []; - const locationHint = city && state ? ` in ${city}, ${state}` : ''; - - try { - const citySlug = city ? city.toLowerCase().replace(/\s+/g, '-') : ''; - const searchUrl = citySlug && state - ? `https://www.doordash.com/food-delivery/${citySlug}-${state.toLowerCase()}-restaurants/chicken-wings/` - : `https://www.doordash.com/search/store/chicken%20wings%20near%20${zipCode}/?pickup=false`; - const goal = `Find chicken wings restaurants that deliver to zip code ${zipCode}${locationHint}. IMPORTANT: Only include restaurants located in or delivering to ${city || 'this area'}, ${state || 'US'}. Ignore any results from other cities. Extract a JSON array of restaurants with these fields for each: name, address (full street address if visible, or neighborhood/area name), delivery_time (as string like "25-35 min"), rating (number), image_url, is_open (boolean), store_url (the DoorDash URL path like /store/12345/). Return as JSON array called "restaurants".`; - - const result = await executeTinyFishScrape(searchUrl, goal); - if (!result.success || !result.data) return restaurants; - - const data = result.data as { restaurants?: Array> }; - for (const r of data.restaurants || []) { - const storeUrl = String(r.store_url || ''); - const storeIdMatch = storeUrl.match(/\/store\/(\d+)/); - - restaurants.push({ - name: String(r.name || 'Unknown'), - address: String(r.address || ''), - delivery_time: String(r.delivery_time || ''), - rating: Number(r.rating) || undefined, - image_url: String(r.image_url || ''), - is_open: Boolean(r.is_open), - source: 'doordash', - menu_items: [], - store_id: storeIdMatch ? storeIdMatch[1] : undefined, - source_url: storeUrl ? `https://www.doordash.com${storeUrl}` : undefined, - }); - } - console.log(`DoorDash: Found ${restaurants.length} restaurants`); - } catch (error) { - console.error('DoorDash scrape error:', error); - } - - return restaurants; -} - -// ===== UBEREATS SCRAPER ===== -export async function scrapeUberEats(zipCode: string, city?: string, state?: string): Promise { - const restaurants: ScrapedRestaurant[] = []; - const locationHint = city && state ? ` in ${city}, ${state}` : ''; - - try { - const citySlug = city ? city.toLowerCase().replace(/\s+/g, '-') : ''; - const searchUrl = citySlug && state - ? `https://www.ubereats.com/city/${citySlug}-${state.toLowerCase()}/food-delivery/chicken-wings` - : `https://www.ubereats.com/search?q=chicken%20wings%20near%20${zipCode}`; - const goal = `Find chicken wings restaurants that deliver to zip code ${zipCode}${locationHint}. IMPORTANT: Only include restaurants in ${city || 'this area'}, ${state || 'US'}. Ignore results from other cities. Extract a JSON array of stores with these fields for each: name, address, eta (delivery time as string), rating (number), image (image URL), is_available (boolean), store_url (the UberEats URL path like /store/restaurant-name/uuid). Return as JSON array called "stores".`; - - const result = await executeTinyFishScrape(searchUrl, goal); - if (!result.success || !result.data) return restaurants; - - const data = result.data as { stores?: Array> }; - for (const s of data.stores || []) { - const storeUrl = String(s.store_url || ''); - const uuidMatch = storeUrl.match(/\/store\/[^/]+\/([a-f0-9-]{36})/i); - - restaurants.push({ - name: String(s.name || 'Unknown'), - address: String(s.address || ''), - delivery_time: String(s.eta || ''), - rating: Number(s.rating) || undefined, - image_url: String(s.image || ''), - is_open: Boolean(s.is_available), - source: 'ubereats', - menu_items: [], - store_uuid: uuidMatch ? uuidMatch[1] : undefined, - source_url: storeUrl ? `https://www.ubereats.com${storeUrl}` : undefined, - }); - } - console.log(`UberEats: Found ${restaurants.length} restaurants`); - } catch (error) { - console.error('UberEats scrape error:', error); - } - - return restaurants; -} - -// ===== GRUBHUB SCRAPER ===== -export async function scrapeGrubhub(zipCode: string, city?: string, state?: string): Promise { - const restaurants: ScrapedRestaurant[] = []; - const locationHint = city && state ? ` in ${city}, ${state}` : ''; - - try { - const searchUrl = city && state - ? `https://www.grubhub.com/delivery/${city.toLowerCase().replace(/\s+/g, '-')}-${state.toLowerCase()}/chicken-wings` - : `https://www.grubhub.com/search?query=chicken+wings+near+${zipCode}&locationMode=DELIVERY`; - const goal = `Find chicken wings restaurants that deliver to zip code ${zipCode}${locationHint}. IMPORTANT: Only include restaurants in ${city || 'this area'}, ${state || 'US'}. Ignore results from other cities. Extract a JSON array of restaurants with these fields for each: name, address, delivery_time (as string), rating (number), image (image URL), is_open (boolean), restaurant_url (the Grubhub URL path like /restaurant/name/12345). Return as JSON array called "restaurants".`; - - const result = await executeTinyFishScrape(searchUrl, goal); - if (!result.success || !result.data) return restaurants; - - const data = result.data as { restaurants?: Array> }; - for (const r of data.restaurants || []) { - const restaurantUrl = String(r.restaurant_url || ''); - const idMatch = restaurantUrl.match(/\/restaurant\/[^/]+\/(\d+)/); - - restaurants.push({ - name: String(r.name || 'Unknown'), - address: String(r.address || ''), - delivery_time: String(r.delivery_time || ''), - rating: Number(r.rating) || undefined, - image_url: String(r.image || ''), - is_open: Boolean(r.is_open), - source: 'grubhub', - menu_items: [], - restaurant_id: idMatch ? idMatch[1] : undefined, - source_url: restaurantUrl ? `https://www.grubhub.com${restaurantUrl}` : undefined, - }); - } - console.log(`Grubhub: Found ${restaurants.length} restaurants`); - } catch (error) { - console.error('Grubhub scrape error:', error); - } - - return restaurants; -} - -// ===== GOOGLE SCRAPER (Hidden Gem Detection) ===== -export async function scrapeGoogle(zipCode: string, city?: string, state?: string): Promise { - const restaurants: ScrapedRestaurant[] = []; - const locationQuery = city && state ? `+${city.replace(/\s/g, '+')}+${state}` : ''; - - try { - const searchUrl = `https://www.google.com/search?q=best+chicken+wings+local+sports+bar+${zipCode}${locationQuery}`; - const goal = `Extract ALL chicken wings restaurants visible on this Google search results page. -IMPORTANT: Only include restaurants located in or near ${city || `zip code ${zipCode}`}, ${state || 'US'}. Ignore any results from other cities or states. -Include local establishments like: -- Family-owned restaurants and pizzerias with wings -- Sports bars and dive bars serving wings -- Local BBQ joints and wing shops -- Small independent restaurants -- Any place serving chicken wings -NOT just major chains like Buffalo Wild Wings, Wingstop, or Hooters. -Return a JSON array called "businesses" with these fields for each restaurant: -- name (restaurant name) -- address (full street address including city and state) -- rating (number like 4.2) -- phone (phone number if visible) -- hours (like "Closed - Opens 11 am" or "Open - Closes 10 pm") -- image (image URL if visible) -- website (the restaurant's official website URL if shown in the Google listing, not a Google link) -Scroll down and extract every restaurant listing. Aim for 10-20+ diverse results including hidden gems and local favorites.`; - - const result = await executeTinyFishScrape(searchUrl, goal); - if (!result.success || !result.data) return restaurants; - - const data = result.data as { businesses?: Array> }; - for (const b of data.businesses || []) { - const hoursStr = String(b.hours || ''); - const isOpen = !hoursStr.toLowerCase().includes('closed'); - - const websiteUrl = String(b.website || ''); - restaurants.push({ - name: String(b.name || 'Unknown'), - address: String(b.address || ''), - phone: String(b.phone || ''), - hours: hoursStr, - rating: Number(b.rating) || undefined, - image_url: String(b.image || ''), - is_open: isOpen, - source: 'google', - menu_items: [], - website_url: websiteUrl && websiteUrl.startsWith('http') ? websiteUrl : undefined, - }); - } - console.log(`Google: Found ${restaurants.length} restaurants`); - } catch (error) { - console.error('Google scrape error:', error); - } - - return restaurants; -} - -// ===== PROCESS RESTAURANTS INTO WING SPOTS ===== -function processRestaurants( - restaurants: ScrapedRestaurant[], - zipCode: string, - lat: number, - lng: number -): WingSpot[] { - const wingSpots: WingSpot[] = []; - - for (const restaurant of restaurants) { - const pricePerWing: number | null = null; - const cheapestItemPrice: number | null = null; - const dealText: string | null = null; - - let deliveryMins: number | null = null; - if (restaurant.delivery_time) { - const match = restaurant.delivery_time.match(/(\d+)/); - if (match) deliveryMins = parseInt(match[1], 10); - } - - const platformIds: PlatformIds = {}; - if (restaurant.store_id) platformIds.doordash_store_id = restaurant.store_id; - if (restaurant.store_uuid) platformIds.ubereats_store_uuid = restaurant.store_uuid; - if (restaurant.restaurant_id) platformIds.grubhub_restaurant_id = restaurant.restaurant_id; - if (restaurant.source_url) platformIds.source_url = restaurant.source_url; - if (restaurant.website_url) platformIds.website_url = restaurant.website_url; - if (restaurant.instagram_url) platformIds.instagram_url = restaurant.instagram_url; - - const spot: Omit & { status?: WingSpot['status'] } = { - id: `${restaurant.source}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, - name: restaurant.name, - address: restaurant.address, - lat: lat + (Math.random() - 0.5) * 0.02, - lng: lng + (Math.random() - 0.5) * 0.02, - price_per_wing: pricePerWing, - cheapest_item_price: cheapestItemPrice, - deal_text: dealText, - delivery_time_mins: deliveryMins, - wait_time_mins: null, - is_in_stock: true, - is_open_now: restaurant.is_open ?? true, - opens_during_game: true, - hours_today: restaurant.hours || '11AM - 11PM', - phone: restaurant.phone || null, - image_url: restaurant.image_url && restaurant.image_url.startsWith('http') ? restaurant.image_url : null, - source: restaurant.source, - zip_code: zipCode, - last_updated: new Date().toISOString(), - platform_ids: Object.keys(platformIds).length > 0 ? platformIds : undefined, - }; - - spot.status = calculateStatus(spot); - wingSpots.push(spot as WingSpot); - } - - return wingSpots; -} - -// ===== FLAVOR SCORING ===== -// Apply flavor persona scoring to all spots -function applyFlavorScoring(spots: WingSpot[], flavorId: FlavorPersona): WingSpot[] { - const persona = getFlavorPersona(flavorId); - return spots.map(spot => ({ - ...spot, - flavor_match: scoreSpotFlavor(spot, persona), - })); -} - -// ===== STATE VALIDATION ===== -// Extract a 2-letter state abbreviation from a US address string -function extractStateFromAddress(address: string): string | null { - if (!address) return null; - // Match ", CA 90028" or ", NY 10001" pattern - const matchWithZip = address.match(/,\s*([A-Z]{2})\s+\d{5}/); - if (matchWithZip) return matchWithZip[1]; - // Match ", CA" at end of string - const matchEnd = address.match(/,\s*([A-Z]{2})\s*$/); - if (matchEnd) return matchEnd[1]; - // Match ", California" or ", New York" (full state name → abbreviation) - const stateNames: Record = { - 'alabama': 'AL', 'alaska': 'AK', 'arizona': 'AZ', 'arkansas': 'AR', - 'california': 'CA', 'colorado': 'CO', 'connecticut': 'CT', 'delaware': 'DE', - 'florida': 'FL', 'georgia': 'GA', 'hawaii': 'HI', 'idaho': 'ID', - 'illinois': 'IL', 'indiana': 'IN', 'iowa': 'IA', 'kansas': 'KS', - 'kentucky': 'KY', 'louisiana': 'LA', 'maine': 'ME', 'maryland': 'MD', - 'massachusetts': 'MA', 'michigan': 'MI', 'minnesota': 'MN', 'mississippi': 'MS', - 'missouri': 'MO', 'montana': 'MT', 'nebraska': 'NE', 'nevada': 'NV', - 'new hampshire': 'NH', 'new jersey': 'NJ', 'new mexico': 'NM', 'new york': 'NY', - 'north carolina': 'NC', 'north dakota': 'ND', 'ohio': 'OH', 'oklahoma': 'OK', - 'oregon': 'OR', 'pennsylvania': 'PA', 'rhode island': 'RI', 'south carolina': 'SC', - 'south dakota': 'SD', 'tennessee': 'TN', 'texas': 'TX', 'utah': 'UT', - 'vermont': 'VT', 'virginia': 'VA', 'washington': 'WA', 'west virginia': 'WV', - 'wisconsin': 'WI', 'wyoming': 'WY', 'district of columbia': 'DC', - }; - const lower = address.toLowerCase(); - for (const [name, abbr] of Object.entries(stateNames)) { - if (lower.includes(name)) return abbr; - } - return null; -} - -// Get the state abbreviation for a given state name or abbreviation -function normalizeStateAbbreviation(state: string): string { - if (state.length === 2) return state.toUpperCase(); - const stateNames: Record = { - 'alabama': 'AL', 'alaska': 'AK', 'arizona': 'AZ', 'arkansas': 'AR', - 'california': 'CA', 'colorado': 'CO', 'connecticut': 'CT', 'delaware': 'DE', - 'florida': 'FL', 'georgia': 'GA', 'hawaii': 'HI', 'idaho': 'ID', - 'illinois': 'IL', 'indiana': 'IN', 'iowa': 'IA', 'kansas': 'KS', - 'kentucky': 'KY', 'louisiana': 'LA', 'maine': 'ME', 'maryland': 'MD', - 'massachusetts': 'MA', 'michigan': 'MI', 'minnesota': 'MN', 'mississippi': 'MS', - 'missouri': 'MO', 'montana': 'MT', 'nebraska': 'NE', 'nevada': 'NV', - 'new hampshire': 'NH', 'new jersey': 'NJ', 'new mexico': 'NM', 'new york': 'NY', - 'north carolina': 'NC', 'north dakota': 'ND', 'ohio': 'OH', 'oklahoma': 'OK', - 'oregon': 'OR', 'pennsylvania': 'PA', 'rhode island': 'RI', 'south carolina': 'SC', - 'south dakota': 'SD', 'tennessee': 'TN', 'texas': 'TX', 'utah': 'UT', - 'vermont': 'VT', 'virginia': 'VA', 'washington': 'WA', 'west virginia': 'WV', - 'wisconsin': 'WI', 'wyoming': 'WY', 'district of columbia': 'DC', - }; - return stateNames[state.toLowerCase()] || state.toUpperCase(); -} - -// ===== MAIN PARALLEL SCRAPER ===== -export async function scrapeAllSources( - zipCode: string, - lat: number, - lng: number, - flavor?: FlavorPersona, - city?: string, - state?: string, -): Promise { - console.log(`Starting parallel scrape for zip: ${zipCode}${city ? ` (${city}, ${state})` : ''}${flavor ? ` flavor: ${flavor}` : ''}`); - - // Fire all scrapers in parallel with Promise.allSettled - const results = await Promise.allSettled([ - withTimeout(scrapeGoogle(zipCode, city, state), SCRAPER_TIMEOUT, []), - withTimeout(scrapeDoorDash(zipCode, city, state), SCRAPER_TIMEOUT, []), - withTimeout(scrapeGrubhub(zipCode, city, state), SCRAPER_TIMEOUT, []), - withTimeout(scrapeUberEats(zipCode, city, state), SCRAPER_TIMEOUT, []), - ]); - - const allRestaurants: ScrapedRestaurant[] = []; - const sourceNames = ['Google', 'DoorDash', 'Grubhub', 'UberEats']; - - results.forEach((result, index) => { - if (result.status === 'fulfilled') { - console.log(`${sourceNames[index]}: ${result.value.length} results`); - allRestaurants.push(...result.value); - } else { - console.error(`${sourceNames[index]} failed:`, result.reason); - } - }); - - // Process + deduplicate - let wingSpots = processRestaurants(allRestaurants, zipCode, lat, lng); - wingSpots = deduplicateWingSpots(wingSpots); - - // Post-scrape state validation: reject results from wrong states/cities - if (state) { - const targetState = normalizeStateAbbreviation(state); - const targetCity = city?.toLowerCase().replace(/\s+/g, '-') || ''; - const beforeCount = wingSpots.length; - wingSpots = wingSpots.filter(spot => { - // 1. Check address for state mismatch - if (spot.address) { - const spotState = extractStateFromAddress(spot.address); - if (spotState && spotState !== targetState) { - console.warn(`Rejected out-of-state result: "${spot.name}" address="${spot.address}" (${spotState}) — expected ${targetState}`); - return false; - } - if (spotState === targetState) return true; // Confirmed correct state - } - - // 2. Check source_url for wrong-city hints (DoorDash URLs contain city like /store/name-los-angeles/) - const sourceUrl = spot.platform_ids?.source_url || ''; - if (sourceUrl && targetCity) { - // Known major cities to cross-check against - const majorCities = [ - 'los-angeles', 'new-york', 'chicago', 'houston', 'phoenix', 'philadelphia', - 'san-antonio', 'san-diego', 'dallas', 'san-jose', 'austin', 'jacksonville', - 'fort-worth', 'columbus', 'charlotte', 'san-francisco', 'indianapolis', 'seattle', - 'denver', 'washington', 'nashville', 'oklahoma-city', 'el-paso', 'boston', - 'portland', 'las-vegas', 'memphis', 'louisville', 'baltimore', 'milwaukee', - 'albuquerque', 'tucson', 'fresno', 'mesa', 'sacramento', 'atlanta', 'miami', - 'detroit', 'minneapolis', 'tampa', 'pittsburgh', 'st-louis', 'orlando', - ]; - const urlLower = sourceUrl.toLowerCase(); - for (const wrongCity of majorCities) { - if (wrongCity !== targetCity && urlLower.includes(wrongCity)) { - console.warn(`Rejected wrong-city result: "${spot.name}" URL contains "${wrongCity}" — expected "${targetCity}"`); - return false; - } - } - } - - // 3. No address and no URL city mismatch — keep it (benefit of the doubt) - return true; - }); - const rejected = beforeCount - wingSpots.length; - if (rejected > 0) { - console.log(`Location validation: rejected ${rejected}/${beforeCount} out-of-area results (target: ${city}, ${targetState})`); - } - } - - // Apply flavor scoring if persona selected - if (flavor) { - wingSpots = applyFlavorScoring(wingSpots, flavor); - } - - console.log(`Total: ${allRestaurants.length} raw, ${wingSpots.length} unique after dedup + validation`); - - return wingSpots; -} - -// ===== MENU DEDUPLICATION ===== -// Normalizes menu item names to intelligently merge across platforms -export function normalizeMenuItem(name: string): string { - return name - .toLowerCase() - .replace(/\d+\s*-?\s*(pc|pcs|piece|pieces|ct|count)/i, '') - .replace(/\s*(traditional|boneless|bone-in|classic|original)\s*/i, '') - .replace(/\s+/g, ' ') - .trim(); -} - -export function dedupeMenu( - items: Array<{ name: string; price?: number | null; source?: string }> -): Array<{ name: string; price: number | null; source: string }> { - const seen = new Map(); - - for (const item of items) { - const key = normalizeMenuItem(item.name); - const existing = seen.get(key); - - if (!existing) { - seen.set(key, { name: item.name, price: item.price ?? null, source: item.source || 'unknown' }); - continue; - } - - // Keep the entry with the lowest price (win condition) - const existingPrice = existing.price ?? Infinity; - const newPrice = item.price ?? Infinity; - if (newPrice < existingPrice) { - seen.set(key, { name: item.name, price: item.price ?? null, source: item.source || 'unknown' }); - } - } - - return Array.from(seen.values()); -} diff --git a/wing-command/lib/types.ts b/wing-command/lib/types.ts index 6c592c93e..c4c102e10 100644 --- a/wing-command/lib/types.ts +++ b/wing-command/lib/types.ts @@ -1,321 +1,88 @@ -// =========================================== -// Wing Scout v2 — Type Definitions -// "Super Bowl War Room" Edition -// =========================================== +// ============================================= +// Wing Command v4 — Types +// No Supabase. No Redis. Pure in-memory. +// ============================================= -/** - * Flavor Persona — user selects before searching - */ export type FlavorPersona = 'face-melter' | 'classicist' | 'sticky-finger'; - -export interface FlavorPersonaInfo { - id: FlavorPersona; - label: string; - subtitle: string; - keywords: string[]; - emoji: string; - color: string; -} - -/** - * Source platform for wing data - */ -export type WingSource = 'doordash' | 'ubereats' | 'grubhub' | 'google'; - -/** - * Pin status color - */ +export type WingSource = 'doordash' | 'ubereats' | 'grubhub' | 'google' | 'yelp'; export type WingStatus = 'green' | 'yellow' | 'red'; +export type SearchPhase = 'idle' | 'discovering' | 'scouting' | 'done'; -/** - * Main wing spot data structure - */ export interface WingSpot { - id: string; - name: string; - address: string; - lat: number; - lng: number; - price_per_wing: number | null; - cheapest_item_price: number | null; - estimated_price_per_wing?: number | null; // Chain lookup or zip-average estimate - is_price_estimated?: boolean; // True = price is an estimate, not real - deal_text: string | null; - delivery_time_mins: number | null; - wait_time_mins: number | null; - is_in_stock: boolean; - is_open_now: boolean; - opens_during_game: boolean; - hours_today: string | null; - phone: string | null; - image_url: string | null; - source: WingSource; - status: WingStatus; - zip_code: string; - last_updated: string; - created_at?: string; - platform_ids?: PlatformIds; - // v2 additions - flavor_tags?: string[]; - flavor_match?: number; // 0-100 score against selected persona - menu_json?: MenuItemRaw[]; + id: string; + name: string; + address: string; + rating?: number; + deliveryTime?: string; + deliveryFee?: string; + isOpen: boolean; + imageUrl?: string; + sourceUrl?: string; + phone?: string; + priceRange?: string; + source: WingSource; + siteName: string; + // set client-side after scrape + status: WingStatus; +} + +export interface AgentStatus { + agentId: string; + siteName: string; + siteUrl: string; + source: string; + status: 'pending' | 'running' | 'complete' | 'error'; + streamingUrl?: string; + message?: string; + spots?: WingSpot[]; + error?: string; +} + +export interface SearchState { + phase: SearchPhase; + stepMessage: string; + agents: Record; + completedSpots: WingSpot[]; +} + +export interface SearchParams { + zipCode: string; + city?: string; + state?: string; + flavor?: FlavorPersona; } -/** - * Geocoded location data - */ -export interface GeocodedLocation { - zip_code: string; - city: string; - state: string; - lat: number; - lng: number; - cached_at?: string; -} - -/** - * Scrape queue item - */ -export interface ScrapeQueueItem { - id: string; - zip_code: string; - status: 'pending' | 'processing' | 'completed' | 'failed'; - created_at: string; - started_at?: string; - completed_at?: string; - error?: string; -} - -/** - * Scout API response (v2 — includes flavor) - */ export interface ScoutResponse { - success: boolean; - spots: WingSpot[]; - cached: boolean; - message: string; - location?: GeocodedLocation; - flavor?: FlavorPersona; + success: boolean; + spots: WingSpot[]; + location?: { city: string; state: string; lat?: number; lng?: number }; + cached: boolean; + message: string; } -// Keep ScrapeResponse as alias for backwards compat -export type ScrapeResponse = ScoutResponse; - -/** - * Raw menu item from scraping - */ -export interface MenuItemRaw { - name: string; - description?: string; - price: number | null; - quantity?: number; - price_per_wing?: number; - is_deal: boolean; - flavor_tags?: string[]; -} - -/** - * Menu item extracted from OCR / scraping - */ -export interface MenuItem { - name: string; - description?: string; - price: number | null; - quantity?: number; - price_per_wing?: number; - is_deal: boolean; -} - -/** - * Platform-specific identifiers for menu lookups - */ -export interface PlatformIds { - doordash_store_id?: string; - ubereats_store_uuid?: string; - grubhub_restaurant_id?: string; - source_url?: string; - website_url?: string; - instagram_url?: string; -} - -/** - * Menu section (e.g., "Wings", "Appetizers") - */ -export interface MenuSection { - name: string; - items: MenuItem[]; -} - -/** - * Result from getCheapestWingPrice() — both per-wing and raw item price - */ -export interface WingPriceResult { - price_per_wing: number | null; // Calculated per-wing price (e.g., $1.30) - cheapest_item_price: number | null; // Raw cheapest menu item price (e.g., $12.99) -} - -/** - * Full menu structure - */ -export interface Menu { - spot_id: string; - sections: MenuSection[]; - fetched_at: string; - source: 'yelp' | 'tinyfish_scrape' | 'cached'; - has_wings: boolean; - wing_section_index?: number; - source_url?: string; -} - -/** - * Menu API response - */ -export interface MenuResponse { - success: boolean; - menu: Menu | null; - cached: boolean; - message: string; - scouting?: boolean; // true when menu is being fetched in the background - source_url?: string; // link to restaurant's page (DoorDash, UberEats, etc.) -} - -/** - * Scraped restaurant data (raw from scraper) - */ -export interface ScrapedRestaurant { - name: string; - address: string; - phone?: string; - hours?: string; - delivery_time?: string; - delivery_fee?: string; - rating?: number; - image_url?: string; - menu_items: MenuItem[]; - is_open?: boolean; - source: WingSource; - store_id?: string; - store_uuid?: string; - restaurant_id?: string; - source_url?: string; - website_url?: string; - instagram_url?: string; -} - -/** - * TinyFish scrape request - */ -export interface TinyFishRequest { - url: string; - query: string; - wait_for_selector?: string; - timeout?: number; - user_agent?: string; -} - -/** - * TinyFish scrape response - */ -export interface TinyFishResponse { - success: boolean; - data: unknown; - screenshot?: string; - error?: string; -} - -/** - * Map viewport state - */ -export interface MapViewport { - latitude: number; - longitude: number; - zoom: number; +export interface AvailabilityStats { + green: number; + yellow: number; + red: number; + total: number; } -/** - * Popular city for autocomplete - */ export interface PopularCity { - name: string; - state: string; - zip: string; + name: string; + state: string; + zip: string; } -/** - * Countdown timer state - */ export interface CountdownTime { - days: number; - hours: number; - minutes: number; - seconds: number; - isPast: boolean; + days: number; + hours: number; + minutes: number; + seconds: number; } -/** - * Availability statistics - */ -export interface AvailabilityStats { - total: number; - green: number; - yellow: number; - red: number; - percentage: number; -} - -/** - * Super Bowl deal found via aggregator roundup, restaurant website, or social media - */ -export interface SuperBowlDeal { - description: string; - source: 'website' | 'instagram' | 'aggregator'; - promo_code?: string; - pre_order_deadline?: string; - pre_order_url?: string; - special_menu_items?: string[]; -} - -/** - * Aggregator deal — intermediate structure from scraping deal roundup pages. - * Groups deals by restaurant name before matching to specific WingSpots. - */ -export interface AggregatorDeal { - restaurant_name: string; - deals: SuperBowlDeal[]; -} - -/** - * Deals API response - */ -export interface DealsResponse { - success: boolean; - deals: SuperBowlDeal[]; - cached: boolean; - message: string; - scouting?: boolean; // true when deals are being fetched in the background -} - -/** - * Supabase database row types - */ -export interface Database { - public: { - Tables: { - wing_spots: { - Row: WingSpot; - Insert: Omit; - Update: Partial>; - }; - geocode_cache: { - Row: GeocodedLocation; - Insert: GeocodedLocation; - Update: Partial; - }; - scrape_queue: { - Row: ScrapeQueueItem; - Insert: Omit; - Update: Partial>; - }; - }; - }; +export interface FlavorPersonaInfo { + label: string; + description: string; + emoji: string; + color: string; } diff --git a/wing-command/package-lock.json b/wing-command/package-lock.json index bab74e234..87c6d38c9 100644 --- a/wing-command/package-lock.json +++ b/wing-command/package-lock.json @@ -1,23 +1,21 @@ { - "name": "wing-scout", - "version": "3.0.0", + "name": "wing-command", + "version": "4.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "wing-scout", - "version": "3.0.0", + "name": "wing-command", + "version": "4.0.0", "dependencies": { - "@supabase/supabase-js": "^2.45.0", "@tanstack/react-query": "^5.51.1", - "@upstash/redis": "^1.34.0", + "@tiny-fish/sdk": "latest", "autoprefixer": "^10.4.19", - "axios": "^1.7.2", "canvas-confetti": "^1.9.3", "clsx": "^2.1.1", "date-fns": "^3.6.0", - "dotenv": "^17.2.4", "framer-motion": "^11.3.0", + "groq-sdk": "^0.7.0", "lucide-react": "^0.400.0", "next": "^14.2.35", "postcss": "^8.4.39", @@ -54,21 +52,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "license": "MIT", "optional": true, "dependencies": { @@ -76,9 +74,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -188,9 +186,9 @@ "license": "BSD-3-Clause" }, "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", "engines": { "node": ">=18" @@ -684,13 +682,13 @@ } }, "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -971,92 +969,12 @@ "license": "MIT" }, "node_modules/@rushstack/eslint-patch": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.15.0.tgz", - "integrity": "sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz", + "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", "dev": true, "license": "MIT" }, - "node_modules/@supabase/auth-js": { - "version": "2.94.0", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.94.0.tgz", - "integrity": "sha512-FPFx8DzEreSoLo2HVfwNY0p/uNQ9rONQp3VKw68UP8wg1YwXK5g+TM4d4U7LTGW4HqwG0rjUdQ1it7QPw09r2w==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/functions-js": { - "version": "2.94.0", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.94.0.tgz", - "integrity": "sha512-DAbIptT7e7hAvYHp4FhRH+LxxvKQ38QGxjaFHLoDoeQBqDaAbP/iu74dLOn6PIAnSRAqUkN2bKGs3awzNzBgKA==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/postgrest-js": { - "version": "2.94.0", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.94.0.tgz", - "integrity": "sha512-3YKoDJu8VxvlJdCe2U2edzSQ9uArR0OLM3A4eAsS4QnIqzs+HuY5ZnubeoWnn/zRNeTENMLSXXZHAeMBPkyXew==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/realtime-js": { - "version": "2.94.0", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.94.0.tgz", - "integrity": "sha512-TTPVttf4yMZTd0Jo65rIn4eyTAlI7XlwgB6OVEnne4Sz4VOddXPavEw4xRISOKJZ1n8ULLRz03hilMtqnj9gNg==", - "license": "MIT", - "dependencies": { - "@types/phoenix": "^1.6.6", - "@types/ws": "^8.18.1", - "tslib": "2.8.1", - "ws": "^8.18.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/storage-js": { - "version": "2.94.0", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.94.0.tgz", - "integrity": "sha512-wLdfqKqSfdDgGbLqgsT8ssEELBaHJm1xwiymq3cvVgxcbjRR6ECtUGtA1kj0JvX/F9DiARbrk/zkIsQ+OaUVBg==", - "license": "MIT", - "dependencies": { - "iceberg-js": "^0.8.1", - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/supabase-js": { - "version": "2.94.0", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.94.0.tgz", - "integrity": "sha512-KcqoA3ITW++CwoyCFxwV10npzR6wMfjKbMz87Q1PSuLw26SmHFQjjbBLvuZpzOrPoQ67on5W55irFsK8e0BhWg==", - "license": "MIT", - "dependencies": { - "@supabase/auth-js": "2.94.0", - "@supabase/functions-js": "2.94.0", - "@supabase/postgrest-js": "2.94.0", - "@supabase/realtime-js": "2.94.0", - "@supabase/storage-js": "2.94.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -1074,9 +992,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.90.20", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", - "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "version": "5.96.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.2.tgz", + "integrity": "sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==", "license": "MIT", "funding": { "type": "github", @@ -1084,12 +1002,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.20", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz", - "integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==", + "version": "5.96.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.96.2.tgz", + "integrity": "sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.20" + "@tanstack/query-core": "5.96.2" }, "funding": { "type": "github", @@ -1099,6 +1017,27 @@ "react": "^18 || ^19" } }, + "node_modules/@tiny-fish/sdk": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@tiny-fish/sdk/-/sdk-0.0.7.tgz", + "integrity": "sha512-VzBTKwYfJZEkNpj56kyBGnT6m8DNaXdDSaDImTC6t/9IyIcXguqrB83pyioRs23vIhQs+soNs8vgYASALDmtsA==", + "dependencies": { + "p-retry": "^7.1.1", + "zod": "^4.3.6" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@tiny-fish/sdk/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1125,19 +1064,23 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.31", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.31.tgz", - "integrity": "sha512-5jsi0wpncvTD33Sh1UCgacK37FFwDn+EG7wCmEvs62fCvBL+n8/76cAYDok21NF6+jaVWIqKwCZyX7Vbu8eB3A==", + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, - "node_modules/@types/phoenix": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", - "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", - "license": "MIT" + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } }, "node_modules/@types/prop-types": { "version": "15.7.15", @@ -1147,9 +1090,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", "dependencies": { @@ -1167,30 +1110,21 @@ "@types/react": "^18.0.0" } }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", - "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/type-utils": "8.54.0", - "@typescript-eslint/utils": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1200,9 +1134,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.54.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -1216,16 +1150,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", - "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3" }, "engines": { @@ -1236,19 +1170,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", - "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.54.0", - "@typescript-eslint/types": "^8.54.0", + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", "debug": "^4.4.3" }, "engines": { @@ -1259,18 +1193,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", - "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0" + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1281,9 +1215,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", - "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", "dev": true, "license": "MIT", "engines": { @@ -1294,21 +1228,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", - "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1318,14 +1252,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", - "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", "dev": true, "license": "MIT", "engines": { @@ -1337,21 +1271,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", - "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.54.0", - "@typescript-eslint/tsconfig-utils": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3", - "minimatch": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1361,46 +1295,59 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", - "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0" + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1410,19 +1357,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", - "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1433,13 +1380,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -1721,19 +1668,22 @@ "win32" ] }, - "node_modules/@upstash/redis": { - "version": "1.36.2", - "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.36.2.tgz", - "integrity": "sha512-C0Yt8hc12vLaQYRG1fMci8iPrLtnTdbJG0HR5T8vKnvEP/1RdMMblsOJs5/jp0JXZJ1oSzMnQz4J9EVezNpI6A==", + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "license": "MIT", "dependencies": { - "uncrypto": "^0.1.3" + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -1753,10 +1703,22 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2022,9 +1984,9 @@ "license": "MIT" }, "node_modules/autoprefixer": { - "version": "10.4.24", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", - "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", "funding": [ { "type": "opencollective", @@ -2042,7 +2004,7 @@ "license": "MIT", "dependencies": { "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001766", + "caniuse-lite": "^1.0.30001774", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -2074,26 +2036,15 @@ } }, "node_modules/axe-core": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", - "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.2.tgz", + "integrity": "sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==", "dev": true, "license": "MPL-2.0", "engines": { "node": ">=4" } }, - "node_modules/axios": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", - "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2112,12 +2063,15 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.10.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", + "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -2133,9 +2087,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -2156,9 +2110,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "funding": [ { "type": "opencollective", @@ -2175,11 +2129,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -2268,9 +2222,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001767", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz", - "integrity": "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==", + "version": "1.0.30001786", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz", + "integrity": "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==", "funding": [ { "type": "opencollective", @@ -2622,18 +2576,6 @@ "node": ">=6.0.0" } }, - "node_modules/dotenv": { - "version": "17.2.4", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.4.tgz", - "integrity": "sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2656,9 +2598,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -2756,9 +2698,9 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", - "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", + "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2777,6 +2719,7 @@ "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0", "safe-array-concat": "^1.1.3" }, "engines": { @@ -2949,15 +2892,15 @@ } }, "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", "dev": true, "license": "MIT", "dependencies": { "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" } }, "node_modules/eslint-import-resolver-node/node_modules/debug": { @@ -3189,24 +3132,6 @@ "node": ">=0.10.0" } }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/eslint-plugin-react/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -3311,6 +3236,15 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3427,32 +3361,12 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -3502,6 +3416,25 @@ "node": ">= 6" } }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -3669,9 +3602,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.1.tgz", - "integrity": "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==", + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3718,9 +3651,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -3728,13 +3661,13 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -3801,6 +3734,36 @@ "dev": true, "license": "MIT" }, + "node_modules/groq-sdk": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/groq-sdk/-/groq-sdk-0.7.0.tgz", + "integrity": "sha512-OgPqrRtti5MjEVclR8sgBHrhSkTLdFCmi47yrEF29uJZaiCkX3s7bXpnMhq8Lwoe1f4AwgC0qGOeHXpeSgu5lg==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/groq-sdk/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/groq-sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -3892,13 +3855,13 @@ "node": ">= 0.4" } }, - "node_modules/iceberg-js": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", - "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", "license": "MIT", - "engines": { - "node": ">=20.0.0" + "dependencies": { + "ms": "^2.0.0" } }, "node_modules/ignore": { @@ -4221,6 +4184,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-network-error": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz", + "integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4697,9 +4672,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4720,11 +4695,11 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -4748,7 +4723,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -4881,10 +4855,79 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", "license": "MIT" }, "node_modules/normalize-path": { @@ -5105,6 +5148,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-retry": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz", + "integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==", + "license": "MIT", + "dependencies": { + "is-network-error": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5178,9 +5236,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -5218,9 +5276,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "funding": [ { "type": "opencollective", @@ -5262,6 +5320,26 @@ "postcss": "^8.0.0" } }, + "node_modules/postcss-import/node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/postcss-js": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", @@ -5395,12 +5473,6 @@ "react-is": "^16.13.1" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5529,12 +5601,16 @@ } }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -5705,9 +5781,9 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6014,13 +6090,13 @@ } }, "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -6309,6 +6385,26 @@ "node": ">=14.0.0" } }, + "node_modules/tailwindcss/node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -6371,9 +6467,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -6394,10 +6490,16 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -6569,12 +6671,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/uncrypto": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", - "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", - "license": "MIT" - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -6662,6 +6758,31 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6863,13 +6984,13 @@ } }, "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -6885,27 +7006,6 @@ "dev": true, "license": "ISC" }, - "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/wing-command/package.json b/wing-command/package.json index 5ed10c790..ecbfb2cb5 100644 --- a/wing-command/package.json +++ b/wing-command/package.json @@ -1,32 +1,24 @@ { - "name": "wing-scout", - "version": "3.0.0", + "name": "wing-command", + "version": "4.0.0", "private": true, - "description": "Super Bowl LX: Wing Command — Your Game Day Wing HQ. Find the best chicken wings for your Super Bowl party.", + "description": "Wing Command \u2014 Find the best chicken wings near you. Powered by TinyFish + Groq.", "scripts": { "dev": "next dev", "build": "next build", "start": "next start -p ${PORT:-3000}", "lint": "next lint", - "type-check": "tsc --noEmit", - "warm": "npx tsx scripts/cache-warmer.ts", - "warm:sb": "npx tsx scripts/cache-warmer.ts --tier=1", - "warm:metros": "npx tsx scripts/cache-warmer.ts --tier=2", - "warm:full": "npx tsx scripts/cache-warmer.ts --tier=3", - "warm:loop": "npx tsx scripts/cache-warmer.ts --loop", - "warm:dry": "npx tsx scripts/cache-warmer.ts --dry-run" + "type-check": "tsc --noEmit" }, "dependencies": { - "@supabase/supabase-js": "^2.45.0", + "@tiny-fish/sdk": "latest", "@tanstack/react-query": "^5.51.1", - "@upstash/redis": "^1.34.0", "autoprefixer": "^10.4.19", - "axios": "1.13.4", "canvas-confetti": "^1.9.3", "clsx": "^2.1.1", "date-fns": "^3.6.0", - "dotenv": "^17.2.4", "framer-motion": "^11.3.0", + "groq-sdk": "^0.7.0", "lucide-react": "^0.400.0", "next": "^14.2.35", "postcss": "^8.4.39", @@ -45,8 +37,5 @@ "eslint": "^8.57.0", "eslint-config-next": "14.2.35", "typescript": "^5.5.3" - }, - "engines": { - "node": "22.x" } -} +} \ No newline at end of file diff --git a/wing-command/render.yaml b/wing-command/render.yaml deleted file mode 100644 index 7507c7ceb..000000000 --- a/wing-command/render.yaml +++ /dev/null @@ -1,71 +0,0 @@ -services: - # ====== Main Web App ====== - - type: web - name: wing-scout - runtime: node - buildCommand: npm install && npm run build - startCommand: npm start - envVars: - - key: NODE_ENV - value: production - - key: NEXT_PUBLIC_SUPABASE_URL - sync: false - - key: NEXT_PUBLIC_SUPABASE_ANON_KEY - sync: false - - key: SUPABASE_SERVICE_ROLE_KEY - sync: false - - key: TINYFISH_API_KEY - sync: false - - key: UPSTASH_REDIS_REST_URL - sync: false - - key: UPSTASH_REDIS_REST_TOKEN - sync: false - - # ====== Cache Warmer — Tier 1 (SB host + top party cities) ====== - # Runs every 3 hours — keeps high-demand ZIPs hot in Redis/Supabase - - type: cron - name: wing-scout-warmer - runtime: node - buildCommand: npm install - schedule: "0 */3 * * *" - startCommand: npx tsx scripts/cache-warmer.ts --tier=1 --concurrency=2 - envVars: - - key: NODE_ENV - value: production - - key: NEXT_PUBLIC_SUPABASE_URL - sync: false - - key: NEXT_PUBLIC_SUPABASE_ANON_KEY - sync: false - - key: SUPABASE_SERVICE_ROLE_KEY - sync: false - - key: TINYFISH_API_KEY - sync: false - - key: UPSTASH_REDIS_REST_URL - sync: false - - key: UPSTASH_REDIS_REST_TOKEN - sync: false - - # ====== Game Day Warmer — Tier 2 (all metros) ====== - # Enable this on Super Bowl weekend for full coverage - # Runs every 2 hours to keep all 49 metro ZIPs warm - # - type: cron - # name: wing-scout-warmer-gameday - # runtime: node - # buildCommand: npm install - # schedule: "0 */2 * * *" - # startCommand: npx tsx scripts/cache-warmer.ts --tier=2 --concurrency=3 - # envVars: - # - key: NODE_ENV - # value: production - # - key: NEXT_PUBLIC_SUPABASE_URL - # sync: false - # - key: NEXT_PUBLIC_SUPABASE_ANON_KEY - # sync: false - # - key: SUPABASE_SERVICE_ROLE_KEY - # sync: false - # - key: TINYFISH_API_KEY - # sync: false - # - key: UPSTASH_REDIS_REST_URL - # sync: false - # - key: UPSTASH_REDIS_REST_TOKEN - # sync: false diff --git a/wing-command/scraper/requirements.txt b/wing-command/scraper/requirements.txt deleted file mode 100644 index 2b1be2aac..000000000 --- a/wing-command/scraper/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -requests>=2.31.0 -python-dotenv>=1.0.0 diff --git a/wing-command/scraper/scrape_wings.py b/wing-command/scraper/scrape_wings.py deleted file mode 100644 index 7922e9624..000000000 --- a/wing-command/scraper/scrape_wings.py +++ /dev/null @@ -1,267 +0,0 @@ -#!/usr/bin/env python3 -""" -Wing Scout - Cron Scraper -Scrapes top 150 high-population zip codes every 4 hours. -Runs via GitHub Actions to pre-populate the database. -""" - -import os -import sys -import time -import random -import json -import logging -from datetime import datetime -from typing import List, Dict, Optional, Any -import requests -from dataclasses import dataclass, asdict - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - -# Environment variables -SUPABASE_URL = os.environ.get('SUPABASE_URL', '') -SUPABASE_SERVICE_KEY = os.environ.get('SUPABASE_SERVICE_ROLE_KEY', '') -TINYFISH_API_KEY = os.environ.get('TINYFISH_API_KEY', '') -TINYFISH_API_URL = os.environ.get('TINYFISH_API_URL', 'https://agent.tinyfish.ai') - -# Top 150 ZIP codes to scrape -TOP_ZIP_CODES = [ - # New York Metro - '10001', '10002', '10003', '10011', '10012', '10013', '10014', '10016', '10017', '10018', - '10019', '10021', '10022', '10023', '10024', '11201', '11211', '11215', '11217', '11222', - # Los Angeles Metro - '90001', '90002', '90003', '90004', '90005', '90006', '90007', '90010', '90011', '90012', - '90013', '90014', '90015', '90016', '90017', '90024', '90025', '90034', '90036', '90046', - # Chicago Metro - '60601', '60602', '60603', '60604', '60605', '60606', '60607', '60608', '60609', '60610', - '60611', '60612', '60613', '60614', '60615', '60616', '60617', '60618', '60619', '60620', - # Houston Metro - '77001', '77002', '77003', '77004', '77005', '77006', '77007', '77008', '77009', '77010', - # Phoenix Metro - '85001', '85002', '85003', '85004', '85005', '85006', '85007', '85008', '85009', '85010', - # Philadelphia Metro - '19101', '19102', '19103', '19104', '19106', '19107', '19109', '19111', '19114', '19115', - # San Diego Metro - '92101', '92102', '92103', '92104', '92105', '92106', '92107', '92108', '92109', '92110', - # Dallas Metro - '75201', '75202', '75203', '75204', '75205', '75206', '75207', '75208', '75209', '75210', - # San Francisco Bay Area - '94102', '94103', '94104', '94105', '94107', '94108', '94109', '94110', '94111', '94112', - # Other Major Cities - '98101', '98102', '98103', '98104', # Seattle - '80201', '80202', '80203', '80204', # Denver - '02101', '02102', '02103', '02108', # Boston - '33101', '33109', '33125', '33126', # Miami - '30301', '30303', '30305', '30306', # Atlanta - '89101', '89102', '89103', '89104', # Las Vegas - '78201', '78202', '78203', '78204', # San Antonio -] - -USER_AGENTS = [ - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36', - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36', - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0', -] - - -@dataclass -class WingSpot: - """Wing spot data structure""" - name: str - address: str - lat: float - lng: float - price_per_wing: Optional[float] - deal_text: Optional[str] - delivery_time_mins: Optional[int] - is_in_stock: bool - is_open_now: bool - opens_during_game: bool - hours_today: Optional[str] - phone: Optional[str] - image_url: Optional[str] - source: str - status: str - zip_code: str - last_updated: str - - -def calculate_status(spot: Dict[str, Any]) -> str: - """Calculate pin status based on criteria""" - if not spot.get('is_in_stock') or not spot.get('is_open_now'): - return 'red' - - price = spot.get('price_per_wing') - has_good_price = price is not None and price <= 1.50 - has_deal = bool(spot.get('deal_text')) - delivery = spot.get('delivery_time_mins') - has_fast_delivery = delivery is not None and delivery < 45 - open_during_game = spot.get('opens_during_game', True) - - if (has_good_price or has_deal) and has_fast_delivery and open_during_game: - return 'green' - - return 'yellow' - - -def geocode_zip(zip_code: str) -> Optional[Dict[str, float]]: - """Geocode zip code using Nominatim""" - try: - response = requests.get( - 'https://nominatim.openstreetmap.org/search', - params={ - 'postalcode': zip_code, - 'country': 'United States', - 'format': 'json', - 'limit': 1, - }, - headers={'User-Agent': 'WingScout-Cron/1.0'}, - timeout=10 - ) - data = response.json() - if data: - return {'lat': float(data[0]['lat']), 'lng': float(data[0]['lon'])} - except Exception as e: - logger.error(f'Geocode error for {zip_code}: {e}') - return None - - -def scrape_with_tinyfish(url: str, goal: str) -> Optional[Dict]: - """Execute TinyFish scrape via sync endpoint""" - try: - response = requests.post( - f'{TINYFISH_API_URL}/v1/automation/run', - json={'url': url, 'goal': goal}, - headers={ - 'X-API-Key': TINYFISH_API_KEY, - 'Content-Type': 'application/json', - }, - timeout=120 - ) - if response.status_code == 200: - data = response.json() - if data.get('status') == 'COMPLETED' and data.get('result'): - return data['result'] - logger.warning(f'TinyFish status: {data.get("status")}, no result') - except Exception as e: - logger.error(f'TinyFish error: {e}') - return None - - -def scrape_doordash(zip_code: str, lat: float, lng: float) -> List[Dict]: - """Scrape DoorDash for wing spots""" - spots = [] - try: - url = f'https://www.doordash.com/search/store/chicken%20wings/' - query = '{restaurants[]{name address delivery_time rating image_url is_open}}' - result = scrape_with_tinyfish(url, query) - - if result and 'restaurants' in result: - for r in result['restaurants'][:10]: - delivery_mins = None - if r.get('delivery_time'): - import re - match = re.search(r'(\d+)', str(r['delivery_time'])) - if match: - delivery_mins = int(match.group(1)) - - spot = { - 'name': r.get('name', 'Unknown'), - 'address': r.get('address', ''), - 'lat': lat + (random.random() - 0.5) * 0.02, - 'lng': lng + (random.random() - 0.5) * 0.02, - 'price_per_wing': round(random.uniform(1.0, 2.0), 2), - 'deal_text': 'Super Bowl Special!' if random.random() > 0.7 else None, - 'delivery_time_mins': delivery_mins, - 'is_in_stock': random.random() > 0.1, - 'is_open_now': r.get('is_open', True), - 'opens_during_game': True, - 'hours_today': '11AM - 2AM', - 'phone': None, - 'image_url': r.get('image_url'), - 'source': 'doordash', - 'zip_code': zip_code, - 'last_updated': datetime.utcnow().isoformat(), - } - spot['status'] = calculate_status(spot) - spots.append(spot) - except Exception as e: - logger.error(f'DoorDash scrape error: {e}') - - return spots - - -def save_to_supabase(spots: List[Dict]) -> bool: - """Save spots to Supabase""" - if not spots or not SUPABASE_URL or not SUPABASE_SERVICE_KEY: - return False - - try: - response = requests.post( - f'{SUPABASE_URL}/rest/v1/wing_spots', - json=spots, - headers={ - 'apikey': SUPABASE_SERVICE_KEY, - 'Authorization': f'Bearer {SUPABASE_SERVICE_KEY}', - 'Content-Type': 'application/json', - 'Prefer': 'resolution=merge-duplicates', - }, - timeout=30 - ) - return response.status_code in [200, 201] - except Exception as e: - logger.error(f'Supabase save error: {e}') - return False - - -def main(): - """Main scraping loop""" - logger.info('Starting Wing Scout cron scraper') - logger.info(f'Processing {len(TOP_ZIP_CODES)} zip codes') - - if not TINYFISH_API_KEY: - logger.error('TINYFISH_API_KEY not set') - sys.exit(1) - - total_spots = 0 - failed_zips = [] - - for i, zip_code in enumerate(TOP_ZIP_CODES): - logger.info(f'[{i+1}/{len(TOP_ZIP_CODES)}] Processing {zip_code}...') - - # Geocode - location = geocode_zip(zip_code) - if not location: - logger.warning(f'Could not geocode {zip_code}') - failed_zips.append(zip_code) - continue - - # Scrape - spots = scrape_doordash(zip_code, location['lat'], location['lng']) - - if spots: - # Save to database - if save_to_supabase(spots): - total_spots += len(spots) - logger.info(f'Saved {len(spots)} spots for {zip_code}') - else: - logger.warning(f'Failed to save spots for {zip_code}') - else: - logger.info(f'No spots found for {zip_code}') - - # Rate limiting - random delay between requests - delay = random.uniform(2, 5) - time.sleep(delay) - - logger.info(f'Scraping complete! Total spots: {total_spots}') - if failed_zips: - logger.warning(f'Failed zips: {failed_zips}') - - -if __name__ == '__main__': - main() diff --git a/wing-command/scripts/cache-warmer.ts b/wing-command/scripts/cache-warmer.ts deleted file mode 100644 index 7b91b93d4..000000000 --- a/wing-command/scripts/cache-warmer.ts +++ /dev/null @@ -1,464 +0,0 @@ -#!/usr/bin/env npx tsx -// =========================================== -// Wing Command — Cache Warmer Script -// Pre-caches wing data for high-demand ZIP codes -// -// Usage: -// npx tsx scripts/cache-warmer.ts # Default: top 30 cities, all flavors -// npx tsx scripts/cache-warmer.ts --tier=1 # Tier 1 only (Super Bowl host + NFL cities) -// npx tsx scripts/cache-warmer.ts --tier=2 # Tier 1 + Tier 2 (top 30 metros) -// npx tsx scripts/cache-warmer.ts --tier=3 # All tiers (396 ZIPs — full blast) -// npx tsx scripts/cache-warmer.ts --zip=19101,10001 # Specific ZIPs only -// npx tsx scripts/cache-warmer.ts --flavor=face-melter # Single flavor only -// npx tsx scripts/cache-warmer.ts --dry-run # Preview what would be cached -// npx tsx scripts/cache-warmer.ts --concurrency=3 # Max parallel scrapes (default: 2) -// npx tsx scripts/cache-warmer.ts --loop # Run continuously (every 12 min) -// npx tsx scripts/cache-warmer.ts --loop --interval=10 # Custom interval (minutes) -// =========================================== - -import * as dotenv from 'dotenv'; -import * as path from 'path'; - -// Load .env.local before anything else -dotenv.config({ path: path.resolve(__dirname, '..', '.env.local') }); - -import { geocodeZipCode } from '../lib/geocode'; -import { scrapeAllSources } from '../lib/tinyfish-scraper'; -import { cacheWingSpots, cacheScrapeResult, getCachedScrapeResult } from '../lib/cache'; -import { createServerClient, upsertWingSpots } from '../lib/supabase'; -import { calculateAvailability } from '../lib/utils'; -import { FlavorPersona, ScoutResponse, WingSpot, GeocodedLocation } from '../lib/types'; - -// =========================================== -// High-Demand ZIP Tiers -// =========================================== - -/** Tier 1: Super Bowl LX host city (Glendale/Phoenix) + NFL championship cities + party hotspots */ -const TIER_1_SUPER_BOWL: Array<{ zip: string; city: string; reason: string }> = [ - // Super Bowl LX host — Glendale, AZ (State Farm Stadium) - { zip: '85301', city: 'Glendale, AZ', reason: 'SB LX Host City' }, - { zip: '85302', city: 'Glendale, AZ', reason: 'SB LX Host City (North)' }, - { zip: '85304', city: 'Glendale, AZ', reason: 'SB LX Host City (West)' }, - { zip: '85001', city: 'Phoenix, AZ', reason: 'SB LX Metro — Downtown Phoenix' }, - { zip: '85003', city: 'Phoenix, AZ', reason: 'SB LX Metro — Midtown' }, - { zip: '85004', city: 'Phoenix, AZ', reason: 'SB LX Metro — Central' }, - { zip: '85008', city: 'Phoenix, AZ', reason: 'SB LX Metro — East Phoenix' }, - { zip: '85281', city: 'Tempe, AZ', reason: 'SB LX Metro — ASU/Tempe' }, - { zip: '85251', city: 'Scottsdale, AZ', reason: 'SB LX Metro — Old Town Scottsdale' }, - - // KC Chiefs (defending champs) — home market - { zip: '64101', city: 'Kansas City, MO', reason: 'Chiefs HQ — Downtown KC' }, - { zip: '64108', city: 'Kansas City, MO', reason: 'Chiefs HQ — Crossroads' }, - { zip: '64129', city: 'Kansas City, MO', reason: 'Arrowhead Stadium Area' }, - - // Philadelphia Eagles (SB contender) — home market - { zip: '19101', city: 'Philadelphia, PA', reason: 'Eagles HQ — Center City' }, - { zip: '19148', city: 'Philadelphia, PA', reason: 'Eagles — South Philly / Stadium' }, - { zip: '19104', city: 'Philadelphia, PA', reason: 'Eagles — University City' }, - - // Top Super Bowl party cities - { zip: '10001', city: 'New York, NY', reason: '#1 Party Market — Midtown' }, - { zip: '90001', city: 'Los Angeles, CA', reason: '#2 Party Market — LA' }, - { zip: '60601', city: 'Chicago, IL', reason: '#3 Party Market — Loop' }, - { zip: '77001', city: 'Houston, TX', reason: '#4 Party Market — Downtown' }, - { zip: '33101', city: 'Miami, FL', reason: '#5 Party Market — Downtown Miami' }, - { zip: '30301', city: 'Atlanta, GA', reason: '#6 Party Market — Downtown ATL' }, - { zip: '75201', city: 'Dallas, TX', reason: '#7 Party Market — Downtown' }, - { zip: '94102', city: 'San Francisco, CA', reason: '#8 Party Market — Downtown SF' }, - { zip: '98101', city: 'Seattle, WA', reason: '#9 Party Market — Downtown' }, - { zip: '80201', city: 'Denver, CO', reason: '#10 Party Market — Denver' }, - - // Las Vegas — massive SB watch party scene - { zip: '89101', city: 'Las Vegas, NV', reason: 'Vegas Strip Watch Parties' }, - { zip: '89109', city: 'Las Vegas, NV', reason: 'Vegas Strip Central' }, - - // New Orleans — wing capital - { zip: '70112', city: 'New Orleans, LA', reason: 'Wing Capital — French Quarter' }, - { zip: '70130', city: 'New Orleans, LA', reason: 'Wing Capital — CBD' }, -]; - -/** Tier 2: Top 30 US metros — downtown ZIPs (high density, lots of delivery) */ -const TIER_2_METROS: Array<{ zip: string; city: string; reason: string }> = [ - { zip: '78201', city: 'San Antonio, TX', reason: 'Top 10 Metro' }, - { zip: '92101', city: 'San Diego, CA', reason: 'Top 10 Metro' }, - { zip: '95101', city: 'San Jose, CA', reason: 'Top 10 Metro' }, - { zip: '78701', city: 'Austin, TX', reason: 'Top 15 Metro' }, - { zip: '32099', city: 'Jacksonville, FL', reason: 'Top 15 Metro' }, - { zip: '76101', city: 'Fort Worth, TX', reason: 'Top 15 Metro' }, - { zip: '43085', city: 'Columbus, OH', reason: 'Top 15 Metro' }, - { zip: '46201', city: 'Indianapolis, IN', reason: 'Top 15 Metro' }, - { zip: '28201', city: 'Charlotte, NC', reason: 'Top 20 Metro' }, - { zip: '33601', city: 'Tampa, FL', reason: 'Top 20 Metro' }, - { zip: '55401', city: 'Minneapolis, MN', reason: 'Top 20 Metro' }, - { zip: '90301', city: 'Inglewood, CA', reason: 'SoFi Stadium Area' }, - { zip: '02101', city: 'Boston, MA', reason: 'Top 15 Metro' }, - { zip: '48201', city: 'Detroit, MI', reason: 'Top 20 Metro' }, - { zip: '37201', city: 'Nashville, TN', reason: 'Top 25 Metro' }, - { zip: '21201', city: 'Baltimore, MD', reason: 'Ravens Market' }, - { zip: '15201', city: 'Pittsburgh, PA', reason: 'Steelers Market' }, - { zip: '14201', city: 'Buffalo, NY', reason: 'WING CAPITAL OF THE WORLD' }, - { zip: '53201', city: 'Milwaukee, WI', reason: 'Packers Overflow' }, - { zip: '44101', city: 'Cleveland, OH', reason: 'Browns Market' }, -]; - -/** Tier 3: All 396 ZIPs from TOP_ZIP_CODES — neighborhood-level coverage */ -// Imported from utils at runtime - -const ALL_FLAVORS: FlavorPersona[] = ['face-melter', 'classicist', 'sticky-finger']; - -// =========================================== -// CLI Argument Parsing -// =========================================== - -interface CliArgs { - tier: 1 | 2 | 3; - zips: string[] | null; - flavors: FlavorPersona[]; - dryRun: boolean; - concurrency: number; - loop: boolean; - intervalMinutes: number; - skipCached: boolean; -} - -function parseArgs(): CliArgs { - const args = process.argv.slice(2); - const flags: Record = {}; - - for (const arg of args) { - if (arg.startsWith('--')) { - const [key, val] = arg.slice(2).split('='); - flags[key] = val ?? 'true'; - } - } - - const tier = parseInt(flags['tier'] || '1', 10) as 1 | 2 | 3; - const zips = flags['zip'] ? flags['zip'].split(',').map(z => z.trim()) : null; - const flavors = flags['flavor'] - ? [flags['flavor'] as FlavorPersona] - : ALL_FLAVORS; - const dryRun = flags['dry-run'] === 'true'; - const concurrency = parseInt(flags['concurrency'] || '2', 10); - const loop = flags['loop'] === 'true'; - const intervalMinutes = parseInt(flags['interval'] || '12', 10); - const skipCached = flags['skip-cached'] !== 'false'; // default true - - return { tier, zips, flavors, dryRun, concurrency, loop, intervalMinutes, skipCached }; -} - -// =========================================== -// Logging -// =========================================== - -const COLORS = { - reset: '\x1b[0m', - bold: '\x1b[1m', - dim: '\x1b[2m', - green: '\x1b[32m', - yellow: '\x1b[33m', - red: '\x1b[31m', - cyan: '\x1b[36m', - magenta: '\x1b[35m', - white: '\x1b[37m', - bgGreen: '\x1b[42m', - bgYellow: '\x1b[43m', - bgRed: '\x1b[41m', -}; - -function log(msg: string) { - const ts = new Date().toLocaleTimeString('en-US', { hour12: false }); - console.log(`${COLORS.dim}[${ts}]${COLORS.reset} ${msg}`); -} - -function logSuccess(msg: string) { log(`${COLORS.green}✓${COLORS.reset} ${msg}`); } -function logWarn(msg: string) { log(`${COLORS.yellow}⚠${COLORS.reset} ${msg}`); } -function logError(msg: string) { log(`${COLORS.red}✗${COLORS.reset} ${msg}`); } -function logInfo(msg: string) { log(`${COLORS.cyan}ℹ${COLORS.reset} ${msg}`); } - -function banner(text: string) { - const line = '═'.repeat(60); - console.log(`\n${COLORS.bold}${COLORS.cyan}╔${line}╗${COLORS.reset}`); - console.log(`${COLORS.bold}${COLORS.cyan}║${COLORS.reset} ${COLORS.bold}${text.padEnd(58)}${COLORS.cyan}║${COLORS.reset}`); - console.log(`${COLORS.bold}${COLORS.cyan}╚${line}╝${COLORS.reset}\n`); -} - -// =========================================== -// Core Warming Logic -// =========================================== - -interface WarmResult { - zip: string; - city: string; - flavor: FlavorPersona; - spots: number; - cached: boolean; - durationMs: number; - error?: string; -} - -async function warmZip( - zip: string, - city: string, - flavor: FlavorPersona, - skipCached: boolean, -): Promise { - const t0 = Date.now(); - - try { - // Check if already cached - if (skipCached) { - const existing = await getCachedScrapeResult(zip); - if (existing && existing.spots.length > 0) { - return { - zip, city, flavor, spots: existing.spots.length, - cached: true, durationMs: Date.now() - t0, - }; - } - } - - // Step 1: Geocode - const location = await geocodeZipCode(zip); - if (!location) { - return { - zip, city, flavor, spots: 0, - cached: false, durationMs: Date.now() - t0, - error: 'Geocode failed', - }; - } - - // Step 2: Scrape all sources - const spots = await scrapeAllSources(zip, location.lat, location.lng, flavor); - - if (spots.length === 0) { - return { - zip, city, flavor, spots: 0, - cached: false, durationMs: Date.now() - t0, - error: 'No spots found', - }; - } - - // Step 3: Cache in Redis (extended TTL for pre-warmed data — 4 hours) - const PRE_WARM_TTL = 4 * 60 * 60; - await cacheWingSpots(zip, spots, PRE_WARM_TTL); - - // Step 4: Build ScoutResponse and cache it - const stats = calculateAvailability(spots); - const result: ScoutResponse = { - success: true, - spots, - cached: false, - flavor, - message: `Pre-warmed: ${spots.length} spots (${stats.percentage}% available)`, - location, - }; - await cacheScrapeResult(zip, result, PRE_WARM_TTL); - - // Step 5: Persist to Supabase - const supabase = createServerClient(); - await upsertWingSpots(supabase, spots); - - return { - zip, city, flavor, spots: spots.length, - cached: false, durationMs: Date.now() - t0, - }; - } catch (error) { - return { - zip, city, flavor, spots: 0, - cached: false, durationMs: Date.now() - t0, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } -} - -/** - * Process a batch of ZIPs with controlled concurrency. - * Each ZIP × flavor combo is a unit of work. - */ -async function processBatch( - targets: Array<{ zip: string; city: string; reason: string }>, - flavors: FlavorPersona[], - concurrency: number, - skipCached: boolean, -): Promise { - // Build the full work queue: each zip × each flavor - const queue: Array<{ zip: string; city: string; reason: string; flavor: FlavorPersona }> = []; - for (const target of targets) { - for (const flavor of flavors) { - queue.push({ ...target, flavor }); - } - } - - const total = queue.length; - let completed = 0; - let skipped = 0; - let errors = 0; - const results: WarmResult[] = []; - - log(`${COLORS.bold}Processing ${total} jobs (${targets.length} ZIPs × ${flavors.length} flavors) with concurrency ${concurrency}${COLORS.reset}\n`); - - // Semaphore-style concurrency control - let active = 0; - let idx = 0; - - async function runNext(): Promise { - while (idx < queue.length) { - const job = queue[idx++]; - const jobNum = idx; - active++; - - const progressBar = `[${completed + skipped}/${total}]`; - logInfo(`${progressBar} ${job.city} (${job.zip}) — ${job.flavor} — ${job.reason}`); - - const result = await warmZip(job.zip, job.city, job.flavor, skipCached); - results.push(result); - - if (result.cached) { - skipped++; - logWarn(`${progressBar} SKIP (cached) ${job.zip} — ${result.spots} spots already warm`); - } else if (result.error) { - errors++; - logError(`${progressBar} FAIL ${job.zip}/${job.flavor}: ${result.error} (${(result.durationMs / 1000).toFixed(1)}s)`); - } else { - completed++; - const secs = (result.durationMs / 1000).toFixed(1); - logSuccess(`${progressBar} DONE ${job.zip} ${job.city} — ${result.spots} spots — ${secs}s`); - } - - active--; - - // Small delay between jobs to avoid hammering APIs - await sleep(2000); - } - } - - // Launch `concurrency` workers - const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => runNext()); - await Promise.all(workers); - - return results; -} - -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -// =========================================== -// Report -// =========================================== - -function printReport(results: WarmResult[], durationMs: number) { - const line = '─'.repeat(60); - console.log(`\n${COLORS.cyan}${line}${COLORS.reset}`); - console.log(`${COLORS.bold} CACHE WARMER REPORT${COLORS.reset}`); - console.log(`${COLORS.cyan}${line}${COLORS.reset}\n`); - - const fresh = results.filter(r => !r.cached && !r.error); - const cached = results.filter(r => r.cached); - const errored = results.filter(r => !!r.error); - - console.log(` ${COLORS.green}✓ Freshly cached:${COLORS.reset} ${fresh.length}`); - console.log(` ${COLORS.yellow}⚡ Already warm:${COLORS.reset} ${cached.length}`); - console.log(` ${COLORS.red}✗ Errors:${COLORS.reset} ${errored.length}`); - console.log(` Total spots cached: ${fresh.reduce((sum, r) => sum + r.spots, 0)}`); - console.log(` Total duration: ${(durationMs / 1000 / 60).toFixed(1)} minutes`); - - if (errored.length > 0) { - console.log(`\n ${COLORS.red}${COLORS.bold}Errors:${COLORS.reset}`); - for (const r of errored) { - console.log(` ${COLORS.red}•${COLORS.reset} ${r.zip} (${r.city}) ${r.flavor}: ${r.error}`); - } - } - - // Top results - const top = fresh.sort((a, b) => b.spots - a.spots).slice(0, 10); - if (top.length > 0) { - console.log(`\n ${COLORS.green}${COLORS.bold}Top Results:${COLORS.reset}`); - for (const r of top) { - console.log(` ${COLORS.green}•${COLORS.reset} ${r.zip} ${r.city} — ${r.spots} spots (${r.flavor}) in ${(r.durationMs / 1000).toFixed(1)}s`); - } - } - - console.log(`\n${COLORS.cyan}${line}${COLORS.reset}\n`); -} - -// =========================================== -// Main -// =========================================== - -async function main() { - const args = parseArgs(); - - banner('🏈 WING COMMAND — CACHE WARMER 🍗'); - logInfo(`Tier: ${args.tier} | Flavors: ${args.flavors.join(', ')} | Concurrency: ${args.concurrency}`); - logInfo(`Skip cached: ${args.skipCached} | Loop: ${args.loop}${args.loop ? ` (every ${args.intervalMinutes}m)` : ''}`); - - // Validate env vars - const requiredEnvVars = [ - 'UPSTASH_REDIS_REST_URL', - 'UPSTASH_REDIS_REST_TOKEN', - 'NEXT_PUBLIC_SUPABASE_URL', - 'SUPABASE_SERVICE_ROLE_KEY', - ]; - const missing = requiredEnvVars.filter(v => !process.env[v]); - if (missing.length > 0) { - logError(`Missing environment variables: ${missing.join(', ')}`); - logInfo('Make sure .env.local has all required keys'); - process.exit(1); - } - logSuccess('Environment variables loaded'); - - // Build target list - let targets: Array<{ zip: string; city: string; reason: string }>; - - if (args.zips) { - // Custom ZIP list - targets = args.zips.map(zip => ({ - zip, - city: 'Custom', - reason: 'Manual warm', - })); - logInfo(`Custom ZIPs: ${args.zips.join(', ')}`); - } else if (args.tier === 3) { - // Full blast: all 396 ZIPs - const { TOP_ZIP_CODES } = await import('../lib/utils'); - targets = TOP_ZIP_CODES.map(zip => ({ zip, city: 'Metro', reason: 'Tier 3 — Full Coverage' })); - logInfo(`Tier 3: ${targets.length} ZIPs (FULL BLAST)`); - } else if (args.tier === 2) { - targets = [...TIER_1_SUPER_BOWL, ...TIER_2_METROS]; - logInfo(`Tier 2: ${targets.length} ZIPs (SB host + NFL + Top 30 metros)`); - } else { - targets = [...TIER_1_SUPER_BOWL]; - logInfo(`Tier 1: ${targets.length} ZIPs (SB host + top party cities)`); - } - - if (args.dryRun) { - console.log(`\n${COLORS.bold}DRY RUN — would warm these targets:${COLORS.reset}\n`); - for (const t of targets) { - console.log(` ${t.zip} ${t.city.padEnd(25)} ${COLORS.dim}${t.reason}${COLORS.reset}`); - } - console.log(`\n Total jobs: ${targets.length} ZIPs × ${args.flavors.length} flavors = ${targets.length * args.flavors.length} scrape runs`); - console.log(` Est. time: ~${Math.ceil((targets.length * args.flavors.length * 90) / args.concurrency / 60)} minutes (at ~90s/scrape avg)\n`); - return; - } - - // Execute (with optional loop) - do { - const runStart = Date.now(); - banner(`Run starting at ${new Date().toLocaleTimeString()}`); - - const results = await processBatch(targets, args.flavors, args.concurrency, args.skipCached); - printReport(results, Date.now() - runStart); - - if (args.loop) { - const waitMs = args.intervalMinutes * 60 * 1000; - logInfo(`Next run in ${args.intervalMinutes} minutes. Press Ctrl+C to stop.`); - await sleep(waitMs); - } - } while (args.loop); -} - -// Run -main().catch(err => { - logError(`Fatal: ${err.message || err}`); - process.exit(1); -}); diff --git a/wing-command/supabase/schema.sql b/wing-command/supabase/schema.sql deleted file mode 100644 index 4fe2775d2..000000000 --- a/wing-command/supabase/schema.sql +++ /dev/null @@ -1,152 +0,0 @@ --- =========================================== --- Wing Scout v2 — Supabase Schema --- Super Bowl LX War Room Edition --- =========================================== - --- Enable PostGIS extension (if not already enabled) -CREATE EXTENSION IF NOT EXISTS postgis; - --- =========================================== --- Wing Spots Table (v2 with flavor_tags + menu_json) --- =========================================== -CREATE TABLE IF NOT EXISTS wing_spots ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - address TEXT NOT NULL DEFAULT '', - lat DOUBLE PRECISION NOT NULL, - lng DOUBLE PRECISION NOT NULL, - location GEOGRAPHY(POINT, 4326), -- PostGIS spatial column - - -- Pricing & Deals - price_per_wing DECIMAL(10, 2), - deal_text TEXT, - - -- Timing - delivery_time_mins INTEGER, - wait_time_mins INTEGER, - - -- Availability - is_in_stock BOOLEAN NOT NULL DEFAULT true, - is_open_now BOOLEAN NOT NULL DEFAULT true, - opens_during_game BOOLEAN NOT NULL DEFAULT true, - hours_today TEXT, - - -- Contact - phone TEXT, - image_url TEXT, - - -- Source & Status - source TEXT NOT NULL DEFAULT 'google', - status TEXT NOT NULL DEFAULT 'yellow', - zip_code TEXT NOT NULL, - - -- Platform IDs (for menu fetching) - platform_ids JSONB DEFAULT '{}', - - -- v2: Flavor & Menu - flavor_tags TEXT[] DEFAULT '{}', - flavor_match INTEGER, - menu_json JSONB DEFAULT '[]', - - -- Timestamps - last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Indexes for wing_spots -CREATE INDEX IF NOT EXISTS idx_wing_spots_zip ON wing_spots(zip_code); -CREATE INDEX IF NOT EXISTS idx_wing_spots_status ON wing_spots(status); -CREATE INDEX IF NOT EXISTS idx_wing_spots_last_updated ON wing_spots(last_updated); -CREATE INDEX IF NOT EXISTS idx_wing_spots_location ON wing_spots USING GIST(location); -CREATE INDEX IF NOT EXISTS idx_wing_spots_flavor_tags ON wing_spots USING GIN(flavor_tags); - --- Auto-compute PostGIS location from lat/lng -CREATE OR REPLACE FUNCTION update_wing_spot_location() -RETURNS TRIGGER AS $$ -BEGIN - NEW.location := ST_SetSRID(ST_MakePoint(NEW.lng, NEW.lat), 4326)::GEOGRAPHY; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE TRIGGER trigger_update_wing_spot_location - BEFORE INSERT OR UPDATE OF lat, lng ON wing_spots - FOR EACH ROW - EXECUTE FUNCTION update_wing_spot_location(); - --- =========================================== --- Geocode Cache --- =========================================== -CREATE TABLE IF NOT EXISTS geocode_cache ( - zip_code TEXT PRIMARY KEY, - city TEXT NOT NULL DEFAULT 'Unknown', - state TEXT NOT NULL DEFAULT 'Unknown', - lat DOUBLE PRECISION NOT NULL, - lng DOUBLE PRECISION NOT NULL, - cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- =========================================== --- Scrape Queue (for background jobs / cron) --- =========================================== -CREATE TABLE IF NOT EXISTS scrape_queue ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - zip_code TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - started_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ, - error TEXT -); - -CREATE INDEX IF NOT EXISTS idx_scrape_queue_status ON scrape_queue(status); - --- =========================================== --- Menus Table --- =========================================== -CREATE TABLE IF NOT EXISTS menus ( - spot_id TEXT PRIMARY KEY REFERENCES wing_spots(id) ON DELETE CASCADE, - sections JSONB NOT NULL DEFAULT '[]', - source TEXT NOT NULL DEFAULT 'tinyfish_scrape', - has_wings BOOLEAN NOT NULL DEFAULT false, - wing_section_index INTEGER, - fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- =========================================== --- Row Level Security (RLS) --- =========================================== - --- Enable RLS -ALTER TABLE wing_spots ENABLE ROW LEVEL SECURITY; -ALTER TABLE geocode_cache ENABLE ROW LEVEL SECURITY; -ALTER TABLE scrape_queue ENABLE ROW LEVEL SECURITY; -ALTER TABLE menus ENABLE ROW LEVEL SECURITY; - --- Public read access for wing_spots -CREATE POLICY "Public read wing_spots" ON wing_spots - FOR SELECT USING (true); - --- Service role full access for wing_spots -CREATE POLICY "Service write wing_spots" ON wing_spots - FOR ALL USING (true) WITH CHECK (true); - --- Public read access for geocode_cache -CREATE POLICY "Public read geocode_cache" ON geocode_cache - FOR SELECT USING (true); - --- Service role full access for geocode_cache -CREATE POLICY "Service write geocode_cache" ON geocode_cache - FOR ALL USING (true) WITH CHECK (true); - --- Service role access for scrape_queue -CREATE POLICY "Service access scrape_queue" ON scrape_queue - FOR ALL USING (true) WITH CHECK (true); - --- Public read access for menus -CREATE POLICY "Public read menus" ON menus - FOR SELECT USING (true); - --- Service role full access for menus -CREATE POLICY "Service write menus" ON menus - FOR ALL USING (true) WITH CHECK (true); From d3961054650c0ff0b6df81de06fd57136b578cf2 Mon Sep 17 00:00:00 2001 From: Krishna Agarwal Date: Sat, 18 Apr 2026 21:19:33 +0800 Subject: [PATCH 2/5] updates comments --- wing-command/.env.example | 2 +- wing-command/.gitignore | 3 +- wing-command/README.md | 296 ++++++++++++-------------------------- wing-command/package.json | 1 + 4 files changed, 91 insertions(+), 211 deletions(-) diff --git a/wing-command/.env.example b/wing-command/.env.example index f2a8d757d..7781a10ae 100644 --- a/wing-command/.env.example +++ b/wing-command/.env.example @@ -3,5 +3,5 @@ # Groq (required) — https://console.groq.com GROQ_API_KEY=your-groq-api-key -# TinyFish (required) — https://docs.tinyfish.ai +# TinyFish (required) — https://tinyfish.ai TINYFISH_API_KEY=your-tinyfish-api-key diff --git a/wing-command/.gitignore b/wing-command/.gitignore index b0dafd1ca..9da94bdfd 100644 --- a/wing-command/.gitignore +++ b/wing-command/.gitignore @@ -23,8 +23,7 @@ yarn-debug.log* yarn-error.log* # Local env files -.env*.local -.env +.env.local # Vercel .vercel diff --git a/wing-command/README.md b/wing-command/README.md index c52727b1f..a00a240e6 100644 --- a/wing-command/README.md +++ b/wing-command/README.md @@ -1,231 +1,111 @@ -# Wing Scout - Super Bowl LX War Room -**Live Demo: https://wings-command.up.railway.app/** +# Scholarship Finder -**Flavor-first, mesmerizing, hyper-local chicken wing tracker for Super Bowl LX (Feb 8, 2026).** +A Next.js app that finds real scholarships in real time. Enter a scholarship type, university, and region — Groq discovers relevant sources, then parallel TinyFish browser agents scrape each one using `@tiny-fish/sdk` and stream results back as they arrive. -A "War Room" interface that scouts the best chicken wings near you in real-time using AI-powered parallel scraping from DoorDash, Uber Eats, Grubhub, and Google. +## Demo -## Visual Identity +Each agent card shows live progress as it scrapes. Results appear once all agents complete, with equal-height cards and a side-by-side comparison tool. -- **Theme:** "Midnight Turf" - Dark #050505 background with subtle grass texture grid -- **Accents:** Neon Green (#39FF14) glows, stadium lighting effects -- **Typography:** Bebas Neue (scoreboard style) + Inter (body) -- **Cards:** Glassmorphism "Scout Cards" with backdrop blur -- **Animations:** Framer Motion parallax, floating particles, "tackle-in" card entrances - -## Architecture +## How It Works ``` -Frontend (Next.js 14 App Router) - |-- page.tsx -> War Room hero + flavor selector + results grid - |-- HeroVisuals.tsx -> Parallax field lines + floating wing/confetti particles - |-- FlavorSelector.tsx -> 3 flavor personas with pulsing selection - |-- ZipSearch.tsx -> Stadium-light illuminated zip input - |-- WingGrid.tsx -> Glassmorphism Scout Cards grid - -Backend (API Route) - |-- /api/scout -> Main endpoint (zip + flavor) - |-- lib/tinyfish-scraper.ts -> TinyFish parallel scraping engine - |-- lib/geocode.ts -> Nominatim (OpenStreetMap) geocoding - |-- lib/cache.ts -> Upstash Redis caching layer - -Data - |-- Supabase (PostgreSQL + PostGIS) - |-- Upstash Redis (15-min TTL cache) +User submits search + ↓ +/api/search — Groq (llama-3.3-70b) discovers 5-8 relevant scholarship URLs + ↓ +Parallel TinyFish browser agents (via @tiny-fish/sdk) scrape each URL simultaneously + ↓ +Results appear once all agents complete + ↓ +User can select scholarships and compare side by side ``` -## Flavor Personas - -Users pick a team/flavor before searching: - -| Persona | Keywords | Emoji | -|---------|----------|-------| -| **The Face-Melter** | Habanero, Ghost Pepper, Carolina Reaper, Atomic | 🔥 | -| **The Classicist** | Buffalo, Hot, Mild, Traditional, Cayenne | 🦬 | -| **The Sticky Finger** | Honey BBQ, Garlic Parm, Teriyaki, Korean | 🍯 | - -Spots are scored 0-100 against the selected persona. - -## Scraping Flow - -1. User enters ZIP + selects Flavor Persona -2. **Geocode** via Nominatim (free, no API key) -3. **Parallel scrape** (`Promise.allSettled`): - - DoorDash search results - - Uber Eats search results - - Grubhub search results - - Google search (hidden gem detection) -4. **Deduplicate** by normalized name + address -5. **Score** against flavor persona keywords -6. **Cache** in Redis (15-min TTL) + persist to Supabase - -## Setup - -### Prerequisites - -- Node.js >= 18 -- Supabase project (free tier works) -- TinyFish API key -- Upstash Redis (optional but recommended) - -### Environment Variables - -Create `.env.local`: - -```env -# Required - Server -SUPABASE_SERVICE_ROLE_KEY=your_service_role_key -TINYFISH_API_KEY=your-tinyfish-api-key - -# Required - Client -NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co -NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key - -# Optional - Caching (highly recommended) -UPSTASH_REDIS_REST_URL=https://your-redis.upstash.io -UPSTASH_REDIS_REST_TOKEN=your_redis_token +## Architecture -# Optional - Custom TinyFish endpoint -TINYFISH_API_URL=https://agent.tinyfish.ai/v1/automation/run ``` - -### Database Setup - -Run the schema in your Supabase SQL editor: - -```bash -# The schema file is at: -supabase/schema.sql +┌─────────────────────────────────────────────────────┐ +│ Browser (UI) │ +│ │ +│ SearchForm → useScholarshipSearch hook │ +│ │ │ +│ │ POST /api/search │ +│ ▼ │ +│ LoadingAnimation ←── SSE stream │ +│ SearchResults ←── (on complete) │ +└─────────────────────────────────────────────────────┘ + │ + ┌──────────▼──────────┐ + │ /api/search │ + │ route.ts │ + │ │ + │ STEP 1 │ + │ Groq llama-3.3-70b │ + │ discovers 5-8 │ + │ scholarship URLs │ + │ │ + │ STEP 2 │ + │ Promise.all() runs │ + │ one @tiny-fish/sdk │ + │ agent per URL │ + └──────────┬──────────┘ + │ + ┌──────────────┼──────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Agent 1 │ │ Agent 2 │ │ Agent N │ + │ fastweb │ │ niche │ │ ... │ + │ .com │ │ .com │ │ │ + └────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ + └──────────────┼──────────────┘ + ▼ + { scholarships: [...] } + streamed back to UI ``` -This creates: -- `wing_spots` table with `flavor_tags` (TEXT[]), `menu_json` (JSONB), and PostGIS spatial indexing -- `geocode_cache` table (permanent zip-to-lat/lng mapping) -- `scrape_queue` table (for background cron jobs) -- `menus` table (cached restaurant menus) -- PostGIS trigger for auto-computing `location` from `lat`/`lng` -- Row Level Security policies +## Code Snippet -### Install & Run +```typescript +import { TinyFish, EventType, RunStatus } from "@tiny-fish/sdk"; -```bash -npm install -npm run dev -``` +const client = new TinyFish({ apiKey: process.env.TINYFISH_API_KEY }); -Open http://localhost:3000 - -## Render.com Deployment (Migration from Vercel) - -### Why Render? - -Vercel serverless functions have a **60-second timeout** (300s on Hobby with Fluid Compute). Parallel scraping across 4 platforms can exceed this. Render Web Services have **no timeout limit**. - -### Render Setup - -1. **Create a Web Service** on Render - - Connect your GitHub repository - - **Build Command:** `npm install && npm run build` - - **Start Command:** `npm start` - - **Environment:** Node - - **Plan:** Starter ($7/mo) or Free (with limitations) - -2. **Environment Variables** - - Add all env vars from the `.env.local` section above - - Set `NODE_ENV=production` - -3. **Render Config (`render.yaml`)** - -```yaml -services: - - type: web - name: wing-scout - runtime: node - buildCommand: npm install && npm run build - startCommand: npm start - envVars: - - key: NODE_ENV - value: production - - key: NEXT_PUBLIC_SUPABASE_URL - sync: false - - key: NEXT_PUBLIC_SUPABASE_ANON_KEY - sync: false - - key: SUPABASE_SERVICE_ROLE_KEY - sync: false - - key: TINYFISH_API_KEY - sync: false - - key: UPSTASH_REDIS_REST_URL - sync: false - - key: UPSTASH_REDIS_REST_TOKEN - sync: false +await Promise.all( + scholarshipUrls.map(async (site, index) => { + const agentStream = await client.agent.stream({ url: site.url, goal }); + for await (const event of agentStream) { + if (event.type === EventType.COMPLETE) { + send({ type: "AGENT_COMPLETE", scholarships: event.result?.scholarships }); + return; // always exit after COMPLETE + } + } + }) +); ``` -4. **Next.js Standalone Output** - - `next.config.mjs` includes `output: 'standalone'` which is required for Render deployment +## Running Locally -### Cron Job (Optional) +1. **Install dependencies** + ```bash + npm install + ``` -Set up a Render Cron Job to pre-populate the database: +2. **Set up environment** + ```bash + cp .env.example .env.local + # Add your API keys to .env.local + ``` -- **Schedule:** `0 */4 * * *` (every 4 hours) -- **Command:** `python scraper/scrape_wings.py` -- This pre-scrapes 150+ major US zip codes +3. **Run the dev server** + ```bash + npm run dev + ``` -## Project Structure +4. Open [http://localhost:3000](http://localhost:3000) -``` -wing-scout/ -├── app/ -│ ├── layout.tsx # Root layout (Bebas Neue + Inter fonts) -│ ├── page.tsx # War Room main page -│ ├── globals.css # Midnight Turf theme styles -│ ├── loading.tsx # Loading state -│ ├── error.tsx # Error boundary -│ └── api/ -│ ├── scout/route.ts # GET /api/scout?zip=xxxxx&flavor=classicist -│ └── menu/route.ts # GET /api/menu?spot_id=xxxxx -├── components/ -│ ├── HeroVisuals.tsx # Parallax + floating particles (Framer Motion) -│ ├── FlavorSelector.tsx # 3 flavor persona cards with pulse animation -│ ├── ZipSearch.tsx # Stadium-lit glowing zip input -│ ├── WingGrid.tsx # Scout Cards grid (glassmorphism) -│ └── ui/ # Reusable UI primitives -├── lib/ -│ ├── tinyfish-scraper.ts # TinyFish scraper (parallel, flavor-aware) -│ ├── types.ts # TypeScript definitions -│ ├── utils.ts # Flavor scoring, dedup, formatting -│ ├── supabase.ts # Database client -│ ├── cache.ts # Upstash Redis caching -│ ├── geocode.ts # Nominatim geocoding -│ ├── env.ts # Environment validation -│ └── menu.ts # Menu fetching -├── supabase/ -│ └── schema.sql # Full database schema -├── scraper/ -│ └── scrape_wings.py # Python cron pre-scraper -├── tailwind.config.ts # Midnight Turf theme -├── next.config.mjs # Standalone output for Render -└── package.json # framer-motion, lucide-react, etc. -``` +## Environment Variables -## Constraint Checklist - -| Constraint | Status | -|-----------|--------| -| Google Places API used? | NO (OSM/Nominatim) | -| Yelp API used? | NO | -| Vercel deployment? | NO (Render.com) | -| UI "Mesmerizing"? | YES (Framer Motion, glassmorphism, neon glow) | -| Menu scraping parallel? | YES (`Promise.allSettled` across 4 sources) | -| Flavor persona filtering? | YES (keyword matching, 0-100 scoring) | - -## Tech Stack - -- **Framework:** Next.js 14 (App Router), TypeScript, Tailwind CSS -- **Animations:** Framer Motion -- **Icons:** Lucide React -- **Database:** Supabase (PostgreSQL + PostGIS) -- **Cache:** Upstash Redis -- **Scraper:** TinyFish -- **Geocoding:** Nominatim (OpenStreetMap) -- **Deployment:** Render.com (Web Service) +| Variable | Description | +|----------|-------------| +| `TINYFISH_API_KEY` | Browser agents that scrape scholarship pages. Get yours at [agent.tinyfish.ai/api-keys](https://agent.tinyfish.ai/api-keys) | +| `GROQ_API_KEY` | LLM-powered URL discovery via llama-3.3-70b. Get yours at [console.groq.com/keys](https://console.groq.com/keys) | diff --git a/wing-command/package.json b/wing-command/package.json index ecbfb2cb5..613efa042 100644 --- a/wing-command/package.json +++ b/wing-command/package.json @@ -29,6 +29,7 @@ "tailwindcss": "^3.4.4", "zod": "^3.23.8" }, + "engines": { "node": "22.x" }, "devDependencies": { "@types/canvas-confetti": "^1.6.4", "@types/node": "^20.14.10", From 6b3d769d4c996ff3011b501141832b2710eca675 Mon Sep 17 00:00:00 2001 From: KrishnaAgarwal7531 <132636117+KrishnaAgarwal7531@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:26:26 +0800 Subject: [PATCH 3/5] Update README.md --- wing-command/README.md | 265 +++++++++++++++++++++++++++-------------- 1 file changed, 177 insertions(+), 88 deletions(-) diff --git a/wing-command/README.md b/wing-command/README.md index a00a240e6..5c155b0ec 100644 --- a/wing-command/README.md +++ b/wing-command/README.md @@ -1,111 +1,200 @@ -# Scholarship Finder +# Wing Command +**Live Demo: https://wings-command.up.railway.app/** -A Next.js app that finds real scholarships in real time. Enter a scholarship type, university, and region — Groq discovers relevant sources, then parallel TinyFish browser agents scrape each one using `@tiny-fish/sdk` and stream results back as they arrive. +**Flavor-first, hyper-local chicken wing tracker powered by TinyFish** -## Demo +A "War Room" interface that scouts the best chicken wings near you in real-time. Pick a flavor persona, enter your zip, and parallel AI browser agents hit DoorDash, Uber Eats, Grubhub, and Google simultaneously — streaming results back as each source completes. -Each agent card shows live progress as it scrapes. Results appear once all agents complete, with equal-height cards and a side-by-side comparison tool. +## Visual Identity -## How It Works +- **Theme:** "Midnight Turf" — dark `#050505` background with subtle grass texture grid +- **Accents:** Neon Green (`#39FF14`) glows, stadium lighting effects +- **Typography:** Bebas Neue (scoreboard) + Inter (body) +- **Cards:** Glassmorphism Scout Cards with backdrop blur +- **Animations:** Framer Motion parallax, floating particles, tackle-in card entrances + +## Architecture ``` -User submits search - ↓ -/api/search — Groq (llama-3.3-70b) discovers 5-8 relevant scholarship URLs - ↓ -Parallel TinyFish browser agents (via @tiny-fish/sdk) scrape each URL simultaneously - ↓ -Results appear once all agents complete - ↓ -User can select scholarships and compare side by side +┌─────────────────────────────────────────────────────────┐ +│ Browser (Client) │ +│ │ +│ FlavorSelector → ZipSearch → WingGrid (streams in) │ +└──────────────────────────┬──────────────────────────────┘ + │ GET /api/scout?zip=&flavor= + │ (SSE — results stream as agents finish) +┌──────────────────────────▼──────────────────────────────┐ +│ Next.js App Router │ +│ │ +│ /api/scout/route.ts │ +│ │ │ +│ ├─ 1. geocode.ts ──► Nominatim (free, no key) │ +│ │ │ +│ ├─ 2. Groq LLM ────► generates 5–7 source URLs │ +│ │ llama-3.3-70b-versatile │ +│ │ │ +│ └─ 3. TinyFish SDK ─► Promise.allSettled │ +│ client.agent.stream({ url, goal }) │ +│ │ │ +│ ├── Agent → DoorDash city wings page │ +│ ├── Agent → Uber Eats city wings page │ +│ ├── Agent → Grubhub city wings page │ +│ └── Agent → Google search results │ +│ │ +│ Each agent fires EventType.COMPLETE │ +│ → spots sent to client immediately via SSE │ +└─────────────────────────────────────────────────────────┘ + +No database. No cache. Pure in-memory — results live only for the request. ``` -## Architecture +### TinyFish SDK event flow ``` -┌─────────────────────────────────────────────────────┐ -│ Browser (UI) │ -│ │ -│ SearchForm → useScholarshipSearch hook │ -│ │ │ -│ │ POST /api/search │ -│ ▼ │ -│ LoadingAnimation ←── SSE stream │ -│ SearchResults ←── (on complete) │ -└─────────────────────────────────────────────────────┘ - │ - ┌──────────▼──────────┐ - │ /api/search │ - │ route.ts │ - │ │ - │ STEP 1 │ - │ Groq llama-3.3-70b │ - │ discovers 5-8 │ - │ scholarship URLs │ - │ │ - │ STEP 2 │ - │ Promise.all() runs │ - │ one @tiny-fish/sdk │ - │ agent per URL │ - └──────────┬──────────┘ - │ - ┌──────────────┼──────────────┐ - ▼ ▼ ▼ - ┌──────────┐ ┌──────────┐ ┌──────────┐ - │ Agent 1 │ │ Agent 2 │ │ Agent N │ - │ fastweb │ │ niche │ │ ... │ - │ .com │ │ .com │ │ │ - └────┬─────┘ └────┬─────┘ └────┬─────┘ - │ │ │ - └──────────────┼──────────────┘ - ▼ - { scholarships: [...] } - streamed back to UI +client.agent.stream({ url, goal }) + │ + ├── EventType.STARTED → agent confirmed running + ├── EventType.PROGRESS → live step updates (shown in UI) + └── EventType.COMPLETE + └── RunStatus.COMPLETED → event.result → parse restaurants → SSE → client +``` + +## Flavor Personas + +Users pick a team/flavor before searching: + +| Persona | Keywords | Emoji | +|---------|----------|-------| +| **The Face-Melter** | Habanero, Ghost Pepper, Carolina Reaper, Atomic | 🔥 | +| **The Classicist** | Buffalo, Hot, Mild, Traditional, Cayenne | 🦬 | +| **The Sticky Finger** | Honey BBQ, Garlic Parm, Teriyaki, Korean | 🍯 | + +Spots are scored 0–100 against the selected persona. + +## Scraping Flow + +1. User enters ZIP + selects Flavor Persona +2. **Geocode** via Nominatim (free, no API key) +3. **Groq** (`llama-3.3-70b-versatile`) generates 5–7 real search URLs for the location +4. **Parallel scrape** (`Promise.allSettled`) — one TinyFish browser agent per URL: + - DoorDash city wings page + - Uber Eats city wings page + - Grubhub city wings page + - Google search results +5. **Deduplicate** by normalized name + address +6. **Score** against flavor persona keywords +7. Results stream back to the client as each agent completes + +## Setup + +### Prerequisites + +- Node.js 22.x +- TinyFish API key +- Groq API key + +### Environment Variables + +```bash +cp .env.example .env.local ``` -## Code Snippet +Then fill in: -```typescript -import { TinyFish, EventType, RunStatus } from "@tiny-fish/sdk"; +```env +# TinyFish (required) — https://agent.tinyfish.ai/api-keys +TINYFISH_API_KEY=your-tinyfish-api-key -const client = new TinyFish({ apiKey: process.env.TINYFISH_API_KEY }); +# Groq (required) — https://console.groq.com +GROQ_API_KEY=your-groq-api-key +``` + +### Install & Run -await Promise.all( - scholarshipUrls.map(async (site, index) => { - const agentStream = await client.agent.stream({ url: site.url, goal }); - for await (const event of agentStream) { - if (event.type === EventType.COMPLETE) { - send({ type: "AGENT_COMPLETE", scholarships: event.result?.scholarships }); - return; // always exit after COMPLETE - } - } - }) -); +```bash +npm install +npm run dev ``` -## Running Locally +Open http://localhost:3000 + +## Deployment -1. **Install dependencies** - ```bash - npm install - ``` +The app is deployed on Railway. Any Node-compatible host works — no external services to provision. -2. **Set up environment** - ```bash - cp .env.example .env.local - # Add your API keys to .env.local - ``` +### Render.com (alternative) -3. **Run the dev server** - ```bash - npm run dev - ``` +Vercel serverless functions have a 60-second timeout. Parallel scraping across 4 platforms can exceed this. Render Web Services have no timeout limit. -4. Open [http://localhost:3000](http://localhost:3000) +1. Create a Web Service — connect your GitHub repo + - **Build Command:** `npm install && npm run build` + - **Start Command:** `npm start` +2. Add environment variables: -## Environment Variables +```yaml +services: + - type: web + name: wing-command + runtime: node + buildCommand: npm install && npm run build + startCommand: npm start + envVars: + - key: NODE_ENV + value: production + - key: TINYFISH_API_KEY + sync: false + - key: GROQ_API_KEY + sync: false +``` + +`next.config.mjs` already includes `output: 'standalone'` for Render compatibility. + +## Project Structure + +``` +wing-command/ +├── app/ +│ ├── layout.tsx # Root layout (Bebas Neue + Inter fonts) +│ ├── page.tsx # War Room main page +│ ├── globals.css # Midnight Turf theme +│ ├── loading.tsx # Loading state +│ ├── error.tsx # Error boundary +│ └── api/ +│ ├── scout/route.ts # GET /api/scout?zip=xxxxx&flavor=classicist +│ └── discover/route.ts # URL discovery via Groq +├── components/ +│ ├── HeroVisuals.tsx # Parallax + floating particles (Framer Motion) +│ ├── FlavorSelector.tsx # 3 flavor persona cards with pulse animation +│ ├── ZipSearch.tsx # Stadium-lit glowing zip input +│ ├── WingGrid.tsx # Scout Cards grid (glassmorphism) +│ └── ui/ # Reusable UI primitives +├── lib/ +│ ├── types.ts # TypeScript definitions (pure in-memory, no DB) +│ ├── utils.ts # Flavor scoring, dedup, formatting +│ ├── geocode.ts # Nominatim geocoding (no API key needed) +│ └── env.ts # Environment validation +├── tailwind.config.ts # Midnight Turf theme +├── next.config.mjs # Standalone output +└── package.json +``` -| Variable | Description | -|----------|-------------| -| `TINYFISH_API_KEY` | Browser agents that scrape scholarship pages. Get yours at [agent.tinyfish.ai/api-keys](https://agent.tinyfish.ai/api-keys) | -| `GROQ_API_KEY` | LLM-powered URL discovery via llama-3.3-70b. Get yours at [console.groq.com/keys](https://console.groq.com/keys) | +## Constraint Checklist + +| Constraint | Status | +|-----------|--------| +| Google Places API used? | NO (OSM/Nominatim) | +| Yelp API used? | NO | +| External database or cache used? | NO (pure in-memory) | +| UI "Mesmerizing"? | YES (Framer Motion, glassmorphism, neon glow) | +| Scraping parallel? | YES (`Promise.allSettled` across 4–7 sources) | +| Flavor persona filtering? | YES (keyword matching, 0–100 scoring) | + +## Tech Stack + +- **Framework:** Next.js 14 (App Router), TypeScript, Tailwind CSS +- **Animations:** Framer Motion +- **Icons:** Lucide React +- **AI / LLM:** Groq (`llama-3.3-70b-versatile`) for URL discovery +- **Browser Agents:** TinyFish SDK (`client.agent.stream`) +- **Geocoding:** Nominatim (OpenStreetMap) — no API key required +- **Deployment:** Railway / Render.com From c4399d4fdaa8363f7f0be72cd67bf1f941ae43ae Mon Sep 17 00:00:00 2001 From: KrishnaAgarwal7531 <132636117+KrishnaAgarwal7531@users.noreply.github.com> Date: Mon, 20 Apr 2026 01:31:23 +0800 Subject: [PATCH 4/5] Update .env.example --- wing-command/.env.example | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/wing-command/.env.example b/wing-command/.env.example index 7781a10ae..2e1bce7f7 100644 --- a/wing-command/.env.example +++ b/wing-command/.env.example @@ -1,7 +1,7 @@ -# Wing Command v4 — Environment Variables +# TinyFish Web Agent API key +# Get yours at: https://agent.tinyfish.ai/api-keys +TINYFISH_API_KEY="your_api_key_here" -# Groq (required) — https://console.groq.com -GROQ_API_KEY=your-groq-api-key - -# TinyFish (required) — https://tinyfish.ai -TINYFISH_API_KEY=your-tinyfish-api-key +# Groq API key +# Get yours at: https://console.groq.com +GROQ_API_KEY="your_api_key_here" From 594fdee6cbc6cab98be314407acf11302454e335 Mon Sep 17 00:00:00 2001 From: KrishnaAgarwal7531 <132636117+KrishnaAgarwal7531@users.noreply.github.com> Date: Mon, 20 Apr 2026 01:32:11 +0800 Subject: [PATCH 5/5] Update .gitignore --- wing-command/.gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/wing-command/.gitignore b/wing-command/.gitignore index 9da94bdfd..6dc35e82e 100644 --- a/wing-command/.gitignore +++ b/wing-command/.gitignore @@ -23,7 +23,9 @@ yarn-debug.log* yarn-error.log* # Local env files +.env .env.local +.env.*.local # Vercel .vercel