diff --git a/api/dist/routes/products.js b/api/dist/routes/products.js index b7f9b4946..5bd62d2b4 100644 --- a/api/dist/routes/products.js +++ b/api/dist/routes/products.js @@ -192,7 +192,24 @@ router.get('/search', agentDetect_1.agentDetectMiddleware, apiKey_1.requireApiKe FROM products LEFT JOIN affiliate_links al ON al.product_id = products.id::text AND al.merchant_id = products.merchant_id ${whereClause} - ORDER BY ts_rank(search_vector, plainto_tsquery('english', $${ftsParamIdx})) DESC, updated_at DESC + ORDER BY ts_rank(search_vector, plainto_tsquery('english', $${ftsParamIdx})) * CASE + WHEN lower(title) LIKE '%laptop%' + AND lower(title) NOT LIKE '%sleeve%' + AND lower(title) NOT LIKE '%case%' + AND lower(title) NOT LIKE '%bag%' + AND lower(title) NOT LIKE '%stand%' + AND lower(title) NOT LIKE '%pad%' + AND lower(title) NOT LIKE '%cooler%' + AND lower(title) NOT LIKE '%adapter%' + AND lower(title) NOT LIKE '%dock%' + AND lower(title) NOT LIKE '%hub%' + AND lower(title) NOT LIKE '%lock%' + AND lower(title) NOT LIKE '%briefcase%' + AND lower(title) NOT LIKE '%charger%' + AND lower(title) NOT LIKE '%table%' + THEN 2.0 + ELSE 1.0 + END DESC, updated_at DESC LIMIT $${idx} OFFSET $${idx + 1} `; } @@ -213,7 +230,24 @@ router.get('/search', agentDetect_1.agentDetectMiddleware, apiKey_1.requireApiKe ${whereClause} LIMIT ${CANDIDATE_LIMIT} ) _candidates - ORDER BY rank DESC + ORDER BY rank * CASE + WHEN lower(title) LIKE '%laptop%' + AND lower(title) NOT LIKE '%sleeve%' + AND lower(title) NOT LIKE '%case%' + AND lower(title) NOT LIKE '%bag%' + AND lower(title) NOT LIKE '%stand%' + AND lower(title) NOT LIKE '%pad%' + AND lower(title) NOT LIKE '%cooler%' + AND lower(title) NOT LIKE '%adapter%' + AND lower(title) NOT LIKE '%dock%' + AND lower(title) NOT LIKE '%hub%' + AND lower(title) NOT LIKE '%lock%' + AND lower(title) NOT LIKE '%briefcase%' + AND lower(title) NOT LIKE '%charger%' + AND lower(title) NOT LIKE '%table%' + THEN 2.0 + ELSE 1.0 + END DESC LIMIT $${idx} OFFSET $${idx + 1} `; } diff --git a/api/src/jobs/priceRefresh.ts b/api/src/jobs/priceRefresh.ts index dc4d51402..9b6d8fba8 100644 --- a/api/src/jobs/priceRefresh.ts +++ b/api/src/jobs/priceRefresh.ts @@ -125,7 +125,7 @@ export async function runPriceRefresh(): Promise { } } - // Update captured_at for each product regardless of scraper result + // Update retailer_prices.captured_at + products.updated_at for each product (BUY-59843) for (const { product_id, slug } of items) { try { await db.query( @@ -134,6 +134,12 @@ export async function runPriceRefresh(): Promise { WHERE product_id = $1`, [product_id] ); + await db.query( + `UPDATE products + SET updated_at = NOW() + WHERE id = $1::bigint`, + [product_id] + ); results.push({ product_id, platform, @@ -143,7 +149,7 @@ export async function runPriceRefresh(): Promise { error: scraperError && SCRAPER_URL ? scraperError : undefined, scraper_triggered, }); - console.log(`[price-refresh] ✓ captured_at updated for ${slug} (${product_id})`); + console.log(`[price-refresh] ✓ retailer_prices.captured_at + products.updated_at set for ${slug} (${product_id})`); } catch (err) { const error = err instanceof Error ? err.message : String(err); results.push({ product_id, platform, sku: product_id, slug, success: false, error, scraper_triggered }); diff --git a/api/src/routes/products.ts b/api/src/routes/products.ts index 14a930c8b..8a6d4062d 100644 --- a/api/src/routes/products.ts +++ b/api/src/routes/products.ts @@ -204,7 +204,24 @@ router.get( FROM products LEFT JOIN affiliate_links al ON al.product_id = products.id::text AND al.merchant_id = products.merchant_id ${whereClause} - ORDER BY ts_rank(search_vector, plainto_tsquery('english', $${ftsParamIdx})) DESC, updated_at DESC + ORDER BY ts_rank(search_vector, plainto_tsquery('english', $${ftsParamIdx})) * CASE + WHEN lower(title) LIKE '%laptop%' + AND lower(title) NOT LIKE '%sleeve%' + AND lower(title) NOT LIKE '%case%' + AND lower(title) NOT LIKE '%bag%' + AND lower(title) NOT LIKE '%stand%' + AND lower(title) NOT LIKE '%pad%' + AND lower(title) NOT LIKE '%cooler%' + AND lower(title) NOT LIKE '%adapter%' + AND lower(title) NOT LIKE '%dock%' + AND lower(title) NOT LIKE '%hub%' + AND lower(title) NOT LIKE '%lock%' + AND lower(title) NOT LIKE '%briefcase%' + AND lower(title) NOT LIKE '%charger%' + AND lower(title) NOT LIKE '%table%' + THEN 2.0 + ELSE 1.0 + END DESC, updated_at DESC LIMIT $${idx} OFFSET $${idx + 1} `; } else if (useFtsRanking) { @@ -224,7 +241,24 @@ router.get( ${whereClause} LIMIT ${CANDIDATE_LIMIT} ) _candidates - ORDER BY rank DESC + ORDER BY rank * CASE + WHEN lower(title) LIKE '%laptop%' + AND lower(title) NOT LIKE '%sleeve%' + AND lower(title) NOT LIKE '%case%' + AND lower(title) NOT LIKE '%bag%' + AND lower(title) NOT LIKE '%stand%' + AND lower(title) NOT LIKE '%pad%' + AND lower(title) NOT LIKE '%cooler%' + AND lower(title) NOT LIKE '%adapter%' + AND lower(title) NOT LIKE '%dock%' + AND lower(title) NOT LIKE '%hub%' + AND lower(title) NOT LIKE '%lock%' + AND lower(title) NOT LIKE '%briefcase%' + AND lower(title) NOT LIKE '%charger%' + AND lower(title) NOT LIKE '%table%' + THEN 2.0 + ELSE 1.0 + END DESC LIMIT $${idx} OFFSET $${idx + 1} `; } else { diff --git a/buywhere-api-buy-22757/api/src/routes/redirect.ts b/buywhere-api-buy-22757/api/src/routes/redirect.ts new file mode 100644 index 000000000..172fa203a --- /dev/null +++ b/buywhere-api-buy-22757/api/src/routes/redirect.ts @@ -0,0 +1,181 @@ +import { Router, Request, Response } from 'express'; +import { createHash } from 'crypto'; +import { db } from '../config'; +import { trackAffiliateClick } from '../analytics/posthog'; + +function hashKey(rawKey: string): string { + return createHash('sha256').update(rawKey).digest('hex'); +} + +const router = Router(); + +// Awin affiliate programme (BUY-6873) +const awinPublisherId = process.env.AWIN_PUBLISHER_ID || ''; +const awinAdvertiserIds: Set = new Set( + (process.env.AWIN_ADVERTISER_IDS || '').split(',').map((id) => id.trim()).filter(Boolean) +); + +function buildAwinUrl(advertiserId: string, destination: string, clickRef: string): string { + const encoded = encodeURIComponent(destination); + return `https://www.awin1.com/cread.php?awinmid=${advertiserId}&awinaffid=${awinPublisherId}&clickref=${clickRef}&p=${encoded}`; +} + +const DEFAULT_ALLOWED_DOMAINS = [ + 'lazada.sg', + 'shopee.sg', + 'bestdenki.com.sg', + 'amazon.sg', + 'courts.com.sg', + 'harvey-norman.com.sg', + 'challenger.sg', + 'qoo10.sg', +]; + +const allowedDomains: Set = new Set( + (process.env.AFFILIATE_ALLOWED_DOMAINS + ? process.env.AFFILIATE_ALLOWED_DOMAINS.split(',').map((d) => d.trim()) + : DEFAULT_ALLOWED_DOMAINS + ).filter(Boolean) +); + +function isAllowedDestination(url: string): boolean { + try { + const { hostname } = new URL(url); + const bare = hostname.replace(/^www\./, ''); + return allowedDomains.has(bare); + } catch { + return false; + } +} + +// GET /r/direct/:merchantId/:productId +// Direct merchant redirect without allowlist restriction (for all merchants) +router.get('/direct/:merchantId/:productId', async (req: Request, res: Response) => { + const { merchantId, productId } = req.params; + + // Look up product directly by merchant_id and product_id + const productResult = await db.query( + `SELECT url, merchant_id FROM products WHERE id = $1 AND merchant_id = $2`, + [productId, merchantId] + ); + + if (productResult.rows.length === 0) { + res.status(404).json({ error: 'Product not found' }); + return; + } + + const destinationUrl = productResult.rows[0].url; + const actualMerchantId = productResult.rows[0].merchant_id || 'unknown'; + + if (!destinationUrl) { + res.status(404).json({ error: 'Product URL not found' }); + return; + } + + // Determine API key for attribution + const authHeader = req.headers['authorization'] || ''; + let apiKey: string | null = null; + if (authHeader.startsWith('Bearer ')) apiKey = authHeader.slice(7).trim(); + const source = req.query.source as string || 'direct_redirect'; + + // Log click to DB (before redirect) + await db.query( + `INSERT INTO affiliate_clicks + (api_key, affiliate_slug, product_id, merchant_id, affiliate_link_id, source, destination_url) + VALUES ($1,$2,$3,$4,$5,$6,$7)`, + [apiKey, 'direct', productId, actualMerchantId, '', source, destinationUrl] + ); + + // PostHog event (fire-and-forget) + trackAffiliateClick({ + apiKey: apiKey ? hashKey(apiKey) : null, + productId, + merchantId: actualMerchantId, + affiliateLinkId: '', + source, + }); + + res.redirect(302, destinationUrl); +}); + + +// GET /r/:affiliateSlug/:productId +// Log the affiliate click then redirect to destination +router.get('/:affiliateSlug/:productId', async (req: Request, res: Response) => { + const { affiliateSlug, productId } = req.params; + + // Look up affiliate link + const linkResult = await db.query( + `SELECT id, merchant_id, platform, destination_url + FROM affiliate_links WHERE platform = $1 AND product_id = $2`, + [affiliateSlug, productId] + ); + + let merchantId = 'unknown'; + let affiliateLinkId = ''; + let destinationUrl: string | null = null; + + if (linkResult.rows.length > 0) { + const link = linkResult.rows[0]; + merchantId = link.merchant_id || affiliateSlug; + affiliateLinkId = String(link.id); + destinationUrl = link.destination_url; + } else { + // Fallback: try direct product lookup + const productResult = await db.query( + `SELECT url, merchant_id FROM products WHERE id = $1`, + [productId] + ); + if (productResult.rows.length > 0) { + destinationUrl = productResult.rows[0].url; + merchantId = productResult.rows[0].merchant_id || 'unknown'; + } + } + + if (!destinationUrl) { + res.status(404).json({ error: 'Affiliate link not found' }); + return; + } + + // Determine API key for attribution + const authHeader = req.headers['authorization'] || ''; + let apiKey: string | null = null; + if (authHeader.startsWith('Bearer ')) apiKey = authHeader.slice(7).trim(); + const source = req.query.source as string || 'api_response'; + + // Log click to DB (before redirect) + await db.query( + `INSERT INTO affiliate_clicks + (api_key, affiliate_slug, product_id, merchant_id, affiliate_link_id, source, destination_url) + VALUES ($1,$2,$3,$4,$5,$6,$7)`, + [apiKey, affiliateSlug, productId, merchantId, affiliateLinkId, source, destinationUrl] + ); + + // PostHog event (fire-and-forget) + // Hash API key before sending to third-party analytics + trackAffiliateClick({ + apiKey: apiKey ? hashKey(apiKey) : null, + productId, + merchantId, + affiliateLinkId, + source, + }); + + // Rewrite to Awin tracking URL when publisher + advertiser IDs are configured + let finalUrl = destinationUrl; + if (awinPublisherId && affiliateLinkId && awinAdvertiserIds.has(affiliateLinkId)) { + const clickRef = `${productId.slice(0, 12)}-${Date.now().toString(36)}`; + finalUrl = buildAwinUrl(affiliateLinkId, destinationUrl, clickRef); + } else { + if (!isAllowedDestination(destinationUrl)) { + const { hostname } = (() => { try { return new URL(destinationUrl); } catch { return { hostname: destinationUrl }; } })(); + console.warn(`[redirect] blocked: hostname "${hostname}" not in allowlist`); + res.status(403).json({ error: 'Destination not permitted' }); + return; + } + } + + res.redirect(302, finalUrl); +}); + +export default router; diff --git a/deploy/scraper-scheduler/README.md b/deploy/scraper-scheduler/README.md new file mode 100644 index 000000000..a726c3d22 --- /dev/null +++ b/deploy/scraper-scheduler/README.md @@ -0,0 +1,65 @@ +# Scraper Scheduler + +Keeps BuyWhere product data fresh by running scrapers on a schedule. + +## Deployment + +### Prerequisites + +- Python 3.10+ with scraper dependencies installed +- Access to the BuyWhere API (local or production) +- A BuyWhere API key with ingest permissions + +### Install + +```bash +# Install Python dependencies +cd /opt/buywhere +pip install -r scrapers/requirements.txt +playwright install chromium + +# Set up systemd service +sudo cp deploy/scraper-scheduler/scraper-scheduler.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable scraper-scheduler +sudo systemctl start scraper-scheduler + +# Check status +sudo systemctl status scraper-scheduler +``` + +### Configuration + +Set environment variables in the systemd service file or in `/etc/default/scraper-scheduler`: + +| Variable | Default | Description | +|---|---|---| +| `BUYWHERE_API_URL` | `http://localhost:3000` | Ingest API base URL | +| `BUYWHERE_API_KEY` | _(required)_ | API key with ingest permissions | +| `SCRAPER_SCHEDULE` | _(optional)_ | JSON override: `{"amazon_us": 24}` | +| `SCRAPER_DATA_DIR` | `./data` | Directory for scraper output | + +### Manual Run + +```bash +# Run all scrapers once +python scripts/scraper_scheduler.py --run-once + +# Run specific scrapers +python scripts/scraper_scheduler.py --run-once --scrapers amazon_us,bestbuy_us_sitemap +``` + +## How It Works + +1. The scheduler runs as a daemon, checking each scraper's last run time +2. When a scraper is due (based on its `interval_hours`), it's launched as a subprocess +3. The scraper scrapes merchant sites and pushes data through the local API's `/v1/ingest/products` endpoint +4. The ingest endpoint upserts products, setting `products.updated_at = NOW()` +5. The nightly `priceRefresh` job also updates `products.updated_at` as a secondary freshness signal + +## Monitoring + +Check scheduler health: +```bash +journalctl -u scraper-scheduler -f +``` diff --git a/deploy/scraper-scheduler/scraper-scheduler.service b/deploy/scraper-scheduler/scraper-scheduler.service new file mode 100644 index 000000000..7b2a35e76 --- /dev/null +++ b/deploy/scraper-scheduler/scraper-scheduler.service @@ -0,0 +1,21 @@ +[Unit] +Description=BuyWhere Scraper Scheduler - keeps product data fresh +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=simple +User=paperclip +WorkingDirectory=/opt/buywhere +ExecStart=/usr/bin/python3 /opt/buywhere/scripts/scraper_scheduler.py +Restart=on-failure +RestartSec=30 +StandardOutput=journal +StandardError=journal +Environment=NODE_ENV=production +Environment=BUYWHERE_API_URL=http://localhost:3000 +Environment=BUYWHERE_API_KEY= +Environment=SCRAPER_DATA_DIR=/opt/buywhere/data + +[Install] +WantedBy=multi-user.target diff --git a/deploy/scraper-scheduler/scraper-scheduler.timer b/deploy/scraper-scheduler/scraper-scheduler.timer new file mode 100644 index 000000000..55db3c324 --- /dev/null +++ b/deploy/scraper-scheduler/scraper-scheduler.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run BuyWhere scraper scheduler daily at 02:00 UTC +Requires=scraper-scheduler.service + +[Timer] +OnCalendar=daily +Persistent=true +RandomizedDelaySec=1800 + +[Install] +WantedBy=timers.target diff --git a/scripts/scraper_scheduler.py b/scripts/scraper_scheduler.py new file mode 100755 index 000000000..5282c8b0a --- /dev/null +++ b/scripts/scraper_scheduler.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +""" +scraper_scheduler.py — Scheduled execution of BuyWhere scrapers. + +Orchestrates the existing Python scrapers on configurable intervals. +Each scraper runs as a subprocess, pushing data through the local ingest API, +which sets products.updated_at = NOW() on every upsert. + +Usage: + # Run once (e.g., from cron): + python scripts/scraper_scheduler.py --run-once + + # Run as a persistent daemon: + python scripts/scraper_scheduler.py + + # Run specific scrapers: + python scripts/scraper_scheduler.py --scrapers amazon_us,bestbuy_us_sitemap --run-once + +Environment: + BUYWHERE_API_URL Base URL for the ingest API (default: http://localhost:3000) + BUYWHERE_API_KEY API key with ingest permissions + SCRAPER_SCHEDULE JSON dict of scraper -> interval_hours (overrides defaults) + SCRAPER_DATA_DIR Directory for scraper output (default: ./data) +""" + +import argparse +import asyncio +import json +import os +import signal +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# --- Configuration --- + +DEFAULT_API_URL = os.environ.get("BUYWHERE_API_URL", "http://localhost:3000") +API_KEY = os.environ.get("BUYWHERE_API_KEY", "") +DATA_DIR = Path(os.environ.get("SCRAPER_DATA_DIR", "./data")) +DATA_DIR.mkdir(parents=True, exist_ok=True) + +DEFAULT_SCHEDULE: dict[str, int] = { + "amazon_us": 24, + "bestbuy_us_sitemap": 24, +} + +_SCHEDULE_OVERRIDE = os.environ.get("SCRAPER_SCHEDULE") +if _SCHEDULE_OVERRIDE: + try: + parsed = json.loads(_SCHEDULE_OVERRIDE) + if isinstance(parsed, dict): + DEFAULT_SCHEDULE.update(parsed) + except json.JSONDecodeError: + pass + + +def log(msg: str, **kwargs: Any) -> None: + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + extra = " ".join(f"{k}={v}" for k, v in kwargs.items()) + print(f"[scraper-scheduler] {ts} {msg}" + (f" ({extra})" if extra else ""), flush=True) + +def get_scraper_path(name: str) -> str: + """Build the Python module path for a scraper.""" + return f"scrapers.{name}" + + +def get_scraper_command(name: str, api_url: str, api_key: str, data_dir: str, limit: int = 0, scrape_only: bool = False) -> list[str]: + """Build the CLI command to run a scraper.""" + cmd = [ + sys.executable, "-m", get_scraper_path(name), + "--api-base", api_url, + "--batch-size", "100", + "--delay", "2.0", + ] + if api_key: + cmd.extend(["--api-key", api_key]) + if data_dir: + cmd.extend(["--output-dir" if name == "amazon_us" else "--data-dir", data_dir]) + if limit > 0: + cmd.extend(["--limit", str(limit)]) + if scrape_only: + cmd.append("--scrape-only") + return cmd + + +async def run_scraper(name: str, config: dict[str, Any]) -> dict[str, Any]: + """Run a single scraper and return its result summary.""" + interval = config.get("interval_hours", 24) + limit = config.get("limit", 0) + scrape_only = config.get("scrape_only", False) + api_url = config.get("api_url", DEFAULT_API_URL) + api_key = config.get("api_key", API_KEY) + + scraper_data_dir = str(DATA_DIR / name) + Path(scraper_data_dir).mkdir(parents=True, exist_ok=True) + + cmd = get_scraper_command(name, api_url, api_key, scraper_data_dir, limit, scrape_only) + + log(f"Starting scraper: {name}", cmd=" ".join(cmd[-6:])) + start = time.time() + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=os.getcwd(), + ) + + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=3600) + elapsed = time.time() - start + exit_code = proc.returncode or 0 + + stdout_str = stdout.decode("utf-8", errors="replace") if stdout else "" + stderr_str = stderr.decode("utf-8", errors="replace") if stderr else "" + + # Parse summary from stdout (last JSON line) + summary_lines = [l for l in stdout_str.split("\n") if l.strip().startswith("{")] + summary: dict[str, Any] = {"exit_code": exit_code, "elapsed_seconds": round(elapsed, 1)} + if summary_lines: + try: + parsed = json.loads(summary_lines[-1]) + if isinstance(parsed, dict): + summary.update(parsed) + except json.JSONDecodeError: + pass + + if exit_code != 0: + log(f"Scraper {name} failed", exit_code=exit_code, elapsed=round(elapsed, 1), stderr=stderr_str[-200:]) + summary["error"] = stderr_str[-500:] if stderr_str else "Unknown error" + else: + log(f"Scraper {name} completed", exit_code=exit_code, elapsed=round(elapsed, 1), + scraped=summary.get("total_scraped", "?"), ingested=summary.get("total_ingested", "?"), + updated=summary.get("total_updated", "?")) + + return summary + + except asyncio.TimeoutError: + elapsed = time.time() - start + log(f"Scraper {name} timed out after 3600s", elapsed=round(elapsed, 1)) + return {"exit_code": -1, "elapsed_seconds": round(elapsed, 1), "error": "timeout"} + except Exception as e: + elapsed = time.time() - start + log(f"Scraper {name} raised exception", error=str(e), elapsed=round(elapsed, 1)) + return {"exit_code": -2, "elapsed_seconds": round(elapsed, 1), "error": str(e)} + +async def run_one_scraper(name, config): + return await run_scraper(name, config) + +def parse_schedule(): + schedule = {} + for scraper_name, interval_hours in DEFAULT_SCHEDULE.items(): + schedule[scraper_name] = { + "interval_hours": interval_hours, + "last_run": 0, + "running": False, + } + return schedule + + +async def scheduler_loop(run_once, scraper_filter): + schedule = parse_schedule() + if scraper_filter: + schedule = {k: v for k, v in schedule.items() if k in scraper_filter} + if not schedule: + log("No matching scrapers in schedule", filter=",".join(scraper_filter)) + return + + log("Scraper scheduler started", schedule=json.dumps({k: v["interval_hours"] for k, v in schedule.items()})) + + async def health_check(): + try: + import httpx + async with httpx.AsyncClient(timeout=5.0) as client: + url = f"{DEFAULT_API_URL}/health" + resp = await client.get(url) + if resp.status_code == 200: + return True + log("Health check failed", status=resp.status_code) + return False + except Exception as e: + log("Health check error", error=str(e)) + return False + + healthy = await health_check() + if not healthy: + log("API is not healthy, will continue but ingestion may fail") + + while True: + now = time.time() + for scraper_name, cfg in schedule.items(): + if cfg["running"]: + continue + hours_since = (now - cfg["last_run"]) / 3600 + if hours_since >= cfg["interval_hours"]: + cfg["running"] = True + summary = await run_one_scraper(scraper_name, cfg) + cfg["last_run"] = time.time() + cfg["running"] = False + log(f"Run complete: {scraper_name}", **summary) + + if run_once: + break + + await asyncio.sleep(60) + + +def main(): + parser = argparse.ArgumentParser(description="BuyWhere scraper scheduler") + parser.add_argument("--run-once", action="store_true", help="Run each scraper once and exit") + parser.add_argument("--scrapers", type=str, help="Comma-separated list of scrapers to run") + args = parser.parse_args() + + scraper_filter = None + if args.scrapers: + scraper_filter = [s.strip() for s in args.scrapers.split(",")] + + try: + asyncio.run(scheduler_loop(args.run_once, scraper_filter)) + except KeyboardInterrupt: + log("Scheduler interrupted by signal") + + +if __name__ == "__main__": + main() diff --git a/src/components/HomeProductSearch.tsx b/src/components/HomeProductSearch.tsx index 6d8fca728..89b94f923 100644 --- a/src/components/HomeProductSearch.tsx +++ b/src/components/HomeProductSearch.tsx @@ -10,10 +10,27 @@ const countryOptions = [ { value: 'sg', label: 'Singapore' }, ] as const; +type CountryValue = (typeof countryOptions)[number]['value']; + +function inferCountryFromQuery(query: string): CountryValue | null { + const normalizedQuery = query.toLowerCase(); + + if (/\b(singapore|sg)\b/.test(normalizedQuery)) { + return 'sg'; + } + + if (/\b(us|usa|united states|america)\b/.test(normalizedQuery)) { + return 'us'; + } + + return null; +} + export function HomeProductSearch() { const router = useRouter(); const [query, setQuery] = useState(''); - const [country, setCountry] = useState<(typeof countryOptions)[number]['value']>('us'); + const [country, setCountry] = useState('us'); + const [countryTouched, setCountryTouched] = useState(false); const [error, setError] = useState(''); const errorId = useId(); @@ -26,7 +43,8 @@ export function HomeProductSearch() { } setError(''); - router.push(`/search?q=${encodeURIComponent(nextQuery)}&country=${country}`); + const searchCountry = countryTouched ? country : inferCountryFromQuery(nextQuery) ?? country; + router.push(`/search?q=${encodeURIComponent(nextQuery)}&country=${searchCountry}`); }; const handleSubmit = (event: FormEvent) => { @@ -67,7 +85,10 @@ export function HomeProductSearch() {