Back to Skills

fishfry

by ianpcook

Find Pittsburgh-area Lenten fish fries. Search by location, filter by features (homemade pierogies, wheelchair accessible, alcohol, takeout), check schedules, and get venue details. Uses data from Code for Pittsburgh's Fish Fry Map.

1.0.0
$ npx skills add https://github.com/ianpcook/fishfry

Files

SKILL.mdMain
2.8 KB
---
name: fishfry
description: Find Pittsburgh-area Lenten fish fries. Search by location, filter by features (homemade pierogies, wheelchair accessible, alcohol, takeout), check schedules, and get venue details. Uses data from Code for Pittsburgh's Fish Fry Map.
version: 1.0.0
homepage: https://codeforpittsburgh.github.io/fishfrymap/
metadata:
  clawdbot:
    emoji: 🐟
  tags:
    - pittsburgh
    - local
    - food
    - lent
    - civic-data
---

# Pittsburgh Fish Fry Finder

Find Lenten fish fries in the Pittsburgh area. Data sourced from [Code for Pittsburgh's Fish Fry Map](https://codeforpittsburgh.github.io/fishfrymap/).

## Usage

```bash
# Search near a location (zip code, neighborhood, or address)
<skill>/fishfry.py search "15213"
<skill>/fishfry.py search "Squirrel Hill"
<skill>/fishfry.py search "4400 Forbes Ave, Pittsburgh"

# Filter by features
<skill>/fishfry.py search "15217" --pierogies        # Homemade pierogies
<skill>/fishfry.py search "15217" --accessible       # Wheelchair accessible
<skill>/fishfry.py search "15217" --alcohol          # Serves alcohol
<skill>/fishfry.py search "15217" --takeout          # Has takeout

# Combine filters
<skill>/fishfry.py search "South Side" --pierogies --takeout

# List all fish fries
<skill>/fishfry.py list
<skill>/fishfry.py list --pierogies

# Get details for a specific venue
<skill>/fishfry.py details "St. Alphonsus"
<skill>/fishfry.py details "Our Lady of Joy"

# Check what's happening on a specific date
<skill>/fishfry.py schedule friday
<skill>/fishfry.py schedule 2026-03-06

# Update the local data cache
<skill>/fishfry.py update
```

## Output

Results include:
- Venue name and type (Church, Fire Hall, VFW, etc.)
- Address and distance from search location
- Hours of operation
- Menu highlights
- Features (pierogies, accessible, alcohol, takeout)
- Contact info (phone, website)

## Data Source

Data is maintained by volunteers at [Code for Pittsburgh](https://github.com/CodeForPittsburgh/fishfrymap) and published through the [Western PA Regional Data Center](https://data.wprdc.org/dataset/pittsburgh-fish-fry-map).

The skill caches data locally and can be updated with `fishfry.py update`.

## Seasonal Note

Fish fries run during Lent (typically late February through mid-April). Outside of Lent season, the data reflects the previous year's schedule. Check venue websites for current year confirmation.

## Example Queries

**"Find fish fries near me with homemade pierogies"**
```bash
fishfry.py search "15232" --pierogies
```

**"What fish fries are in the South Hills?"**
```bash
fishfry.py search "South Hills"
```

**"Tell me about the fish fry at St. Basil's"**
```bash
fishfry.py details "St. Basil"
```

**"Which places have takeout and are wheelchair accessible?"**
```bash
fishfry.py list --takeout --accessible
```
fishfry.py
16.6 KB
#!/usr/bin/env python3
"""
Pittsburgh Fish Fry Finder

Find Lenten fish fries in the Pittsburgh area using data from Code for Pittsburgh.

Usage:
    fishfry.py search <location> [--pierogies] [--accessible] [--alcohol] [--takeout]
    fishfry.py list [--pierogies] [--accessible] [--alcohol] [--takeout]
    fishfry.py details <venue_name>
    fishfry.py schedule [<date>]
    fishfry.py update

Data source: https://codeforpittsburgh.github.io/fishfrymap/
"""

import argparse
import json
import math
import os
import re
import sys
from datetime import datetime, timedelta
from pathlib import Path
from urllib.request import urlopen, Request
from urllib.error import HTTPError, URLError
from urllib.parse import quote_plus

# Data source
GEOJSON_URL = "https://raw.githubusercontent.com/CodeForPittsburgh/fishfrymap/master/data/fishfrymap.geojson"

# Cache location
CACHE_DIR = Path(__file__).parent / ".cache"
CACHE_FILE = CACHE_DIR / "fishfry_data.json"
CACHE_MAX_AGE_DAYS = 7


def get_cache_path() -> Path:
    """Get the cache file path, creating directory if needed."""
    CACHE_DIR.mkdir(exist_ok=True)
    return CACHE_FILE


def load_data(force_update: bool = False) -> list[dict]:
    """Load fish fry data from cache or fetch fresh."""
    cache_path = get_cache_path()
    
    # Check if cache exists and is fresh
    if not force_update and cache_path.exists():
        cache_age = datetime.now().timestamp() - cache_path.stat().st_mtime
        if cache_age < CACHE_MAX_AGE_DAYS * 24 * 60 * 60:
            try:
                with open(cache_path) as f:
                    return json.load(f)
            except (json.JSONDecodeError, IOError):
                pass
    
    # Fetch fresh data
    print("Fetching fresh fish fry data...", file=sys.stderr)
    try:
        req = Request(GEOJSON_URL, headers={"User-Agent": "FishFry-Skill/1.0"})
        with urlopen(req, timeout=30) as response:
            geojson = json.loads(response.read().decode())
            
        # Extract and normalize features
        venues = []
        for feature in geojson.get("features", []):
            props = feature.get("properties", {})
            geom = feature.get("geometry", {})
            coords = geom.get("coordinates", [None, None])
            
            venue = {
                "id": feature.get("id"),
                "name": props.get("venue_name", "Unknown"),
                "type": props.get("venue_type", "Unknown"),
                "address": props.get("venue_address", ""),
                "phone": props.get("phone"),
                "email": props.get("email"),
                "website": props.get("website"),
                "notes": props.get("venue_notes"),
                "events": props.get("events", []),
                "menu_text": props.get("menu", {}).get("text") if props.get("menu") else None,
                "menu_url": props.get("menu", {}).get("url") if props.get("menu") else None,
                "homemade_pierogies": props.get("homemade_pierogies"),
                "handicap": props.get("handicap"),
                "alcohol": props.get("alcohol"),
                "take_out": props.get("take_out"),
                "lunch": props.get("lunch"),
                "etc": props.get("etc"),
                "lon": coords[0],
                "lat": coords[1],
                "publish": props.get("publish", True),
            }
            venues.append(venue)
        
        # Cache the data
        with open(cache_path, "w") as f:
            json.dump(venues, f)
        
        print(f"Loaded {len(venues)} fish fry venues.", file=sys.stderr)
        return venues
        
    except (HTTPError, URLError, json.JSONDecodeError) as e:
        print(f"Error fetching data: {e}", file=sys.stderr)
        # Try to use stale cache
        if cache_path.exists():
            print("Using cached data.", file=sys.stderr)
            with open(cache_path) as f:
                return json.load(f)
        return []


def geocode_location(location: str) -> tuple[float, float] | None:
    """
    Convert a location string to coordinates.
    Uses Nominatim (OpenStreetMap) for geocoding.
    """
    # Check if it looks like a zip code
    if re.match(r"^\d{5}$", location.strip()):
        location = f"{location}, PA"
    
    # Add Pittsburgh context if it looks like a neighborhood
    if not any(x in location.lower() for x in ["pa", "pennsylvania", "pittsburgh", ","]):
        location = f"{location}, Pittsburgh, PA"
    
    try:
        url = f"https://nominatim.openstreetmap.org/search?q={quote_plus(location)}&format=json&limit=1"
        req = Request(url, headers={"User-Agent": "FishFry-Skill/1.0"})
        with urlopen(req, timeout=10) as response:
            results = json.loads(response.read().decode())
            if results:
                return float(results[0]["lat"]), float(results[0]["lon"])
    except Exception as e:
        print(f"Geocoding failed: {e}", file=sys.stderr)
    
    return None


def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
    """Calculate distance between two points in miles."""
    R = 3959  # Earth's radius in miles
    
    lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
    dlat = lat2 - lat1
    dlon = lon2 - lon1
    
    a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
    c = 2 * math.asin(math.sqrt(a))
    
    return R * c


def filter_venues(venues: list[dict], 
                  pierogies: bool = False,
                  accessible: bool = False,
                  alcohol: bool = False,
                  takeout: bool = False) -> list[dict]:
    """Filter venues by features."""
    filtered = venues
    
    if pierogies:
        filtered = [v for v in filtered if v.get("homemade_pierogies") == True]
    if accessible:
        filtered = [v for v in filtered if v.get("handicap") == True]
    if alcohol:
        filtered = [v for v in filtered if v.get("alcohol") == True]
    if takeout:
        filtered = [v for v in filtered if v.get("take_out") == True]
    
    return filtered


def format_venue(venue: dict, distance: float | None = None) -> str:
    """Format a venue for display."""
    lines = []
    
    # Header
    name = venue["name"]
    vtype = venue["type"]
    lines.append(f"🐟 {name}")
    lines.append(f"   Type: {vtype}")
    
    # Distance
    if distance is not None:
        lines.append(f"   Distance: {distance:.1f} miles")
    
    # Address
    if venue["address"]:
        lines.append(f"   Address: {venue['address']}")
    
    # Features
    features = []
    if venue.get("homemade_pierogies"):
        features.append("🥟 Homemade Pierogies")
    if venue.get("handicap"):
        features.append("♿ Wheelchair Accessible")
    if venue.get("alcohol"):
        features.append("🍺 Alcohol")
    if venue.get("take_out"):
        features.append("📦 Takeout")
    if venue.get("lunch"):
        features.append("☀️ Lunch")
    
    if features:
        lines.append(f"   Features: {', '.join(features)}")
    
    # Hours/Events
    if venue.get("etc"):
        lines.append(f"   Hours: {venue['etc']}")
    
    # Contact
    if venue.get("phone"):
        lines.append(f"   Phone: {venue['phone']}")
    if venue.get("website"):
        lines.append(f"   Web: {venue['website']}")
    
    return "\n".join(lines)


def format_venue_details(venue: dict) -> str:
    """Format detailed venue information."""
    lines = [format_venue(venue)]
    
    # Menu
    if venue.get("menu_text"):
        lines.append(f"\n   📋 Menu: {venue['menu_text']}")
    if venue.get("menu_url"):
        lines.append(f"   Menu URL: {venue['menu_url']}")
    
    # Notes
    if venue.get("notes"):
        lines.append(f"\n   📝 Notes: {venue['notes']}")
    
    # Events/Schedule
    events = venue.get("events", [])
    if events:
        lines.append("\n   📅 Schedule:")
        for event in events[:10]:  # Limit to 10
            start = event.get("dt_start", "")
            end = event.get("dt_end", "")
            if start:
                try:
                    dt_start = datetime.fromisoformat(start.replace("Z", "+00:00"))
                    dt_end = datetime.fromisoformat(end.replace("Z", "+00:00")) if end else None
                    date_str = dt_start.strftime("%a %b %d")
                    time_str = dt_start.strftime("%I:%M %p").lstrip("0")
                    if dt_end:
                        time_str += f" - {dt_end.strftime('%I:%M %p').lstrip('0')}"
                    lines.append(f"      {date_str}: {time_str}")
                except ValueError:
                    lines.append(f"      {start}")
    
    return "\n".join(lines)


def cmd_search(args):
    """Search for fish fries near a location."""
    venues = load_data()
    if not venues:
        print("No fish fry data available.")
        return 1
    
    # Geocode the location
    coords = geocode_location(args.location)
    if not coords:
        print(f"Could not find location: {args.location}")
        return 1
    
    lat, lon = coords
    print(f"Searching near: {args.location} ({lat:.4f}, {lon:.4f})\n", file=sys.stderr)
    
    # Filter by features
    filtered = filter_venues(
        venues,
        pierogies=args.pierogies,
        accessible=args.accessible,
        alcohol=args.alcohol,
        takeout=args.takeout
    )
    
    # Calculate distances and sort
    results = []
    for venue in filtered:
        if venue.get("lat") and venue.get("lon"):
            dist = haversine_distance(lat, lon, venue["lat"], venue["lon"])
            results.append((venue, dist))
    
    results.sort(key=lambda x: x[1])
    
    # Show results (limit to 15)
    limit = args.limit or 15
    if not results:
        print("No fish fries found matching your criteria.")
        return 0
    
    print(f"Found {len(results)} fish fries. Showing nearest {min(limit, len(results))}:\n")
    
    for venue, dist in results[:limit]:
        print(format_venue(venue, dist))
        print()
    
    return 0


def cmd_list(args):
    """List all fish fries."""
    venues = load_data()
    if not venues:
        print("No fish fry data available.")
        return 1
    
    # Filter by features
    filtered = filter_venues(
        venues,
        pierogies=args.pierogies,
        accessible=args.accessible,
        alcohol=args.alcohol,
        takeout=args.takeout
    )
    
    # Sort by name
    filtered.sort(key=lambda v: v["name"])
    
    if not filtered:
        print("No fish fries found matching your criteria.")
        return 0
    
    print(f"Found {len(filtered)} fish fries:\n")
    
    for venue in filtered:
        print(format_venue(venue))
        print()
    
    return 0


def cmd_details(args):
    """Get details for a specific venue."""
    venues = load_data()
    if not venues:
        print("No fish fry data available.")
        return 1
    
    # Search by name (fuzzy)
    query = args.venue_name.lower()
    matches = [v for v in venues if query in v["name"].lower()]
    
    if not matches:
        print(f"No venue found matching: {args.venue_name}")
        # Suggest similar
        suggestions = [v["name"] for v in venues if any(word in v["name"].lower() for word in query.split())][:5]
        if suggestions:
            print("\nDid you mean:")
            for s in suggestions:
                print(f"  - {s}")
        return 1
    
    if len(matches) > 1:
        print(f"Multiple venues match '{args.venue_name}':\n")
    
    for venue in matches[:5]:
        print(format_venue_details(venue))
        print()
    
    return 0


def cmd_schedule(args):
    """Show fish fries happening on a specific date."""
    venues = load_data()
    if not venues:
        print("No fish fry data available.")
        return 1
    
    # Parse date
    if args.date:
        date_str = args.date.lower()
        if date_str in ("today", "now"):
            target_date = datetime.now().date()
        elif date_str in ("tomorrow",):
            target_date = (datetime.now() + timedelta(days=1)).date()
        elif date_str in ("friday", "fri"):
            today = datetime.now()
            days_until_friday = (4 - today.weekday()) % 7
            if days_until_friday == 0:
                days_until_friday = 7  # Next Friday if today is Friday
            target_date = (today + timedelta(days=days_until_friday)).date()
        else:
            try:
                target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
            except ValueError:
                print(f"Could not parse date: {args.date}")
                print("Use: today, tomorrow, friday, or YYYY-MM-DD")
                return 1
    else:
        # Default to next Friday
        today = datetime.now()
        days_until_friday = (4 - today.weekday()) % 7
        if days_until_friday == 0 and today.hour >= 20:
            days_until_friday = 7
        target_date = (today + timedelta(days=days_until_friday)).date()
    
    print(f"Fish fries on {target_date.strftime('%A, %B %d, %Y')}:\n")
    
    # Find venues with events on that date
    happening = []
    for venue in venues:
        for event in venue.get("events", []):
            try:
                start = datetime.fromisoformat(event["dt_start"].replace("Z", "+00:00"))
                if start.date() == target_date:
                    happening.append((venue, event))
                    break
            except (ValueError, KeyError):
                pass
    
    if not happening:
        print("No fish fries scheduled for this date in our data.")
        print("Note: Event data may be from a previous year. Check venue websites for current schedules.")
        return 0
    
    print(f"Found {len(happening)} fish fries:\n")
    
    for venue, event in happening:
        try:
            start = datetime.fromisoformat(event["dt_start"].replace("Z", "+00:00"))
            end = datetime.fromisoformat(event["dt_end"].replace("Z", "+00:00"))
            time_str = f"{start.strftime('%I:%M %p').lstrip('0')} - {end.strftime('%I:%M %p').lstrip('0')}"
        except:
            time_str = "Check venue"
        
        print(f"🐟 {venue['name']} ({venue['type']})")
        print(f"   Time: {time_str}")
        print(f"   Address: {venue['address']}")
        if venue.get("phone"):
            print(f"   Phone: {venue['phone']}")
        print()
    
    return 0


def cmd_update(args):
    """Update the local data cache."""
    venues = load_data(force_update=True)
    print(f"Updated cache with {len(venues)} venues.")
    return 0


def main():
    parser = argparse.ArgumentParser(
        description="Pittsburgh Fish Fry Finder",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__
    )
    subparsers = parser.add_subparsers(dest="command", help="Commands")
    
    # Search command
    search_parser = subparsers.add_parser("search", help="Search near a location")
    search_parser.add_argument("location", help="Location (zip, neighborhood, or address)")
    search_parser.add_argument("--pierogies", action="store_true", help="Has homemade pierogies")
    search_parser.add_argument("--accessible", action="store_true", help="Wheelchair accessible")
    search_parser.add_argument("--alcohol", action="store_true", help="Serves alcohol")
    search_parser.add_argument("--takeout", action="store_true", help="Has takeout")
    search_parser.add_argument("--limit", "-n", type=int, default=15, help="Max results")
    
    # List command
    list_parser = subparsers.add_parser("list", help="List all fish fries")
    list_parser.add_argument("--pierogies", action="store_true", help="Has homemade pierogies")
    list_parser.add_argument("--accessible", action="store_true", help="Wheelchair accessible")
    list_parser.add_argument("--alcohol", action="store_true", help="Serves alcohol")
    list_parser.add_argument("--takeout", action="store_true", help="Has takeout")
    
    # Details command
    details_parser = subparsers.add_parser("details", help="Get venue details")
    details_parser.add_argument("venue_name", help="Venue name (partial match)")
    
    # Schedule command
    schedule_parser = subparsers.add_parser("schedule", help="Show schedule for a date")
    schedule_parser.add_argument("date", nargs="?", help="Date (today, tomorrow, friday, or YYYY-MM-DD)")
    
    # Update command
    subparsers.add_parser("update", help="Update local data cache")
    
    args = parser.parse_args()
    
    if args.command == "search":
        return cmd_search(args)
    elif args.command == "list":
        return cmd_list(args)
    elif args.command == "details":
        return cmd_details(args)
    elif args.command == "schedule":
        return cmd_schedule(args)
    elif args.command == "update":
        return cmd_update(args)
    else:
        parser.print_help()
        return 0


if __name__ == "__main__":
    sys.exit(main())
data-schema.md
1.7 KB
# Fish Fry Data Schema

The fish fry data is sourced from Code for Pittsburgh's GeoJSON file.

## Venue Properties

| Field | Type | Description |
|-------|------|-------------|
| `venue_name` | string | Name of the venue |
| `venue_type` | string | Type: Church, Fire Department, VFW, Restaurant, etc. |
| `venue_address` | string | Full address |
| `venue_notes` | string | Additional venue-specific notes |
| `phone` | string | Contact phone number |
| `email` | string | Contact email |
| `website` | string | Website URL |
| `events` | array | List of event times (see below) |
| `menu.text` | string | Menu description |
| `menu.url` | string | Link to full menu |
| `homemade_pierogies` | boolean | Has homemade pierogies |
| `handicap` | boolean | Wheelchair accessible |
| `alcohol` | boolean | Serves alcohol |
| `take_out` | boolean | Offers takeout |
| `lunch` | boolean | Open for lunch |
| `etc` | string | Hours and additional info |
| `publish` | boolean | Whether venue should be shown |

## Event Object

Each venue may have multiple events:

```json
{
  "dt_start": "2024-02-16T16:00:00",
  "dt_end": "2024-02-16T19:00:00"
}
```

## Geometry

Each venue has GPS coordinates:

```json
{
  "type": "Point",
  "coordinates": [-79.9519, 40.4406]  // [longitude, latitude]
}
```

## Data Sources

- **Primary**: GitHub raw file (updated by maintainers)
  `https://raw.githubusercontent.com/CodeForPittsburgh/fishfrymap/master/data/fishfrymap.geojson`

- **WPRDC**: Western PA Regional Data Center
  `https://data.wprdc.org/dataset/pittsburgh-fish-fry-map`

## Venue Types

Common venue types in the dataset:
- Church
- Fire Department
- VFW
- American Legion
- Restaurant
- Community Organization
- Market
- School

Compatible Agents

Claude CodeclaudeCodexOpenClawAntigravityGemini

Details

Category
Uncategorized
Version
1.0.0
Stars
0
Added
January 27, 2026
Updated
February 5, 2026

Actions

Download .zip

Upload this .zip to Claude Desktop via Settings → Capabilities → Skills

Vote: