Repository files navigation

Find a Dram

Know what's on the shelf before you walk through the door.

Find a Dram is a whiskey discovery platform for the Pacific Northwest. Search 60+ bars across Portland, Seattle, and Vancouver, browse 380+ whiskeys, compare prices, and find your next pour — all in one place.

Live demo:findadram.vercel.app


The Problem

The Pacific Northwest has one of the best whiskey scenes in the country — Portland's Multnomah Whiskey Library, Seattle's Canon (4,000+ bottles), Scotch Lodge, Pope House, and dozens more. But there's no good way to answer simple questions:

  • "Which bars near me carry Blanton's?"
  • "What's the whiskey list at that new bar on Division?"
  • "Where can I get a pour of Yamazaki 12 for under $25?"

You'd have to call each bar, check scattered websites, or just show up and hope for the best.

The Solution

Find a Dram aggregates whiskey menus from bars into a single searchable database. You can:

  • Search by whiskey — find every bar carrying a specific bottle, with prices and pour sizes
  • Search by bar — see the full whiskey menu for any bar before you visit
  • Search nearby — use your location to find whiskey bars within walking distance
  • Buy a bottle — search Oregon OLCC liquor store inventory for retail bottles
  • Submit menus — upload a bar's menu URL or photo and our AI extracts every whiskey automatically
  • Verify availability — confirm or flag whiskeys as "not found" to keep data fresh
  • Claim your bar — bar owners can claim their listing and manage their own whiskey menu

How It Works

AI-Powered Menu Extraction

The most technically interesting piece. Given a bar's website URL or a photo of their menu:

URL / Photo / PDF → Crawl + OCR → Claude Extraction → Normalize → Deduplicate → Database
  1. Crawl — Puppeteer (headless Chrome) for JS-rendered menus, plain fetch fallback. SSRF protection blocks private IPs and validates content types.
  2. Extract — Claude parses unstructured menu text into structured JSON: whiskey name, distillery, type, price, pour size, confidence score. Three input modes: HTML text (Haiku), menu photos/back-bar shots (Sonnet vision), and native PDF documents.
  3. Normalize — 10-step pure TypeScript pipeline: Unicode normalization, distillery alias resolution (150+ aliases), ABV/proof stripping, legal category removal, age statement normalization ("12yo" / "12 Year Old" / "12yr" all become "12 year"), and private barrel detection ("Eagle Rare — Private Barrel" separates the pick metadata from the base whiskey).
  4. Deduplicate — three-tier matching: exact normalized name match, Levenshtein fuzzy match (85% threshold), then Claude AI as a final judge for ambiguous near-matches ("Are these the same whiskey?").
  5. Ingest — upserts to Supabase with full provenance: source URL, source type, extraction confidence, content hash (SHA-256) for change detection, and dual timestamps (when we scraped vs. when the source was published).

Supports batch processing (up to 20 URLs), PDF menus, and camera photos of physical menus.

Google Places Discovery

We also built a pipeline that uses the Google Places API to automatically discover whiskey bars in the Portland metro area, pull ratings and review counts, and download menu photos for OCR extraction.

Data Pipeline

Multiple enrichment strategies run together:

SourceMethodConfidence
Bar websitesAI menu extraction (Claude Vision + text)High
Google PlacesPhoto OCR + review miningMedium
AI knowledgeClaude's knowledge of Portland barsLower
CrowdsourcedUser-submitted sightings and confirmationsVaries

Every entry tracks its source type, confidence score, and when it was last verified.

Data Freshness & Crowd Verification

Bar menus change constantly. We built multiple systems to keep data accurate:

  • Staleness warnings — listings not verified in 90+ days, or sourced from data over 90 days old, show an amber warning badge so users know to take pricing with a grain of salt
  • Crowd confirmations — any user can tap "Still here" or "Not found" on a whiskey listing. Two "not found" reports within 7 days automatically marks the whiskey as unavailable (via Postgres trigger).
  • Provenance chain — every bar-whiskey link traces back to its source: the original URL or photo, when we scraped it, when the source was published, and a content hash for change detection on re-crawls
  • Bar owner management — verified bar owners can claim their listing and directly update prices, availability, and pour sizes through a dashboard, with RLS policies enforcing ownership at the database level

OLCC Liquor Store Integration

Oregon's liquor stores are state-controlled with uniform pricing. We reverse-engineered the OLCC (Oregon Liquor and Cannabis Commission) web servlet to query their product catalog directly — POSTing through the age gate to establish a session cookie, then parsing the HTML product table for item codes, descriptions, sizes, proof, and bottle prices. The integration handles cookie extraction differences across Node.js runtimes (Vercel vs. local) and defaults to a statewide search when no ZIP code is provided.

Tech Stack

LayerTechnology
FrameworkNext.js 16 (App Router, Server Components)
DatabaseSupabase (PostgreSQL + PostGIS for geo-queries)
AIClaude API — Haiku for classification, Sonnet for extraction and vision
External DataGoogle Places API — bar discovery, ratings, photo OCR
MapsLeaflet + react-leaflet
StylingTailwind CSS v4 with custom whiskey/oak color palette
ValidationZod v4
LanguageTypeScript (strict mode)
HostingVercel

Architecture

src/
app/ Next.js App Router
api/ REST endpoints (search, trawl, geocode, sightings)
bars/[id]/ Bar detail — full whiskey menu, map, activity feed
whiskeys/[id]/ Whiskey detail — where to find it, pricing, OLCC search
search/ Full-text + geo search across bars and whiskeys
submit/ Upload a bar menu (URL, image, or PDF)
bottles/ Oregon OLCC liquor store bottle search
dashboard/ Bar owner portal — claim bars, manage whiskey menus
auth/ Email/password sign-in, sign-up, OAuth callback
components/
features/ BarCard, WhiskeyCard, MapView, NearbyBars, SearchBar, ...
ui/ Design system primitives (Button, Input, Badge, Card)
lib/
trawler/ AI extraction pipeline (crawl → extract → normalize → ingest)
liquor-search/ OLCC state liquor store integration (extensible to other states)
supabase/ DB clients, server/client helpers
geo.ts Pacific NW geofencing, Haversine distance, region detection
hooks/ useLocation, useDebounce
types/ Shared TypeScript types (database, trawler, geo)
scripts/
google-places-enrich Google Places discovery + photo OCR pipeline
enrich-portland-bars AI knowledge-based enrichment
seed-whiskey-reference Comprehensive whiskey catalog seeder
trawl-portland Batch website trawler for Portland bars
audit-data Data quality audit tool
supabase/
migrations/ Sequential SQL migrations (schema, engagement, RLS, seeds)

Database

bars ──────────── bar_whiskeys ──────────── whiskeys
PostGIS location price, pour_size type (bourbon, scotch, rye, ...)
Google place_id confidence, source_type distillery, region, country
rating, reviews available, last_verified age, abv, normalized_name
sightings ─── user reports: "I saw X at Y for $Z" (triggers auto bar_whiskey upsert)
confirmations ─── crowdsourced "still here" / "not found" verification
trawl_jobs ─── extraction pipeline tracking with provenance + content hashes
user_profiles ─── session + optional auth linkage, roles (user/bar_owner/admin)
bar_claims ─── bar ownership requests (pending → approved/rejected)
  • Row Level Security on all tables — public reads, service role for core data writes, session-based ownership for user-generated content
  • PostGIS GIST spatial indexes for geo-queries (ST_DWithin)
  • Full-text search via tsvector + GIN indexes on bars and whiskeys
  • Trigram indexes (pg_trgm) for fuzzy name matching
  • Custom PostgreSQL RPCs: search_bars and search_whiskeys combine full-text + geospatial sorting; nearby_bars for pure geo; bar_activity for UNION ALL activity feeds
  • normalize_whiskey_name() — an IMMUTABLE SQL function mirroring the TypeScript normalization pipeline, used in a GENERATED ALWAYS column for index-backed deduplication

Current Data

MetricCount
Bars (Portland, Seattle, Vancouver)63
Whiskeys cataloged378
Bar–whiskey links715
Whiskey typesbourbon, scotch, rye, irish, japanese, canadian, single malt, blended, PNW craft
Venue categorieswhiskey bar, cocktail bar, restaurant, pub, hotel bar, distillery, brewery, lounge

In a production trawl run, the pipeline crawled 39 Portland bar websites in 185 seconds, extracting structured whiskey data including private barrel picks (e.g., Interurban's private barrel Eagle Rare on draft at $13, E.H. Taylor at $21).

Security

  • SSRF protection — URL validation before any crawl (blocks private IPs, non-HTTP schemes)
  • Input validation — Zod schemas at all API boundaries; coordinate bounds checking
  • File limits — 25MB max upload, mime type validation
  • RLS — all Supabase tables have Row Level Security; public users are read-only
  • No secrets in client — service role key used only server-side

Getting Started

npm install
# Set up environment
cp .env.example .env.local
# Fill in: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY,# SUPABASE_SERVICE_ROLE_KEY, ANTHROPIC_API_KEY, GOOGLE_PLACES_API_KEY# Push schema to Supabase
npx supabase link --project-ref <your-project-ref>
npx supabase db push
# Run
npm run dev # → http://localhost:3000

How It Was Built

This project was built using Claude Code with parallel AI agents, each owning a domain (database, API, UI, geo/search, auth). Agents coordinate through file ownership boundaries and a shared collaboration protocol defined in CLAUDE.md.

An autonomous overseer agent ran alongside the domain agents — triaging the codebase, picking the highest-value next action, executing it, and committing. This kept the project moving forward without manual task assignment.

The trawler pipeline and Google Places enrichment pipeline both use Claude's vision and text capabilities to extract structured whiskey data from unstructured sources — bar websites, menu photos, and Google reviews.

About

Whiskey/spirits discovery platform for Portland, OR (Claude Hackathon March 2026 Project)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

Find a Dram

Know what's on the shelf before you walk through the door.

Find a Dram is a whiskey discovery platform for the Pacific Northwest. Search 60+ bars across Portland, Seattle, and Vancouver, browse 380+ whiskeys, compare prices, and find your next pour — all in one place.

Live demo:findadram.vercel.app


The Problem

The Pacific Northwest has one of the best whiskey scenes in the country — Portland's Multnomah Whiskey Library, Seattle's Canon (4,000+ bottles), Scotch Lodge, Pope House, and dozens more. But there's no good way to answer simple questions:

  • "Which bars near me carry Blanton's?"
  • "What's the whiskey list at that new bar on Division?"
  • "Where can I get a pour of Yamazaki 12 for under $25?"

You'd have to call each bar, check scattered websites, or just show up and hope for the best.

The Solution

Find a Dram aggregates whiskey menus from bars into a single searchable database. You can:

  • Search by whiskey — find every bar carrying a specific bottle, with prices and pour sizes
  • Search by bar — see the full whiskey menu for any bar before you visit
  • Search nearby — use your location to find whiskey bars within walking distance
  • Buy a bottle — search Oregon OLCC liquor store inventory for retail bottles
  • Submit menus — upload a bar's menu URL or photo and our AI extracts every whiskey automatically
  • Verify availability — confirm or flag whiskeys as "not found" to keep data fresh
  • Claim your bar — bar owners can claim their listing and manage their own whiskey menu

How It Works

AI-Powered Menu Extraction

The most technically interesting piece. Given a bar's website URL or a photo of their menu:

URL / Photo / PDF → Crawl + OCR → Claude Extraction → Normalize → Deduplicate → Database
  1. Crawl — Puppeteer (headless Chrome) for JS-rendered menus, plain fetch fallback. SSRF protection blocks private IPs and validates content types.
  2. Extract — Claude parses unstructured menu text into structured JSON: whiskey name, distillery, type, price, pour size, confidence score. Three input modes: HTML text (Haiku), menu photos/back-bar shots (Sonnet vision), and native PDF documents.
  3. Normalize — 10-step pure TypeScript pipeline: Unicode normalization, distillery alias resolution (150+ aliases), ABV/proof stripping, legal category removal, age statement normalization ("12yo" / "12 Year Old" / "12yr" all become "12 year"), and private barrel detection ("Eagle Rare — Private Barrel" separates the pick metadata from the base whiskey).
  4. Deduplicate — three-tier matching: exact normalized name match, Levenshtein fuzzy match (85% threshold), then Claude AI as a final judge for ambiguous near-matches ("Are these the same whiskey?").
  5. Ingest — upserts to Supabase with full provenance: source URL, source type, extraction confidence, content hash (SHA-256) for change detection, and dual timestamps (when we scraped vs. when the source was published).

Supports batch processing (up to 20 URLs), PDF menus, and camera photos of physical menus.

Google Places Discovery

We also built a pipeline that uses the Google Places API to automatically discover whiskey bars in the Portland metro area, pull ratings and review counts, and download menu photos for OCR extraction.

Data Pipeline

Multiple enrichment strategies run together:

SourceMethodConfidence
Bar websitesAI menu extraction (Claude Vision + text)High
Google PlacesPhoto OCR + review miningMedium
AI knowledgeClaude's knowledge of Portland barsLower
CrowdsourcedUser-submitted sightings and confirmationsVaries

Every entry tracks its source type, confidence score, and when it was last verified.

Data Freshness & Crowd Verification

Bar menus change constantly. We built multiple systems to keep data accurate:

  • Staleness warnings — listings not verified in 90+ days, or sourced from data over 90 days old, show an amber warning badge so users know to take pricing with a grain of salt
  • Crowd confirmations — any user can tap "Still here" or "Not found" on a whiskey listing. Two "not found" reports within 7 days automatically marks the whiskey as unavailable (via Postgres trigger).
  • Provenance chain — every bar-whiskey link traces back to its source: the original URL or photo, when we scraped it, when the source was published, and a content hash for change detection on re-crawls
  • Bar owner management — verified bar owners can claim their listing and directly update prices, availability, and pour sizes through a dashboard, with RLS policies enforcing ownership at the database level

OLCC Liquor Store Integration

Oregon's liquor stores are state-controlled with uniform pricing. We reverse-engineered the OLCC (Oregon Liquor and Cannabis Commission) web servlet to query their product catalog directly — POSTing through the age gate to establish a session cookie, then parsing the HTML product table for item codes, descriptions, sizes, proof, and bottle prices. The integration handles cookie extraction differences across Node.js runtimes (Vercel vs. local) and defaults to a statewide search when no ZIP code is provided.

Tech Stack

LayerTechnology
FrameworkNext.js 16 (App Router, Server Components)
DatabaseSupabase (PostgreSQL + PostGIS for geo-queries)
AIClaude API — Haiku for classification, Sonnet for extraction and vision
External DataGoogle Places API — bar discovery, ratings, photo OCR
MapsLeaflet + react-leaflet
StylingTailwind CSS v4 with custom whiskey/oak color palette
ValidationZod v4
LanguageTypeScript (strict mode)
HostingVercel

Architecture

src/
app/ Next.js App Router
api/ REST endpoints (search, trawl, geocode, sightings)
bars/[id]/ Bar detail — full whiskey menu, map, activity feed
whiskeys/[id]/ Whiskey detail — where to find it, pricing, OLCC search
search/ Full-text + geo search across bars and whiskeys
submit/ Upload a bar menu (URL, image, or PDF)
bottles/ Oregon OLCC liquor store bottle search
dashboard/ Bar owner portal — claim bars, manage whiskey menus
auth/ Email/password sign-in, sign-up, OAuth callback
components/
features/ BarCard, WhiskeyCard, MapView, NearbyBars, SearchBar, ...
ui/ Design system primitives (Button, Input, Badge, Card)
lib/
trawler/ AI extraction pipeline (crawl → extract → normalize → ingest)
liquor-search/ OLCC state liquor store integration (extensible to other states)
supabase/ DB clients, server/client helpers
geo.ts Pacific NW geofencing, Haversine distance, region detection
hooks/ useLocation, useDebounce
types/ Shared TypeScript types (database, trawler, geo)
scripts/
google-places-enrich Google Places discovery + photo OCR pipeline
enrich-portland-bars AI knowledge-based enrichment
seed-whiskey-reference Comprehensive whiskey catalog seeder
trawl-portland Batch website trawler for Portland bars
audit-data Data quality audit tool
supabase/
migrations/ Sequential SQL migrations (schema, engagement, RLS, seeds)

Database

bars ──────────── bar_whiskeys ──────────── whiskeys
PostGIS location price, pour_size type (bourbon, scotch, rye, ...)
Google place_id confidence, source_type distillery, region, country
rating, reviews available, last_verified age, abv, normalized_name
sightings ─── user reports: "I saw X at Y for $Z" (triggers auto bar_whiskey upsert)
confirmations ─── crowdsourced "still here" / "not found" verification
trawl_jobs ─── extraction pipeline tracking with provenance + content hashes
user_profiles ─── session + optional auth linkage, roles (user/bar_owner/admin)
bar_claims ─── bar ownership requests (pending → approved/rejected)
  • Row Level Security on all tables — public reads, service role for core data writes, session-based ownership for user-generated content
  • PostGIS GIST spatial indexes for geo-queries (ST_DWithin)
  • Full-text search via tsvector + GIN indexes on bars and whiskeys
  • Trigram indexes (pg_trgm) for fuzzy name matching
  • Custom PostgreSQL RPCs: search_bars and search_whiskeys combine full-text + geospatial sorting; nearby_bars for pure geo; bar_activity for UNION ALL activity feeds
  • normalize_whiskey_name() — an IMMUTABLE SQL function mirroring the TypeScript normalization pipeline, used in a GENERATED ALWAYS column for index-backed deduplication

Current Data

MetricCount
Bars (Portland, Seattle, Vancouver)63
Whiskeys cataloged378
Bar–whiskey links715
Whiskey typesbourbon, scotch, rye, irish, japanese, canadian, single malt, blended, PNW craft
Venue categorieswhiskey bar, cocktail bar, restaurant, pub, hotel bar, distillery, brewery, lounge

In a production trawl run, the pipeline crawled 39 Portland bar websites in 185 seconds, extracting structured whiskey data including private barrel picks (e.g., Interurban's private barrel Eagle Rare on draft at $13, E.H. Taylor at $21).

Security

  • SSRF protection — URL validation before any crawl (blocks private IPs, non-HTTP schemes)
  • Input validation — Zod schemas at all API boundaries; coordinate bounds checking
  • File limits — 25MB max upload, mime type validation
  • RLS — all Supabase tables have Row Level Security; public users are read-only
  • No secrets in client — service role key used only server-side

Getting Started

npm install
# Set up environment
cp .env.example .env.local
# Fill in: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY,# SUPABASE_SERVICE_ROLE_KEY, ANTHROPIC_API_KEY, GOOGLE_PLACES_API_KEY# Push schema to Supabase
npx supabase link --project-ref <your-project-ref>
npx supabase db push
# Run
npm run dev # → http://localhost:3000

How It Was Built

This project was built using Claude Code with parallel AI agents, each owning a domain (database, API, UI, geo/search, auth). Agents coordinate through file ownership boundaries and a shared collaboration protocol defined in CLAUDE.md.

An autonomous overseer agent ran alongside the domain agents — triaging the codebase, picking the highest-value next action, executing it, and committing. This kept the project moving forward without manual task assignment.

The trawler pipeline and Google Places enrichment pipeline both use Claude's vision and text capabilities to extract structured whiskey data from unstructured sources — bar websites, menu photos, and Google reviews.

About

Whiskey/spirits discovery platform for Portland, OR (Claude Hackathon March 2026 Project)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Find a Dram

Know what's on the shelf before you walk through the door.

Find a Dram is a whiskey discovery platform for the Pacific Northwest. Search 60+ bars across Portland, Seattle, and Vancouver, browse 380+ whiskeys, compare prices, and find your next pour — all in one place.

Live demo:findadram.vercel.app


The Problem

The Pacific Northwest has one of the best whiskey scenes in the country — Portland's Multnomah Whiskey Library, Seattle's Canon (4,000+ bottles), Scotch Lodge, Pope House, and dozens more. But there's no good way to answer simple questions:

  • "Which bars near me carry Blanton's?"
  • "What's the whiskey list at that new bar on Division?"
  • "Where can I get a pour of Yamazaki 12 for under $25?"

You'd have to call each bar, check scattered websites, or just show up and hope for the best.

The Solution

Find a Dram aggregates whiskey menus from bars into a single searchable database. You can:

  • Search by whiskey — find every bar carrying a specific bottle, with prices and pour sizes
  • Search by bar — see the full whiskey menu for any bar before you visit
  • Search nearby — use your location to find whiskey bars within walking distance
  • Buy a bottle — search Oregon OLCC liquor store inventory for retail bottles
  • Submit menus — upload a bar's menu URL or photo and our AI extracts every whiskey automatically
  • Verify availability — confirm or flag whiskeys as "not found" to keep data fresh
  • Claim your bar — bar owners can claim their listing and manage their own whiskey menu

How It Works

AI-Powered Menu Extraction

The most technically interesting piece. Given a bar's website URL or a photo of their menu:

URL / Photo / PDF → Crawl + OCR → Claude Extraction → Normalize → Deduplicate → Database
  1. Crawl — Puppeteer (headless Chrome) for JS-rendered menus, plain fetch fallback. SSRF protection blocks private IPs and validates content types.
  2. Extract — Claude parses unstructured menu text into structured JSON: whiskey name, distillery, type, price, pour size, confidence score. Three input modes: HTML text (Haiku), menu photos/back-bar shots (Sonnet vision), and native PDF documents.
  3. Normalize — 10-step pure TypeScript pipeline: Unicode normalization, distillery alias resolution (150+ aliases), ABV/proof stripping, legal category removal, age statement normalization ("12yo" / "12 Year Old" / "12yr" all become "12 year"), and private barrel detection ("Eagle Rare — Private Barrel" separates the pick metadata from the base whiskey).
  4. Deduplicate — three-tier matching: exact normalized name match, Levenshtein fuzzy match (85% threshold), then Claude AI as a final judge for ambiguous near-matches ("Are these the same whiskey?").
  5. Ingest — upserts to Supabase with full provenance: source URL, source type, extraction confidence, content hash (SHA-256) for change detection, and dual timestamps (when we scraped vs. when the source was published).

Supports batch processing (up to 20 URLs), PDF menus, and camera photos of physical menus.

Google Places Discovery

We also built a pipeline that uses the Google Places API to automatically discover whiskey bars in the Portland metro area, pull ratings and review counts, and download menu photos for OCR extraction.

Data Pipeline

Multiple enrichment strategies run together:

SourceMethodConfidence
Bar websitesAI menu extraction (Claude Vision + text)High
Google PlacesPhoto OCR + review miningMedium
AI knowledgeClaude's knowledge of Portland barsLower
CrowdsourcedUser-submitted sightings and confirmationsVaries

Every entry tracks its source type, confidence score, and when it was last verified.

Data Freshness & Crowd Verification

Bar menus change constantly. We built multiple systems to keep data accurate:

  • Staleness warnings — listings not verified in 90+ days, or sourced from data over 90 days old, show an amber warning badge so users know to take pricing with a grain of salt
  • Crowd confirmations — any user can tap "Still here" or "Not found" on a whiskey listing. Two "not found" reports within 7 days automatically marks the whiskey as unavailable (via Postgres trigger).
  • Provenance chain — every bar-whiskey link traces back to its source: the original URL or photo, when we scraped it, when the source was published, and a content hash for change detection on re-crawls
  • Bar owner management — verified bar owners can claim their listing and directly update prices, availability, and pour sizes through a dashboard, with RLS policies enforcing ownership at the database level

OLCC Liquor Store Integration

Oregon's liquor stores are state-controlled with uniform pricing. We reverse-engineered the OLCC (Oregon Liquor and Cannabis Commission) web servlet to query their product catalog directly — POSTing through the age gate to establish a session cookie, then parsing the HTML product table for item codes, descriptions, sizes, proof, and bottle prices. The integration handles cookie extraction differences across Node.js runtimes (Vercel vs. local) and defaults to a statewide search when no ZIP code is provided.

Tech Stack

LayerTechnology
FrameworkNext.js 16 (App Router, Server Components)
DatabaseSupabase (PostgreSQL + PostGIS for geo-queries)
AIClaude API — Haiku for classification, Sonnet for extraction and vision
External DataGoogle Places API — bar discovery, ratings, photo OCR
MapsLeaflet + react-leaflet
StylingTailwind CSS v4 with custom whiskey/oak color palette
ValidationZod v4
LanguageTypeScript (strict mode)
HostingVercel

Architecture

src/
app/ Next.js App Router
api/ REST endpoints (search, trawl, geocode, sightings)
bars/[id]/ Bar detail — full whiskey menu, map, activity feed
whiskeys/[id]/ Whiskey detail — where to find it, pricing, OLCC search
search/ Full-text + geo search across bars and whiskeys
submit/ Upload a bar menu (URL, image, or PDF)
bottles/ Oregon OLCC liquor store bottle search
dashboard/ Bar owner portal — claim bars, manage whiskey menus
auth/ Email/password sign-in, sign-up, OAuth callback
components/
features/ BarCard, WhiskeyCard, MapView, NearbyBars, SearchBar, ...
ui/ Design system primitives (Button, Input, Badge, Card)
lib/
trawler/ AI extraction pipeline (crawl → extract → normalize → ingest)
liquor-search/ OLCC state liquor store integration (extensible to other states)
supabase/ DB clients, server/client helpers
geo.ts Pacific NW geofencing, Haversine distance, region detection
hooks/ useLocation, useDebounce
types/ Shared TypeScript types (database, trawler, geo)
scripts/
google-places-enrich Google Places discovery + photo OCR pipeline
enrich-portland-bars AI knowledge-based enrichment
seed-whiskey-reference Comprehensive whiskey catalog seeder
trawl-portland Batch website trawler for Portland bars
audit-data Data quality audit tool
supabase/
migrations/ Sequential SQL migrations (schema, engagement, RLS, seeds)

Database

bars ──────────── bar_whiskeys ──────────── whiskeys
PostGIS location price, pour_size type (bourbon, scotch, rye, ...)
Google place_id confidence, source_type distillery, region, country
rating, reviews available, last_verified age, abv, normalized_name
sightings ─── user reports: "I saw X at Y for $Z" (triggers auto bar_whiskey upsert)
confirmations ─── crowdsourced "still here" / "not found" verification
trawl_jobs ─── extraction pipeline tracking with provenance + content hashes
user_profiles ─── session + optional auth linkage, roles (user/bar_owner/admin)
bar_claims ─── bar ownership requests (pending → approved/rejected)
  • Row Level Security on all tables — public reads, service role for core data writes, session-based ownership for user-generated content
  • PostGIS GIST spatial indexes for geo-queries (ST_DWithin)
  • Full-text search via tsvector + GIN indexes on bars and whiskeys
  • Trigram indexes (pg_trgm) for fuzzy name matching
  • Custom PostgreSQL RPCs: search_bars and search_whiskeys combine full-text + geospatial sorting; nearby_bars for pure geo; bar_activity for UNION ALL activity feeds
  • normalize_whiskey_name() — an IMMUTABLE SQL function mirroring the TypeScript normalization pipeline, used in a GENERATED ALWAYS column for index-backed deduplication

Current Data

MetricCount
Bars (Portland, Seattle, Vancouver)63
Whiskeys cataloged378
Bar–whiskey links715
Whiskey typesbourbon, scotch, rye, irish, japanese, canadian, single malt, blended, PNW craft
Venue categorieswhiskey bar, cocktail bar, restaurant, pub, hotel bar, distillery, brewery, lounge

In a production trawl run, the pipeline crawled 39 Portland bar websites in 185 seconds, extracting structured whiskey data including private barrel picks (e.g., Interurban's private barrel Eagle Rare on draft at $13, E.H. Taylor at $21).

Security

  • SSRF protection — URL validation before any crawl (blocks private IPs, non-HTTP schemes)
  • Input validation — Zod schemas at all API boundaries; coordinate bounds checking
  • File limits — 25MB max upload, mime type validation
  • RLS — all Supabase tables have Row Level Security; public users are read-only
  • No secrets in client — service role key used only server-side

Getting Started

npm install
# Set up environment
cp .env.example .env.local
# Fill in: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY,# SUPABASE_SERVICE_ROLE_KEY, ANTHROPIC_API_KEY, GOOGLE_PLACES_API_KEY# Push schema to Supabase
npx supabase link --project-ref <your-project-ref>
npx supabase db push
# Run
npm run dev # → http://localhost:3000

How It Was Built

This project was built using Claude Code with parallel AI agents, each owning a domain (database, API, UI, geo/search, auth). Agents coordinate through file ownership boundaries and a shared collaboration protocol defined in CLAUDE.md.

An autonomous overseer agent ran alongside the domain agents — triaging the codebase, picking the highest-value next action, executing it, and committing. This kept the project moving forward without manual task assignment.

The trawler pipeline and Google Places enrichment pipeline both use Claude's vision and text capabilities to extract structured whiskey data from unstructured sources — bar websites, menu photos, and Google reviews.

About

Whiskey/spirits discovery platform for Portland, OR (Claude Hackathon March 2026 Project)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Find a Dram

Know what's on the shelf before you walk through the door.

Find a Dram is a whiskey discovery platform for the Pacific Northwest. Search 60+ bars across Portland, Seattle, and Vancouver, browse 380+ whiskeys, compare prices, and find your next pour — all in one place.

Live demo:findadram.vercel.app


The Problem

The Pacific Northwest has one of the best whiskey scenes in the country — Portland's Multnomah Whiskey Library, Seattle's Canon (4,000+ bottles), Scotch Lodge, Pope House, and dozens more. But there's no good way to answer simple questions:

  • "Which bars near me carry Blanton's?"
  • "What's the whiskey list at that new bar on Division?"
  • "Where can I get a pour of Yamazaki 12 for under $25?"

You'd have to call each bar, check scattered websites, or just show up and hope for the best.

The Solution

Find a Dram aggregates whiskey menus from bars into a single searchable database. You can:

  • Search by whiskey — find every bar carrying a specific bottle, with prices and pour sizes
  • Search by bar — see the full whiskey menu for any bar before you visit
  • Search nearby — use your location to find whiskey bars within walking distance
  • Buy a bottle — search Oregon OLCC liquor store inventory for retail bottles
  • Submit menus — upload a bar's menu URL or photo and our AI extracts every whiskey automatically
  • Verify availability — confirm or flag whiskeys as "not found" to keep data fresh
  • Claim your bar — bar owners can claim their listing and manage their own whiskey menu

How It Works

AI-Powered Menu Extraction

The most technically interesting piece. Given a bar's website URL or a photo of their menu:

URL / Photo / PDF → Crawl + OCR → Claude Extraction → Normalize → Deduplicate → Database
  1. Crawl — Puppeteer (headless Chrome) for JS-rendered menus, plain fetch fallback. SSRF protection blocks private IPs and validates content types.
  2. Extract — Claude parses unstructured menu text into structured JSON: whiskey name, distillery, type, price, pour size, confidence score. Three input modes: HTML text (Haiku), menu photos/back-bar shots (Sonnet vision), and native PDF documents.
  3. Normalize — 10-step pure TypeScript pipeline: Unicode normalization, distillery alias resolution (150+ aliases), ABV/proof stripping, legal category removal, age statement normalization ("12yo" / "12 Year Old" / "12yr" all become "12 year"), and private barrel detection ("Eagle Rare — Private Barrel" separates the pick metadata from the base whiskey).
  4. Deduplicate — three-tier matching: exact normalized name match, Levenshtein fuzzy match (85% threshold), then Claude AI as a final judge for ambiguous near-matches ("Are these the same whiskey?").
  5. Ingest — upserts to Supabase with full provenance: source URL, source type, extraction confidence, content hash (SHA-256) for change detection, and dual timestamps (when we scraped vs. when the source was published).

Supports batch processing (up to 20 URLs), PDF menus, and camera photos of physical menus.

Google Places Discovery

We also built a pipeline that uses the Google Places API to automatically discover whiskey bars in the Portland metro area, pull ratings and review counts, and download menu photos for OCR extraction.

Data Pipeline

Multiple enrichment strategies run together:

SourceMethodConfidence
Bar websitesAI menu extraction (Claude Vision + text)High
Google PlacesPhoto OCR + review miningMedium
AI knowledgeClaude's knowledge of Portland barsLower
CrowdsourcedUser-submitted sightings and confirmationsVaries

Every entry tracks its source type, confidence score, and when it was last verified.

Data Freshness & Crowd Verification

Bar menus change constantly. We built multiple systems to keep data accurate:

  • Staleness warnings — listings not verified in 90+ days, or sourced from data over 90 days old, show an amber warning badge so users know to take pricing with a grain of salt
  • Crowd confirmations — any user can tap "Still here" or "Not found" on a whiskey listing. Two "not found" reports within 7 days automatically marks the whiskey as unavailable (via Postgres trigger).
  • Provenance chain — every bar-whiskey link traces back to its source: the original URL or photo, when we scraped it, when the source was published, and a content hash for change detection on re-crawls
  • Bar owner management — verified bar owners can claim their listing and directly update prices, availability, and pour sizes through a dashboard, with RLS policies enforcing ownership at the database level

OLCC Liquor Store Integration

Oregon's liquor stores are state-controlled with uniform pricing. We reverse-engineered the OLCC (Oregon Liquor and Cannabis Commission) web servlet to query their product catalog directly — POSTing through the age gate to establish a session cookie, then parsing the HTML product table for item codes, descriptions, sizes, proof, and bottle prices. The integration handles cookie extraction differences across Node.js runtimes (Vercel vs. local) and defaults to a statewide search when no ZIP code is provided.

Tech Stack

LayerTechnology
FrameworkNext.js 16 (App Router, Server Components)
DatabaseSupabase (PostgreSQL + PostGIS for geo-queries)
AIClaude API — Haiku for classification, Sonnet for extraction and vision
External DataGoogle Places API — bar discovery, ratings, photo OCR
MapsLeaflet + react-leaflet
StylingTailwind CSS v4 with custom whiskey/oak color palette
ValidationZod v4
LanguageTypeScript (strict mode)
HostingVercel

Architecture

src/
app/ Next.js App Router
api/ REST endpoints (search, trawl, geocode, sightings)
bars/[id]/ Bar detail — full whiskey menu, map, activity feed
whiskeys/[id]/ Whiskey detail — where to find it, pricing, OLCC search
search/ Full-text + geo search across bars and whiskeys
submit/ Upload a bar menu (URL, image, or PDF)
bottles/ Oregon OLCC liquor store bottle search
dashboard/ Bar owner portal — claim bars, manage whiskey menus
auth/ Email/password sign-in, sign-up, OAuth callback
components/
features/ BarCard, WhiskeyCard, MapView, NearbyBars, SearchBar, ...
ui/ Design system primitives (Button, Input, Badge, Card)
lib/
trawler/ AI extraction pipeline (crawl → extract → normalize → ingest)
liquor-search/ OLCC state liquor store integration (extensible to other states)
supabase/ DB clients, server/client helpers
geo.ts Pacific NW geofencing, Haversine distance, region detection
hooks/ useLocation, useDebounce
types/ Shared TypeScript types (database, trawler, geo)
scripts/
google-places-enrich Google Places discovery + photo OCR pipeline
enrich-portland-bars AI knowledge-based enrichment
seed-whiskey-reference Comprehensive whiskey catalog seeder
trawl-portland Batch website trawler for Portland bars
audit-data Data quality audit tool
supabase/
migrations/ Sequential SQL migrations (schema, engagement, RLS, seeds)

Database

bars ──────────── bar_whiskeys ──────────── whiskeys
PostGIS location price, pour_size type (bourbon, scotch, rye, ...)
Google place_id confidence, source_type distillery, region, country
rating, reviews available, last_verified age, abv, normalized_name
sightings ─── user reports: "I saw X at Y for $Z" (triggers auto bar_whiskey upsert)
confirmations ─── crowdsourced "still here" / "not found" verification
trawl_jobs ─── extraction pipeline tracking with provenance + content hashes
user_profiles ─── session + optional auth linkage, roles (user/bar_owner/admin)
bar_claims ─── bar ownership requests (pending → approved/rejected)
  • Row Level Security on all tables — public reads, service role for core data writes, session-based ownership for user-generated content
  • PostGIS GIST spatial indexes for geo-queries (ST_DWithin)
  • Full-text search via tsvector + GIN indexes on bars and whiskeys
  • Trigram indexes (pg_trgm) for fuzzy name matching
  • Custom PostgreSQL RPCs: search_bars and search_whiskeys combine full-text + geospatial sorting; nearby_bars for pure geo; bar_activity for UNION ALL activity feeds
  • normalize_whiskey_name() — an IMMUTABLE SQL function mirroring the TypeScript normalization pipeline, used in a GENERATED ALWAYS column for index-backed deduplication

Current Data

MetricCount
Bars (Portland, Seattle, Vancouver)63
Whiskeys cataloged378
Bar–whiskey links715
Whiskey typesbourbon, scotch, rye, irish, japanese, canadian, single malt, blended, PNW craft
Venue categorieswhiskey bar, cocktail bar, restaurant, pub, hotel bar, distillery, brewery, lounge

In a production trawl run, the pipeline crawled 39 Portland bar websites in 185 seconds, extracting structured whiskey data including private barrel picks (e.g., Interurban's private barrel Eagle Rare on draft at $13, E.H. Taylor at $21).

Security

  • SSRF protection — URL validation before any crawl (blocks private IPs, non-HTTP schemes)
  • Input validation — Zod schemas at all API boundaries; coordinate bounds checking
  • File limits — 25MB max upload, mime type validation
  • RLS — all Supabase tables have Row Level Security; public users are read-only
  • No secrets in client — service role key used only server-side

Getting Started

npm install
# Set up environment
cp .env.example .env.local
# Fill in: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY,# SUPABASE_SERVICE_ROLE_KEY, ANTHROPIC_API_KEY, GOOGLE_PLACES_API_KEY# Push schema to Supabase
npx supabase link --project-ref <your-project-ref>
npx supabase db push
# Run
npm run dev # → http://localhost:3000

How It Was Built

This project was built using Claude Code with parallel AI agents, each owning a domain (database, API, UI, geo/search, auth). Agents coordinate through file ownership boundaries and a shared collaboration protocol defined in CLAUDE.md.

An autonomous overseer agent ran alongside the domain agents — triaging the codebase, picking the highest-value next action, executing it, and committing. This kept the project moving forward without manual task assignment.

The trawler pipeline and Google Places enrichment pipeline both use Claude's vision and text capabilities to extract structured whiskey data from unstructured sources — bar websites, menu photos, and Google reviews.

About

Whiskey/spirits discovery platform for Portland, OR (Claude Hackathon March 2026 Project)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

Find a Dram

Know what's on the shelf before you walk through the door.

Find a Dram is a whiskey discovery platform for the Pacific Northwest. Search 60+ bars across Portland, Seattle, and Vancouver, browse 380+ whiskeys, compare prices, and find your next pour — all in one place.

Live demo:findadram.vercel.app


The Problem

The Pacific Northwest has one of the best whiskey scenes in the country — Portland's Multnomah Whiskey Library, Seattle's Canon (4,000+ bottles), Scotch Lodge, Pope House, and dozens more. But there's no good way to answer simple questions:

  • "Which bars near me carry Blanton's?"
  • "What's the whiskey list at that new bar on Division?"
  • "Where can I get a pour of Yamazaki 12 for under $25?"

You'd have to call each bar, check scattered websites, or just show up and hope for the best.

The Solution

Find a Dram aggregates whiskey menus from bars into a single searchable database. You can:

  • Search by whiskey — find every bar carrying a specific bottle, with prices and pour sizes
  • Search by bar — see the full whiskey menu for any bar before you visit
  • Search nearby — use your location to find whiskey bars within walking distance
  • Buy a bottle — search Oregon OLCC liquor store inventory for retail bottles
  • Submit menus — upload a bar's menu URL or photo and our AI extracts every whiskey automatically
  • Verify availability — confirm or flag whiskeys as "not found" to keep data fresh
  • Claim your bar — bar owners can claim their listing and manage their own whiskey menu

How It Works

AI-Powered Menu Extraction

The most technically interesting piece. Given a bar's website URL or a photo of their menu:

URL / Photo / PDF → Crawl + OCR → Claude Extraction → Normalize → Deduplicate → Database
  1. Crawl — Puppeteer (headless Chrome) for JS-rendered menus, plain fetch fallback. SSRF protection blocks private IPs and validates content types.
  2. Extract — Claude parses unstructured menu text into structured JSON: whiskey name, distillery, type, price, pour size, confidence score. Three input modes: HTML text (Haiku), menu photos/back-bar shots (Sonnet vision), and native PDF documents.
  3. Normalize — 10-step pure TypeScript pipeline: Unicode normalization, distillery alias resolution (150+ aliases), ABV/proof stripping, legal category removal, age statement normalization ("12yo" / "12 Year Old" / "12yr" all become "12 year"), and private barrel detection ("Eagle Rare — Private Barrel" separates the pick metadata from the base whiskey).
  4. Deduplicate — three-tier matching: exact normalized name match, Levenshtein fuzzy match (85% threshold), then Claude AI as a final judge for ambiguous near-matches ("Are these the same whiskey?").
  5. Ingest — upserts to Supabase with full provenance: source URL, source type, extraction confidence, content hash (SHA-256) for change detection, and dual timestamps (when we scraped vs. when the source was published).

Supports batch processing (up to 20 URLs), PDF menus, and camera photos of physical menus.

Google Places Discovery

We also built a pipeline that uses the Google Places API to automatically discover whiskey bars in the Portland metro area, pull ratings and review counts, and download menu photos for OCR extraction.

Data Pipeline

Multiple enrichment strategies run together:

SourceMethodConfidence
Bar websitesAI menu extraction (Claude Vision + text)High
Google PlacesPhoto OCR + review miningMedium
AI knowledgeClaude's knowledge of Portland barsLower
CrowdsourcedUser-submitted sightings and confirmationsVaries

Every entry tracks its source type, confidence score, and when it was last verified.

Data Freshness & Crowd Verification

Bar menus change constantly. We built multiple systems to keep data accurate:

  • Staleness warnings — listings not verified in 90+ days, or sourced from data over 90 days old, show an amber warning badge so users know to take pricing with a grain of salt
  • Crowd confirmations — any user can tap "Still here" or "Not found" on a whiskey listing. Two "not found" reports within 7 days automatically marks the whiskey as unavailable (via Postgres trigger).
  • Provenance chain — every bar-whiskey link traces back to its source: the original URL or photo, when we scraped it, when the source was published, and a content hash for change detection on re-crawls
  • Bar owner management — verified bar owners can claim their listing and directly update prices, availability, and pour sizes through a dashboard, with RLS policies enforcing ownership at the database level

OLCC Liquor Store Integration

Oregon's liquor stores are state-controlled with uniform pricing. We reverse-engineered the OLCC (Oregon Liquor and Cannabis Commission) web servlet to query their product catalog directly — POSTing through the age gate to establish a session cookie, then parsing the HTML product table for item codes, descriptions, sizes, proof, and bottle prices. The integration handles cookie extraction differences across Node.js runtimes (Vercel vs. local) and defaults to a statewide search when no ZIP code is provided.

Tech Stack

LayerTechnology
FrameworkNext.js 16 (App Router, Server Components)
DatabaseSupabase (PostgreSQL + PostGIS for geo-queries)
AIClaude API — Haiku for classification, Sonnet for extraction and vision
External DataGoogle Places API — bar discovery, ratings, photo OCR
MapsLeaflet + react-leaflet
StylingTailwind CSS v4 with custom whiskey/oak color palette
ValidationZod v4
LanguageTypeScript (strict mode)
HostingVercel

Architecture

src/
app/ Next.js App Router
api/ REST endpoints (search, trawl, geocode, sightings)
bars/[id]/ Bar detail — full whiskey menu, map, activity feed
whiskeys/[id]/ Whiskey detail — where to find it, pricing, OLCC search
search/ Full-text + geo search across bars and whiskeys
submit/ Upload a bar menu (URL, image, or PDF)
bottles/ Oregon OLCC liquor store bottle search
dashboard/ Bar owner portal — claim bars, manage whiskey menus
auth/ Email/password sign-in, sign-up, OAuth callback
components/
features/ BarCard, WhiskeyCard, MapView, NearbyBars, SearchBar, ...
ui/ Design system primitives (Button, Input, Badge, Card)
lib/
trawler/ AI extraction pipeline (crawl → extract → normalize → ingest)
liquor-search/ OLCC state liquor store integration (extensible to other states)
supabase/ DB clients, server/client helpers
geo.ts Pacific NW geofencing, Haversine distance, region detection
hooks/ useLocation, useDebounce
types/ Shared TypeScript types (database, trawler, geo)
scripts/
google-places-enrich Google Places discovery + photo OCR pipeline
enrich-portland-bars AI knowledge-based enrichment
seed-whiskey-reference Comprehensive whiskey catalog seeder
trawl-portland Batch website trawler for Portland bars
audit-data Data quality audit tool
supabase/
migrations/ Sequential SQL migrations (schema, engagement, RLS, seeds)

Database

bars ──────────── bar_whiskeys ──────────── whiskeys
PostGIS location price, pour_size type (bourbon, scotch, rye, ...)
Google place_id confidence, source_type distillery, region, country
rating, reviews available, last_verified age, abv, normalized_name
sightings ─── user reports: "I saw X at Y for $Z" (triggers auto bar_whiskey upsert)
confirmations ─── crowdsourced "still here" / "not found" verification
trawl_jobs ─── extraction pipeline tracking with provenance + content hashes
user_profiles ─── session + optional auth linkage, roles (user/bar_owner/admin)
bar_claims ─── bar ownership requests (pending → approved/rejected)
  • Row Level Security on all tables — public reads, service role for core data writes, session-based ownership for user-generated content
  • PostGIS GIST spatial indexes for geo-queries (ST_DWithin)
  • Full-text search via tsvector + GIN indexes on bars and whiskeys
  • Trigram indexes (pg_trgm) for fuzzy name matching
  • Custom PostgreSQL RPCs: search_bars and search_whiskeys combine full-text + geospatial sorting; nearby_bars for pure geo; bar_activity for UNION ALL activity feeds
  • normalize_whiskey_name() — an IMMUTABLE SQL function mirroring the TypeScript normalization pipeline, used in a GENERATED ALWAYS column for index-backed deduplication

Current Data

MetricCount
Bars (Portland, Seattle, Vancouver)63
Whiskeys cataloged378
Bar–whiskey links715
Whiskey typesbourbon, scotch, rye, irish, japanese, canadian, single malt, blended, PNW craft
Venue categorieswhiskey bar, cocktail bar, restaurant, pub, hotel bar, distillery, brewery, lounge

In a production trawl run, the pipeline crawled 39 Portland bar websites in 185 seconds, extracting structured whiskey data including private barrel picks (e.g., Interurban's private barrel Eagle Rare on draft at $13, E.H. Taylor at $21).

Security

  • SSRF protection — URL validation before any crawl (blocks private IPs, non-HTTP schemes)
  • Input validation — Zod schemas at all API boundaries; coordinate bounds checking
  • File limits — 25MB max upload, mime type validation
  • RLS — all Supabase tables have Row Level Security; public users are read-only
  • No secrets in client — service role key used only server-side

Getting Started

npm install
# Set up environment
cp .env.example .env.local
# Fill in: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY,# SUPABASE_SERVICE_ROLE_KEY, ANTHROPIC_API_KEY, GOOGLE_PLACES_API_KEY# Push schema to Supabase
npx supabase link --project-ref <your-project-ref>
npx supabase db push
# Run
npm run dev # → http://localhost:3000

How It Was Built

This project was built using Claude Code with parallel AI agents, each owning a domain (database, API, UI, geo/search, auth). Agents coordinate through file ownership boundaries and a shared collaboration protocol defined in CLAUDE.md.

An autonomous overseer agent ran alongside the domain agents — triaging the codebase, picking the highest-value next action, executing it, and committing. This kept the project moving forward without manual task assignment.

The trawler pipeline and Google Places enrichment pipeline both use Claude's vision and text capabilities to extract structured whiskey data from unstructured sources — bar websites, menu photos, and Google reviews.

About

Whiskey/spirits discovery platform for Portland, OR (Claude Hackathon March 2026 Project)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Find a Dram

Know what's on the shelf before you walk through the door.

Find a Dram is a whiskey discovery platform for the Pacific Northwest. Search 60+ bars across Portland, Seattle, and Vancouver, browse 380+ whiskeys, compare prices, and find your next pour — all in one place.

Live demo:findadram.vercel.app


The Problem

The Pacific Northwest has one of the best whiskey scenes in the country — Portland's Multnomah Whiskey Library, Seattle's Canon (4,000+ bottles), Scotch Lodge, Pope House, and dozens more. But there's no good way to answer simple questions:

  • "Which bars near me carry Blanton's?"
  • "What's the whiskey list at that new bar on Division?"
  • "Where can I get a pour of Yamazaki 12 for under $25?"

You'd have to call each bar, check scattered websites, or just show up and hope for the best.

The Solution

Find a Dram aggregates whiskey menus from bars into a single searchable database. You can:

  • Search by whiskey — find every bar carrying a specific bottle, with prices and pour sizes
  • Search by bar — see the full whiskey menu for any bar before you visit
  • Search nearby — use your location to find whiskey bars within walking distance
  • Buy a bottle — search Oregon OLCC liquor store inventory for retail bottles
  • Submit menus — upload a bar's menu URL or photo and our AI extracts every whiskey automatically
  • Verify availability — confirm or flag whiskeys as "not found" to keep data fresh
  • Claim your bar — bar owners can claim their listing and manage their own whiskey menu

How It Works

AI-Powered Menu Extraction

The most technically interesting piece. Given a bar's website URL or a photo of their menu:

URL / Photo / PDF → Crawl + OCR → Claude Extraction → Normalize → Deduplicate → Database
  1. Crawl — Puppeteer (headless Chrome) for JS-rendered menus, plain fetch fallback. SSRF protection blocks private IPs and validates content types.
  2. Extract — Claude parses unstructured menu text into structured JSON: whiskey name, distillery, type, price, pour size, confidence score. Three input modes: HTML text (Haiku), menu photos/back-bar shots (Sonnet vision), and native PDF documents.
  3. Normalize — 10-step pure TypeScript pipeline: Unicode normalization, distillery alias resolution (150+ aliases), ABV/proof stripping, legal category removal, age statement normalization ("12yo" / "12 Year Old" / "12yr" all become "12 year"), and private barrel detection ("Eagle Rare — Private Barrel" separates the pick metadata from the base whiskey).
  4. Deduplicate — three-tier matching: exact normalized name match, Levenshtein fuzzy match (85% threshold), then Claude AI as a final judge for ambiguous near-matches ("Are these the same whiskey?").
  5. Ingest — upserts to Supabase with full provenance: source URL, source type, extraction confidence, content hash (SHA-256) for change detection, and dual timestamps (when we scraped vs. when the source was published).

Supports batch processing (up to 20 URLs), PDF menus, and camera photos of physical menus.

Google Places Discovery

We also built a pipeline that uses the Google Places API to automatically discover whiskey bars in the Portland metro area, pull ratings and review counts, and download menu photos for OCR extraction.

Data Pipeline

Multiple enrichment strategies run together:

SourceMethodConfidence
Bar websitesAI menu extraction (Claude Vision + text)High
Google PlacesPhoto OCR + review miningMedium
AI knowledgeClaude's knowledge of Portland barsLower
CrowdsourcedUser-submitted sightings and confirmationsVaries

Every entry tracks its source type, confidence score, and when it was last verified.

Data Freshness & Crowd Verification

Bar menus change constantly. We built multiple systems to keep data accurate:

  • Staleness warnings — listings not verified in 90+ days, or sourced from data over 90 days old, show an amber warning badge so users know to take pricing with a grain of salt
  • Crowd confirmations — any user can tap "Still here" or "Not found" on a whiskey listing. Two "not found" reports within 7 days automatically marks the whiskey as unavailable (via Postgres trigger).
  • Provenance chain — every bar-whiskey link traces back to its source: the original URL or photo, when we scraped it, when the source was published, and a content hash for change detection on re-crawls
  • Bar owner management — verified bar owners can claim their listing and directly update prices, availability, and pour sizes through a dashboard, with RLS policies enforcing ownership at the database level

OLCC Liquor Store Integration

Oregon's liquor stores are state-controlled with uniform pricing. We reverse-engineered the OLCC (Oregon Liquor and Cannabis Commission) web servlet to query their product catalog directly — POSTing through the age gate to establish a session cookie, then parsing the HTML product table for item codes, descriptions, sizes, proof, and bottle prices. The integration handles cookie extraction differences across Node.js runtimes (Vercel vs. local) and defaults to a statewide search when no ZIP code is provided.

Tech Stack

LayerTechnology
FrameworkNext.js 16 (App Router, Server Components)
DatabaseSupabase (PostgreSQL + PostGIS for geo-queries)
AIClaude API — Haiku for classification, Sonnet for extraction and vision
External DataGoogle Places API — bar discovery, ratings, photo OCR
MapsLeaflet + react-leaflet
StylingTailwind CSS v4 with custom whiskey/oak color palette
ValidationZod v4
LanguageTypeScript (strict mode)
HostingVercel

Architecture

src/
app/ Next.js App Router
api/ REST endpoints (search, trawl, geocode, sightings)
bars/[id]/ Bar detail — full whiskey menu, map, activity feed
whiskeys/[id]/ Whiskey detail — where to find it, pricing, OLCC search
search/ Full-text + geo search across bars and whiskeys
submit/ Upload a bar menu (URL, image, or PDF)
bottles/ Oregon OLCC liquor store bottle search
dashboard/ Bar owner portal — claim bars, manage whiskey menus
auth/ Email/password sign-in, sign-up, OAuth callback
components/
features/ BarCard, WhiskeyCard, MapView, NearbyBars, SearchBar, ...
ui/ Design system primitives (Button, Input, Badge, Card)
lib/
trawler/ AI extraction pipeline (crawl → extract → normalize → ingest)
liquor-search/ OLCC state liquor store integration (extensible to other states)
supabase/ DB clients, server/client helpers
geo.ts Pacific NW geofencing, Haversine distance, region detection
hooks/ useLocation, useDebounce
types/ Shared TypeScript types (database, trawler, geo)
scripts/
google-places-enrich Google Places discovery + photo OCR pipeline
enrich-portland-bars AI knowledge-based enrichment
seed-whiskey-reference Comprehensive whiskey catalog seeder
trawl-portland Batch website trawler for Portland bars
audit-data Data quality audit tool
supabase/
migrations/ Sequential SQL migrations (schema, engagement, RLS, seeds)

Database

bars ──────────── bar_whiskeys ──────────── whiskeys
PostGIS location price, pour_size type (bourbon, scotch, rye, ...)
Google place_id confidence, source_type distillery, region, country
rating, reviews available, last_verified age, abv, normalized_name
sightings ─── user reports: "I saw X at Y for $Z" (triggers auto bar_whiskey upsert)
confirmations ─── crowdsourced "still here" / "not found" verification
trawl_jobs ─── extraction pipeline tracking with provenance + content hashes
user_profiles ─── session + optional auth linkage, roles (user/bar_owner/admin)
bar_claims ─── bar ownership requests (pending → approved/rejected)
  • Row Level Security on all tables — public reads, service role for core data writes, session-based ownership for user-generated content
  • PostGIS GIST spatial indexes for geo-queries (ST_DWithin)
  • Full-text search via tsvector + GIN indexes on bars and whiskeys
  • Trigram indexes (pg_trgm) for fuzzy name matching
  • Custom PostgreSQL RPCs: search_bars and search_whiskeys combine full-text + geospatial sorting; nearby_bars for pure geo; bar_activity for UNION ALL activity feeds
  • normalize_whiskey_name() — an IMMUTABLE SQL function mirroring the TypeScript normalization pipeline, used in a GENERATED ALWAYS column for index-backed deduplication

Current Data

MetricCount
Bars (Portland, Seattle, Vancouver)63
Whiskeys cataloged378
Bar–whiskey links715
Whiskey typesbourbon, scotch, rye, irish, japanese, canadian, single malt, blended, PNW craft
Venue categorieswhiskey bar, cocktail bar, restaurant, pub, hotel bar, distillery, brewery, lounge

In a production trawl run, the pipeline crawled 39 Portland bar websites in 185 seconds, extracting structured whiskey data including private barrel picks (e.g., Interurban's private barrel Eagle Rare on draft at $13, E.H. Taylor at $21).

Security

  • SSRF protection — URL validation before any crawl (blocks private IPs, non-HTTP schemes)
  • Input validation — Zod schemas at all API boundaries; coordinate bounds checking
  • File limits — 25MB max upload, mime type validation
  • RLS — all Supabase tables have Row Level Security; public users are read-only
  • No secrets in client — service role key used only server-side

Getting Started

npm install
# Set up environment
cp .env.example .env.local
# Fill in: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY,# SUPABASE_SERVICE_ROLE_KEY, ANTHROPIC_API_KEY, GOOGLE_PLACES_API_KEY# Push schema to Supabase
npx supabase link --project-ref <your-project-ref>
npx supabase db push
# Run
npm run dev # → http://localhost:3000

How It Was Built

This project was built using Claude Code with parallel AI agents, each owning a domain (database, API, UI, geo/search, auth). Agents coordinate through file ownership boundaries and a shared collaboration protocol defined in CLAUDE.md.

An autonomous overseer agent ran alongside the domain agents — triaging the codebase, picking the highest-value next action, executing it, and committing. This kept the project moving forward without manual task assignment.

The trawler pipeline and Google Places enrichment pipeline both use Claude's vision and text capabilities to extract structured whiskey data from unstructured sources — bar websites, menu photos, and Google reviews.

About

Whiskey/spirits discovery platform for Portland, OR (Claude Hackathon March 2026 Project)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Find a Dram

Know what's on the shelf before you walk through the door.

Find a Dram is a whiskey discovery platform for the Pacific Northwest. Search 60+ bars across Portland, Seattle, and Vancouver, browse 380+ whiskeys, compare prices, and find your next pour — all in one place.

Live demo:findadram.vercel.app


The Problem

The Pacific Northwest has one of the best whiskey scenes in the country — Portland's Multnomah Whiskey Library, Seattle's Canon (4,000+ bottles), Scotch Lodge, Pope House, and dozens more. But there's no good way to answer simple questions:

  • "Which bars near me carry Blanton's?"
  • "What's the whiskey list at that new bar on Division?"
  • "Where can I get a pour of Yamazaki 12 for under $25?"

You'd have to call each bar, check scattered websites, or just show up and hope for the best.

The Solution

Find a Dram aggregates whiskey menus from bars into a single searchable database. You can:

  • Search by whiskey — find every bar carrying a specific bottle, with prices and pour sizes
  • Search by bar — see the full whiskey menu for any bar before you visit
  • Search nearby — use your location to find whiskey bars within walking distance
  • Buy a bottle — search Oregon OLCC liquor store inventory for retail bottles
  • Submit menus — upload a bar's menu URL or photo and our AI extracts every whiskey automatically
  • Verify availability — confirm or flag whiskeys as "not found" to keep data fresh
  • Claim your bar — bar owners can claim their listing and manage their own whiskey menu

How It Works

AI-Powered Menu Extraction

The most technically interesting piece. Given a bar's website URL or a photo of their menu:

URL / Photo / PDF → Crawl + OCR → Claude Extraction → Normalize → Deduplicate → Database
  1. Crawl — Puppeteer (headless Chrome) for JS-rendered menus, plain fetch fallback. SSRF protection blocks private IPs and validates content types.
  2. Extract — Claude parses unstructured menu text into structured JSON: whiskey name, distillery, type, price, pour size, confidence score. Three input modes: HTML text (Haiku), menu photos/back-bar shots (Sonnet vision), and native PDF documents.
  3. Normalize — 10-step pure TypeScript pipeline: Unicode normalization, distillery alias resolution (150+ aliases), ABV/proof stripping, legal category removal, age statement normalization ("12yo" / "12 Year Old" / "12yr" all become "12 year"), and private barrel detection ("Eagle Rare — Private Barrel" separates the pick metadata from the base whiskey).
  4. Deduplicate — three-tier matching: exact normalized name match, Levenshtein fuzzy match (85% threshold), then Claude AI as a final judge for ambiguous near-matches ("Are these the same whiskey?").
  5. Ingest — upserts to Supabase with full provenance: source URL, source type, extraction confidence, content hash (SHA-256) for change detection, and dual timestamps (when we scraped vs. when the source was published).

Supports batch processing (up to 20 URLs), PDF menus, and camera photos of physical menus.

Google Places Discovery

We also built a pipeline that uses the Google Places API to automatically discover whiskey bars in the Portland metro area, pull ratings and review counts, and download menu photos for OCR extraction.

Data Pipeline

Multiple enrichment strategies run together:

SourceMethodConfidence
Bar websitesAI menu extraction (Claude Vision + text)High
Google PlacesPhoto OCR + review miningMedium
AI knowledgeClaude's knowledge of Portland barsLower
CrowdsourcedUser-submitted sightings and confirmationsVaries

Every entry tracks its source type, confidence score, and when it was last verified.

Data Freshness & Crowd Verification

Bar menus change constantly. We built multiple systems to keep data accurate:

  • Staleness warnings — listings not verified in 90+ days, or sourced from data over 90 days old, show an amber warning badge so users know to take pricing with a grain of salt
  • Crowd confirmations — any user can tap "Still here" or "Not found" on a whiskey listing. Two "not found" reports within 7 days automatically marks the whiskey as unavailable (via Postgres trigger).
  • Provenance chain — every bar-whiskey link traces back to its source: the original URL or photo, when we scraped it, when the source was published, and a content hash for change detection on re-crawls
  • Bar owner management — verified bar owners can claim their listing and directly update prices, availability, and pour sizes through a dashboard, with RLS policies enforcing ownership at the database level

OLCC Liquor Store Integration

Oregon's liquor stores are state-controlled with uniform pricing. We reverse-engineered the OLCC (Oregon Liquor and Cannabis Commission) web servlet to query their product catalog directly — POSTing through the age gate to establish a session cookie, then parsing the HTML product table for item codes, descriptions, sizes, proof, and bottle prices. The integration handles cookie extraction differences across Node.js runtimes (Vercel vs. local) and defaults to a statewide search when no ZIP code is provided.

Tech Stack

LayerTechnology
FrameworkNext.js 16 (App Router, Server Components)
DatabaseSupabase (PostgreSQL + PostGIS for geo-queries)
AIClaude API — Haiku for classification, Sonnet for extraction and vision
External DataGoogle Places API — bar discovery, ratings, photo OCR
MapsLeaflet + react-leaflet
StylingTailwind CSS v4 with custom whiskey/oak color palette
ValidationZod v4
LanguageTypeScript (strict mode)
HostingVercel

Architecture

src/
app/ Next.js App Router
api/ REST endpoints (search, trawl, geocode, sightings)
bars/[id]/ Bar detail — full whiskey menu, map, activity feed
whiskeys/[id]/ Whiskey detail — where to find it, pricing, OLCC search
search/ Full-text + geo search across bars and whiskeys
submit/ Upload a bar menu (URL, image, or PDF)
bottles/ Oregon OLCC liquor store bottle search
dashboard/ Bar owner portal — claim bars, manage whiskey menus
auth/ Email/password sign-in, sign-up, OAuth callback
components/
features/ BarCard, WhiskeyCard, MapView, NearbyBars, SearchBar, ...
ui/ Design system primitives (Button, Input, Badge, Card)
lib/
trawler/ AI extraction pipeline (crawl → extract → normalize → ingest)
liquor-search/ OLCC state liquor store integration (extensible to other states)
supabase/ DB clients, server/client helpers
geo.ts Pacific NW geofencing, Haversine distance, region detection
hooks/ useLocation, useDebounce
types/ Shared TypeScript types (database, trawler, geo)
scripts/
google-places-enrich Google Places discovery + photo OCR pipeline
enrich-portland-bars AI knowledge-based enrichment
seed-whiskey-reference Comprehensive whiskey catalog seeder
trawl-portland Batch website trawler for Portland bars
audit-data Data quality audit tool
supabase/
migrations/ Sequential SQL migrations (schema, engagement, RLS, seeds)

Database

bars ──────────── bar_whiskeys ──────────── whiskeys
PostGIS location price, pour_size type (bourbon, scotch, rye, ...)
Google place_id confidence, source_type distillery, region, country
rating, reviews available, last_verified age, abv, normalized_name
sightings ─── user reports: "I saw X at Y for $Z" (triggers auto bar_whiskey upsert)
confirmations ─── crowdsourced "still here" / "not found" verification
trawl_jobs ─── extraction pipeline tracking with provenance + content hashes
user_profiles ─── session + optional auth linkage, roles (user/bar_owner/admin)
bar_claims ─── bar ownership requests (pending → approved/rejected)
  • Row Level Security on all tables — public reads, service role for core data writes, session-based ownership for user-generated content
  • PostGIS GIST spatial indexes for geo-queries (ST_DWithin)
  • Full-text search via tsvector + GIN indexes on bars and whiskeys
  • Trigram indexes (pg_trgm) for fuzzy name matching
  • Custom PostgreSQL RPCs: search_bars and search_whiskeys combine full-text + geospatial sorting; nearby_bars for pure geo; bar_activity for UNION ALL activity feeds
  • normalize_whiskey_name() — an IMMUTABLE SQL function mirroring the TypeScript normalization pipeline, used in a GENERATED ALWAYS column for index-backed deduplication

Current Data

MetricCount
Bars (Portland, Seattle, Vancouver)63
Whiskeys cataloged378
Bar–whiskey links715
Whiskey typesbourbon, scotch, rye, irish, japanese, canadian, single malt, blended, PNW craft
Venue categorieswhiskey bar, cocktail bar, restaurant, pub, hotel bar, distillery, brewery, lounge

In a production trawl run, the pipeline crawled 39 Portland bar websites in 185 seconds, extracting structured whiskey data including private barrel picks (e.g., Interurban's private barrel Eagle Rare on draft at $13, E.H. Taylor at $21).

Security

  • SSRF protection — URL validation before any crawl (blocks private IPs, non-HTTP schemes)
  • Input validation — Zod schemas at all API boundaries; coordinate bounds checking
  • File limits — 25MB max upload, mime type validation
  • RLS — all Supabase tables have Row Level Security; public users are read-only
  • No secrets in client — service role key used only server-side

Getting Started

npm install
# Set up environment
cp .env.example .env.local
# Fill in: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY,# SUPABASE_SERVICE_ROLE_KEY, ANTHROPIC_API_KEY, GOOGLE_PLACES_API_KEY# Push schema to Supabase
npx supabase link --project-ref <your-project-ref>
npx supabase db push
# Run
npm run dev # → http://localhost:3000

How It Was Built

This project was built using Claude Code with parallel AI agents, each owning a domain (database, API, UI, geo/search, auth). Agents coordinate through file ownership boundaries and a shared collaboration protocol defined in CLAUDE.md.

An autonomous overseer agent ran alongside the domain agents — triaging the codebase, picking the highest-value next action, executing it, and committing. This kept the project moving forward without manual task assignment.

The trawler pipeline and Google Places enrichment pipeline both use Claude's vision and text capabilities to extract structured whiskey data from unstructured sources — bar websites, menu photos, and Google reviews.

About

Whiskey/spirits discovery platform for Portland, OR (Claude Hackathon March 2026 Project)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

Find a Dram

Know what's on the shelf before you walk through the door.

Find a Dram is a whiskey discovery platform for the Pacific Northwest. Search 60+ bars across Portland, Seattle, and Vancouver, browse 380+ whiskeys, compare prices, and find your next pour — all in one place.

Live demo:findadram.vercel.app


The Problem

The Pacific Northwest has one of the best whiskey scenes in the country — Portland's Multnomah Whiskey Library, Seattle's Canon (4,000+ bottles), Scotch Lodge, Pope House, and dozens more. But there's no good way to answer simple questions:

  • "Which bars near me carry Blanton's?"
  • "What's the whiskey list at that new bar on Division?"
  • "Where can I get a pour of Yamazaki 12 for under $25?"

You'd have to call each bar, check scattered websites, or just show up and hope for the best.

The Solution

Find a Dram aggregates whiskey menus from bars into a single searchable database. You can:

  • Search by whiskey — find every bar carrying a specific bottle, with prices and pour sizes
  • Search by bar — see the full whiskey menu for any bar before you visit
  • Search nearby — use your location to find whiskey bars within walking distance
  • Buy a bottle — search Oregon OLCC liquor store inventory for retail bottles
  • Submit menus — upload a bar's menu URL or photo and our AI extracts every whiskey automatically
  • Verify availability — confirm or flag whiskeys as "not found" to keep data fresh
  • Claim your bar — bar owners can claim their listing and manage their own whiskey menu

How It Works

AI-Powered Menu Extraction

The most technically interesting piece. Given a bar's website URL or a photo of their menu:

URL / Photo / PDF → Crawl + OCR → Claude Extraction → Normalize → Deduplicate → Database
  1. Crawl — Puppeteer (headless Chrome) for JS-rendered menus, plain fetch fallback. SSRF protection blocks private IPs and validates content types.
  2. Extract — Claude parses unstructured menu text into structured JSON: whiskey name, distillery, type, price, pour size, confidence score. Three input modes: HTML text (Haiku), menu photos/back-bar shots (Sonnet vision), and native PDF documents.
  3. Normalize — 10-step pure TypeScript pipeline: Unicode normalization, distillery alias resolution (150+ aliases), ABV/proof stripping, legal category removal, age statement normalization ("12yo" / "12 Year Old" / "12yr" all become "12 year"), and private barrel detection ("Eagle Rare — Private Barrel" separates the pick metadata from the base whiskey).
  4. Deduplicate — three-tier matching: exact normalized name match, Levenshtein fuzzy match (85% threshold), then Claude AI as a final judge for ambiguous near-matches ("Are these the same whiskey?").
  5. Ingest — upserts to Supabase with full provenance: source URL, source type, extraction confidence, content hash (SHA-256) for change detection, and dual timestamps (when we scraped vs. when the source was published).

Supports batch processing (up to 20 URLs), PDF menus, and camera photos of physical menus.

Google Places Discovery

We also built a pipeline that uses the Google Places API to automatically discover whiskey bars in the Portland metro area, pull ratings and review counts, and download menu photos for OCR extraction.

Data Pipeline

Multiple enrichment strategies run together:

SourceMethodConfidence
Bar websitesAI menu extraction (Claude Vision + text)High
Google PlacesPhoto OCR + review miningMedium
AI knowledgeClaude's knowledge of Portland barsLower
CrowdsourcedUser-submitted sightings and confirmationsVaries

Every entry tracks its source type, confidence score, and when it was last verified.

Data Freshness & Crowd Verification

Bar menus change constantly. We built multiple systems to keep data accurate:

  • Staleness warnings — listings not verified in 90+ days, or sourced from data over 90 days old, show an amber warning badge so users know to take pricing with a grain of salt
  • Crowd confirmations — any user can tap "Still here" or "Not found" on a whiskey listing. Two "not found" reports within 7 days automatically marks the whiskey as unavailable (via Postgres trigger).
  • Provenance chain — every bar-whiskey link traces back to its source: the original URL or photo, when we scraped it, when the source was published, and a content hash for change detection on re-crawls
  • Bar owner management — verified bar owners can claim their listing and directly update prices, availability, and pour sizes through a dashboard, with RLS policies enforcing ownership at the database level

OLCC Liquor Store Integration

Oregon's liquor stores are state-controlled with uniform pricing. We reverse-engineered the OLCC (Oregon Liquor and Cannabis Commission) web servlet to query their product catalog directly — POSTing through the age gate to establish a session cookie, then parsing the HTML product table for item codes, descriptions, sizes, proof, and bottle prices. The integration handles cookie extraction differences across Node.js runtimes (Vercel vs. local) and defaults to a statewide search when no ZIP code is provided.

Tech Stack

LayerTechnology
FrameworkNext.js 16 (App Router, Server Components)
DatabaseSupabase (PostgreSQL + PostGIS for geo-queries)
AIClaude API — Haiku for classification, Sonnet for extraction and vision
External DataGoogle Places API — bar discovery, ratings, photo OCR
MapsLeaflet + react-leaflet
StylingTailwind CSS v4 with custom whiskey/oak color palette
ValidationZod v4
LanguageTypeScript (strict mode)
HostingVercel

Architecture

src/
app/ Next.js App Router
api/ REST endpoints (search, trawl, geocode, sightings)
bars/[id]/ Bar detail — full whiskey menu, map, activity feed
whiskeys/[id]/ Whiskey detail — where to find it, pricing, OLCC search
search/ Full-text + geo search across bars and whiskeys
submit/ Upload a bar menu (URL, image, or PDF)
bottles/ Oregon OLCC liquor store bottle search
dashboard/ Bar owner portal — claim bars, manage whiskey menus
auth/ Email/password sign-in, sign-up, OAuth callback
components/
features/ BarCard, WhiskeyCard, MapView, NearbyBars, SearchBar, ...
ui/ Design system primitives (Button, Input, Badge, Card)
lib/
trawler/ AI extraction pipeline (crawl → extract → normalize → ingest)
liquor-search/ OLCC state liquor store integration (extensible to other states)
supabase/ DB clients, server/client helpers
geo.ts Pacific NW geofencing, Haversine distance, region detection
hooks/ useLocation, useDebounce
types/ Shared TypeScript types (database, trawler, geo)
scripts/
google-places-enrich Google Places discovery + photo OCR pipeline
enrich-portland-bars AI knowledge-based enrichment
seed-whiskey-reference Comprehensive whiskey catalog seeder
trawl-portland Batch website trawler for Portland bars
audit-data Data quality audit tool
supabase/
migrations/ Sequential SQL migrations (schema, engagement, RLS, seeds)

Database

bars ──────────── bar_whiskeys ──────────── whiskeys
PostGIS location price, pour_size type (bourbon, scotch, rye, ...)
Google place_id confidence, source_type distillery, region, country
rating, reviews available, last_verified age, abv, normalized_name
sightings ─── user reports: "I saw X at Y for $Z" (triggers auto bar_whiskey upsert)
confirmations ─── crowdsourced "still here" / "not found" verification
trawl_jobs ─── extraction pipeline tracking with provenance + content hashes
user_profiles ─── session + optional auth linkage, roles (user/bar_owner/admin)
bar_claims ─── bar ownership requests (pending → approved/rejected)
  • Row Level Security on all tables — public reads, service role for core data writes, session-based ownership for user-generated content
  • PostGIS GIST spatial indexes for geo-queries (ST_DWithin)
  • Full-text search via tsvector + GIN indexes on bars and whiskeys
  • Trigram indexes (pg_trgm) for fuzzy name matching
  • Custom PostgreSQL RPCs: search_bars and search_whiskeys combine full-text + geospatial sorting; nearby_bars for pure geo; bar_activity for UNION ALL activity feeds
  • normalize_whiskey_name() — an IMMUTABLE SQL function mirroring the TypeScript normalization pipeline, used in a GENERATED ALWAYS column for index-backed deduplication

Current Data

MetricCount
Bars (Portland, Seattle, Vancouver)63
Whiskeys cataloged378
Bar–whiskey links715
Whiskey typesbourbon, scotch, rye, irish, japanese, canadian, single malt, blended, PNW craft
Venue categorieswhiskey bar, cocktail bar, restaurant, pub, hotel bar, distillery, brewery, lounge

In a production trawl run, the pipeline crawled 39 Portland bar websites in 185 seconds, extracting structured whiskey data including private barrel picks (e.g., Interurban's private barrel Eagle Rare on draft at $13, E.H. Taylor at $21).

Security

  • SSRF protection — URL validation before any crawl (blocks private IPs, non-HTTP schemes)
  • Input validation — Zod schemas at all API boundaries; coordinate bounds checking
  • File limits — 25MB max upload, mime type validation
  • RLS — all Supabase tables have Row Level Security; public users are read-only
  • No secrets in client — service role key used only server-side

Getting Started

npm install
# Set up environment
cp .env.example .env.local
# Fill in: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY,# SUPABASE_SERVICE_ROLE_KEY, ANTHROPIC_API_KEY, GOOGLE_PLACES_API_KEY# Push schema to Supabase
npx supabase link --project-ref <your-project-ref>
npx supabase db push
# Run
npm run dev # → http://localhost:3000

How It Was Built

This project was built using Claude Code with parallel AI agents, each owning a domain (database, API, UI, geo/search, auth). Agents coordinate through file ownership boundaries and a shared collaboration protocol defined in CLAUDE.md.

An autonomous overseer agent ran alongside the domain agents — triaging the codebase, picking the highest-value next action, executing it, and committing. This kept the project moving forward without manual task assignment.

The trawler pipeline and Google Places enrichment pipeline both use Claude's vision and text capabilities to extract structured whiskey data from unstructured sources — bar websites, menu photos, and Google reviews.

About

Whiskey/spirits discovery platform for Portland, OR (Claude Hackathon March 2026 Project)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages