Portlore is a cruise port guide that helps passengers find worthwhile places near where they disembark and plan what they can realistically fit into a day ashore.
It covers 631 cruise ports. For each one it finds the real passenger terminals, gathers the notable places nearby, and has a language model curate them into a guide with the must-see sights, local favourites, and a few hidden gems, presented on a map and itinerary with travel estimates from the passenger's terminal.
Live site: https://portlore.com
- Search and browse port destinations
- View port and terminal locations on a map
- Explore attractions, food, shopping, and practical stops
- Compare walking and driving estimates
- Build an itinerary with visit and travel time
- Open matched locations in Google Maps
- Generate guides for ports that do not have one yet
client/ Static frontend
server/ Express API and generation routes
shared/ Shared POI selection, curation, and Google Places logic
scripts/ Port list, catalog, and guide generation tools
data/cruise-ports.json The port list: ports, cruise terminals, and gateway city centres
cities/ports.json Generated from data/cruise-ports.json
cities/poi/ Generated POI shortlists, one per port
The frontend is currently contained in client/src/index.html and can be reworked into a more structured client later.
Ports and terminals. data/cruise-ports.json is the only hand-maintained data. The list was assembled with an assisted review and checked against CruiseMapper's published cruise schedules: a port belongs on it when cruise or passenger ships call there and passengers step ashore into a town. A few ports with paused or occasional calls are kept on purpose. Terminals were located with Google Places and OpenStreetMap, then reviewed port by port. Pins that mark the harbour rather than a confirmed berth are flagged approximate. Ports whose ships dock far from the city visitors come for, such as Civitavecchia for Rome, carry a guideCentre.
Shortlists. Every catalog in cities/poi/ is generated from the port list and OpenStreetMap. For each port, the builder gathers places within 10 km and keeps up to 80. Fame comes from how many Wikipedia editions cover a place (data/wikidata-fame.json, fetched from Wikidata by scripts/fetch-wikidata-fame.js): each port's best-known landmarks go in first, then each category is filled with a mix of types, favouring well-known and well-documented places near the terminal. Chains never count as famous, and entries with no useful details are skipped, so small ports get shorter lists. Places with a Wikipedia article carry its opening sentences (data/wikipedia-intros.json, fetched by scripts/fetch-wikipedia-intros.js), so guides describe them from real facts.
Editor's picks and hidden gems. data/curated-picks.json lists hand-picked places that always make a port's shortlist, matched by name and location. A pick that isn't in OpenStreetMap is built from its own entry. Picks marked gem (about five per port, mostly sights and food) are hidden-gem candidates: the model keeps the ones that hold up for the guide's Hidden gems section and can add its own. Gems given by name only are passed to the model as suggestions and located with Google Places if kept.
Guides. On a port's first visit, or with scripts/generate-city.js, Gemini curates the shortlist into the guide, planning around the terminals (set CITY_MODEL_PROVIDER=anthropic to use Claude instead). Google Places then confirms the chosen stops, drops any that have closed, and fills in each stop's category, hours, and website. Restaurants, cafés, and shops with no Wikipedia or OpenStreetMap description are rewritten from the text of their own website, so details like a menu are real, and links to sites that no longer exist are dropped. A Google match that moves a restaurant or shop far from its OpenStreetMap location, or isn't a business at all, is treated as a different place and ignored.
Guides older than GUIDE_MAX_AGE_DAYS (120 by default) are rebuilt in the background the next time someone opens them, so visitors never wait and the old guide stays up if a rebuild fails. Rebuilds count toward the daily generation limit.
Port pages. Every port has a page at /ports/<id>, rendered by the server from the port list and the port's guide: where ships dock, the stops within walking distance, and the hidden gems. Only ports with a current guide are indexed and listed in /sitemap.xml; the rest are marked noindex until someone plans a day there. /?port=<id> opens the planner with that port selected.
After editing data/cruise-ports.json, regenerate the port list and attach terminals to the catalogs:
node scripts/build-port-list.jsBuild or rebuild catalogs for specific ports, keeping every other catalog:
node scripts/build-all-poi-catalogs.js --ports saint-john-canada,halifax-canada
node scripts/build-port-list.jsRebuild everything, with 8 OpenStreetMap extracts on disk at a time:
cd scripts
npm run catalogsBuilding needs osmium-tool. Each catalog records the extract it was built from (sourceExtract). Rebuilds always use the latest OpenStreetMap data, so shortlists pick up new and closed places.
After new catalogs are built, refresh the fame counts, then the Wikipedia intros for the shortlisted places:
node scripts/fetch-wikidata-fame.js
node scripts/reselect-poi-candidates.js
node scripts/fetch-wikipedia-intros.js
node scripts/reselect-poi-candidates.jsTo rerun the shortlist rules on catalogs that are already built, for example after editing data/curated-picks.json:
node scripts/reselect-poi-candidates.js --ports saint-john-canadaCheck that ports and catalogs line up, and run the shortlist and guide-building tests:
node scripts/validate-data.js
node scripts/smoke-shared.jsGitHub Actions runs both on every push, along with a syntax check and a check that cities/ports.json and the catalogs match data/cruise-ports.json.
Requirements:
- Node.js 20 or newer
- Gemini API key (or an Anthropic API key, with
CITY_MODEL_PROVIDER=anthropic) - Google Maps Platform key with Places access
- Pexels API key
Create the local environment file:
cp .env.example .envInstall dependencies:
cd server
npm install
cd ../scripts
npm installStart the API:
cd server
npm startServe client/src/ with a static web server on port 8080 to run the frontend locally.
Generate one guide:
node scripts/generate-city.js saint-john-canadaGenerated guides are written to cities/<port-id>.json. The source files in cities/poi/ are not changed.
Copy .env.deploy.example to .env.deploy and add the SSH target and remote directory.
Deploy with:
bash scripts/deploy.shThe script uploads the release, installs server dependencies, preserves existing generated guides, restarts Portlore with PM2, checks the health endpoint, and reloads Nginx.
Portlore is a planning tool. Opening hours, accessibility, transportation, terminal use, and cruise schedules can change. Travelers should confirm important details with official sources.
Map data comes from OpenStreetMap contributors. Place descriptions draw on Wikipedia (CC BY-SA). Line icons are from Lucide (ISC licence), plus a few drawn for Portlore in the same style.
- Add or delete the port's entry in
data/cruise-ports.json. - Run
node scripts/build-port-list.js --prune, which also deletes catalogs for removed ports. - For a new port, build its catalog with
node scripts/build-all-poi-catalogs.js --ports <id>, then runbuild-port-list.jsagain. - Run
node scripts/validate-data.js.
Normal deployment preserves generated guides, so a removed port's old guide stays on the server until it is deleted there.