Imported from tealblu/activityfinder (
AGENTS.md). Install upstream withnpx skills add tealblu/activityfinder. Copyright stays with the author.
Activity Finder
Index local things to do ("activities") and recommend them based on search criteria. CLI-only internal Python library.
Quick start
.venv/bin/pip install -e .
.venv/bin/pytest # 84 tests, all pass
.venv/bin/activityfinder --help
IMPORTANT: Keep this file up to date. Whenever you add, remove, rename, or refactor a module, change a dependency, alter the CLI interface, or modify the project structure in any way, update this file to reflect it. Add new sections for new subsystems (e.g., database). This file is the single source of truth for project context — stale entries here mislead future agents.
Architecture
src/activityfinder/
├── __init__.py # Empty package marker
├── __main__.py # Enables `python -m activityfinder`
├── cli.py # Typer CLI (5 commands: add, search, list, geogrid, foursquare-search; --db option)
├── db.py # SQLite persistence layer (activities, reviews, cells_fetched, sources)
├── foursquare.py # Foursquare Places API v3 client (httpx)
├── geocells.py # Geohash grid generation via Nominatim (httpx)
├── indexer.py # In-memory cache backed by a Database instance
├── models.py # Activity, SearchCriteria dataclasses
└── recommender.py # Search/filter against an Indexer instance
- CLI → Recommender → Indexer — decoupled architecture.
models.pyhas zero dependencies beyond stdlib.recommender.pydepends only onindexer.pyandmodels.py— no Click dependency.geocells.pydepends onhttpx(Nominatim geocoding API) but not on Click or other app modules.db.pydepends onmodels.pyand stdlibsqlite3/json— no third-party dependencies.cli.pyusespython-dotenv(load_dotenv()) to auto-load.envfrom the project root — bothFOURSQUARE_API_KEYandACTIVITYFINDER_DBcan be set via.env.- Data persists across invocations (SQLite file, default:
activityfinder.db).
Foursquare (src/activityfinder/foursquare.py)
Foursquare Places API client using httpx. Targets the new places-api.foursquare.com endpoint (migrated from api.foursquare.com/v3/places).
FoursquareClient— main class; configured viaFOURSQUARE_API_KEYenv var (or passed directly). Context-manager compatible.FoursquareError/FoursquareAPIError— custom exceptions (401, 429 handled specifically)- Uses
Authorization: Bearer <key>auth withX-Places-Api-Version: 2025-06-17header - Requests field-restricted responses via
FOURSQUARE_DEFAULT_FIELDS search_places(ll, near, radius, query, fsq_category_ids, limit)— raw API call returning dict resultssearch_by_location(location, query, radius_m, limit, category_ids)— geocode a location string then search, returnslist[tuple[Activity, list[dict]]](activity + its tips)search_by_coords(latitude, longitude, query, radius_m, limit, category_ids, location_name)— search by raw coordinates, also fetches tips for each placesearch_cell(cell, query, radius_m, limit)— search a singleGeocell, returnslist[tuple[Activity, list[dict]]]search_grid(grid, query, limit, radius_m, db)— iterate all cells in aGeogrid, skip already-fetched cells when aDatabaseis providedget_place(fsq_place_id),get_place_photos(fsq_place_id),get_place_tips(fsq_place_id)— detail endpoints (usesfsq_place_idinstead of legacyfsq_id)- Maps Foursquare top-level category IDs →
ActivityCategoryviaFOURSQUARE_CATEGORY_MAP(readsfsq_category_idfield withidfallback for legacy responses) _place_to_activityreturnstuple[Activity, str](activity + fsq_place_id)- Tips are automatically fetched per place during
search_by_coordsand stored as reviews when indexed via theIndexer - CLI integration via
foursquare-searchcommand incli.py
Geocells (src/activityfinder/geocells.py)
Generates a geohash grid for a location via the Nominatim geocoding API (httpx).
Geocelldataclass:geohash,latitude,longitude,precisionGeogriddataclass:location,latitude,longitude,cells: list[Geocell]GeocellsError/GeocodeError— custom exceptionsgenerate_geogrid(location, precision, radius_km)— main entry point; resolves location via Nominatim, auto-picks precision and radius if omitted, generates deduplicated geohash cellsgeocode_location(location) -> (lat, lng)— simple lat/lng lookupresolve_area(query) -> dict— low-level Nominatim lookup returning bounding box, lat/lng, display name, and typefind_cell(cells, latitude, longitude) -> Geocell | None— match a lat/lng to a cell in an existing grid via geohash encoding- Contains internal geohash encode/decode/step helpers (pure Python, no external geohash library)
- Default max grid cells: 10,000
Database (src/activityfinder/db.py)
SQLite persistence layer accessed via Database:
activitiestable — geohash-indexed with lat/lng, category, tags (JSON), cost, times, andexpires_atfor cache-aware expiryreviewstable — linked to activities byactivity_id, with raw text and optional rating for NLP usecells_fetchedtable — tracks which geohash+source combinations have been crawled so APIs aren't re-hit unnecessarilysourcestable — each source has arefresh_cadence_secondsso cache invalidation is source-aware rather than a single global TTL
Source methods:
get_or_create_source(name, refresh_cadence_seconds=86400) -> int— upsert a source, returns its idget_source(name) -> dict | None— lookup a source by namelist_sources() -> list[dict]— all sources sorted by name
Activity methods:
add_activity(activity, ...) -> int— persist an Activity with optional lat/lng/geohashremove_activity(title) -> bool— delete by titleget_activity_by_title(title) -> Activity | Noneget_activity_by_id(id) -> dict | None— raw row lookupall_activities() -> list[Activity]— all activities, newest firstsearch_activities(query, category, max_cost, location, tag)— filter-based SQL searchclear_activities()— delete all
Review methods:
add_review(activity_id, text, rating, author, source_name) -> intget_reviews(activity_id) -> list[dict]
Cell cache:
is_cell_fetched(geohash, source) -> boolmark_cell_fetched(geohash, source)— record a fetch timestampget_stale_cells(source, max_age_seconds=None)— returns cells that exceed their source's refresh cadence (or a manual max age)
Expiry:
get_expired_activities() -> list[Activity]— activities past theirexpires_atremove_expired() -> int— deletes expired activities (concert dates, event end times, etc.)
Type-aware expiry: a concert gets a hard expires_at, a restaurant doesn't, a hiking trail never expires (NULL).
No third-party dependencies — uses stdlib sqlite3 and json.
Conventions
- Python 3.10+ with full type annotations.
pyproject.toml-based project (nosetup.py).src/layout.- Tests use
pytestwith class-based organization (Test*classes,setup_method). - CLI tests use
typer.testing.CliRunner. - Do not add comments to code unless asked.
- Do not create documentation files (
*.md) unless explicitly requested.
Key models (src/activityfinder/models.py)
ActivityCategory — str Enum: sports, arts, music, food, outdoors, education, social, entertainment, other.
Activity dataclass:
title,description,category,location(required)start_time(defaults to now),end_time(optional)cost(float, default 0.0),tags(list[str]),source,urlexpires_at(Optional[datetime], default None) —None= never expires
SearchCriteria dataclass:
query,categories,max_cost,location,tags— all optional
Indexer (src/activityfinder/indexer.py)
Simple in-memory list-based store backed by a Database:
index(activity, tips=None),index_many(activities),remove(title) -> bool,all() -> list[Activity],clear()foursquare_search_and_index(location, query, radius_m, limit, category_ids)— search Foursquare by geocoded location and index all results; stores tips as reviews via_store_tipsfoursquare_grid_search_and_index(location, query, precision, radius_km, radius_m, limit)— generate a geohash grid, search every cell on Foursquare (skipping cells already cached viaDatabase), and index results with tips as reviews- Requires a
Databaseinstance — delegates persistence on every mutation - Both foursquare methods return
list[tuple[Activity, list[dict]]](activity + its Foursquare tips) - CLI integration via
foursquare-searchcommand incli.py
Recommender (src/activityfinder/recommender.py)
Filter-based search; applies each non-empty criterion as a narrowing filter:
- query → substring match on title/description (case-insensitive)
- categories → exact match
- max_cost →
cost <= max_cost - location → substring match (case-insensitive)
- tags → any overlap
CLI (src/activityfinder/cli.py)
Typer group main with five commands:
add— index an activity (requires--title,--description,--location; optional--category,--cost,--tags,--source,--url,--start-time)search— search by--query,--category(repeatable),--max-cost,--location,--tag(repeatable)list— list all indexed activitiesgeogrid— generate a geohash grid for a LOCATION argument (optional--precision,--radius)foursquare-search— search and index Foursquare Places API by LOCATION (optional--query,--radius,--limit,--category,--dry-runto search without indexing)
The list command is registered as @main.command(name="list") with function name list_activities to avoid shadowing the built-in.
The main group accepts --db (also ACTIVITYFINDER_DB env var) to persist data to a SQLite file; defaults to activityfinder.db.
Testing
.venv/bin/pytest -v
Tests are in tests/. Add test files alongside existing ones following the same class-per-module pattern. CLI tests should use CliRunner from typer.testing.