openvibers/openvibe.tours Commits
Sourced city and place guides, planning and live handoffs (#106)
Changes
@@@ -40,8 +40,9 @@ OpenVibe.Tours is the travel room. V1 has six things, all from open data or the## Does not own--- **Live, Rent and AI** — OpenVibe.Live, OpenVibe.Rent and OpenVibe.AI. Tours reads their public APIs and never-- writes to them. It never uses a service token.++- **Live, Rent, Media and AI** — Tours reads public Live/Rent pages. Licensed place images are copied to Media with++ the `media.object.upload` service scope; sourced place summaries use AI with `ai.run.create ai.run.read` when++ `OV_AI_INTERNAL_URL` is configured. Both keep the original source and attribution on the place page.- **Identity and roles** — OpenVibe.Network. Tours is OAuth client `tours` (PKCE S256) and verifies session tokensagainst the Network JWKS; a person's canonical subject is `usr_…`.- **OpenStreetMap, Wikivoyage, Wikidata, Commons** — external open data, cached in Tours' own tables with the source@@@ -55,12 +56,13 @@ OpenVibe.Tours is the travel room. V1 has six things, all from open data or the| OpenStreetMap via Overpass | places (`tourism=*`) | ODbL 1.0; contact User-Agent, per-host pacing, one bounded `around:` query, no bulk download | OSM object URL, fetched time || Wikivoyage | city summary, See listings, route stops | CC BY-SA 4.0; the page URL on every city and stop | page URL, fetched time || Wikidata | coordinates, population, country, English Wikivoyage sitelink | CC0 | Q id, fetched time |--| Wikimedia Commons | city image + licence/credit | each image's own licence (usually CC BY-SA) | credit, licence, licence URL |++| Wikimedia Commons and Openverse | place and city images with licence/credit | CC BY, CC BY-SA, CC0 or public domain; no NC/ND | source URL, credit, licence, Media thumbnail || OpenVibe.Live | live IRL streams, VODs, clips | public API; read-only | item URL, fetched time || OpenVibe.Rent | stays | public listings; read-only | listing URL, fetched time |--OpenVibe.AI is **not** used: its public API has chat, generate, summarize, classify, extract, embed, image and--research, but no speech synthesis. The route schema has `audio_status`/`audio_url` ready for the day it does.++OpenVibe.AI may summarize the cited place description for “Why go”; the page labels this as an AI summary and links++the source. If the AI call is unavailable, the sourced description remains. AI has no speech synthesis route; route++audio remains off.## Jobsnnew file mode 100644@@@ -0,0 +1,38 @@++-- phase: expand++-- Source checked images and cached city name resolutions.++ALTER TABLE tours_places ADD COLUMN image_license_url text;++ALTER TABLE tours_places ADD COLUMN image_source_url text;++ALTER TABLE tours_places ADD COLUMN gallery jsonb NOT NULL DEFAULT '[]'::jsonb;++ALTER TABLE tours_places ADD COLUMN rank_score integer NOT NULL DEFAULT 0;++ALTER TABLE tours_places ADD COLUMN guide_features text;++ALTER TABLE tours_places ADD COLUMN guide_visit text;++ALTER TABLE tours_places ADD COLUMN guide_season text;++ALTER TABLE tours_places DROP CONSTRAINT tours_places_source;++ALTER TABLE tours_places ADD CONSTRAINT tours_places_source CHECK (source IN ('openstreetmap', 'wikivoyage', 'wikidata', 'wikipedia'));++ALTER TABLE tours_cities ADD COLUMN image_source_url text;++ALTER TABLE tours_cities ADD COLUMN osm_attempted_at timestamptz;++ALTER TABLE tours_live ADD COLUMN place_id text COLLATE "C" REFERENCES tours_places(id) ON DELETE SET NULL;++CREATE INDEX tours_live_place ON tours_live (place_id, fetched_at DESC) WHERE place_id IS NOT NULL;++CREATE TABLE tours_city_lookup (++ query text COLLATE "C" PRIMARY KEY,++ city_id text COLLATE "C" REFERENCES tours_cities(id) ON DELETE CASCADE,++ expires_at timestamptz NOT NULL++);++CREATE TABLE tours_photo_assets (++ source_url text COLLATE "C" PRIMARY KEY,++ media_id text COLLATE "C" NOT NULL,++ media_url text NOT NULL,++ created_at timestamptz NOT NULL DEFAULT now()++);++CREATE TABLE tours_pageview_cache (++ wikipedia_title text COLLATE "C" PRIMARY KEY,++ views integer NOT NULL,++ fetched_at timestamptz NOT NULL DEFAULT now()++);++CREATE TABLE tours_place_summaries (++ place_id text COLLATE "C" PRIMARY KEY REFERENCES tours_places(id) ON DELETE CASCADE,++ source_fetched_at timestamptz NOT NULL,++ summary text NOT NULL,++ run_id text COLLATE "C" NOT NULL,++ updated_at timestamptz NOT NULL DEFAULT now()++);nnew file mode 100644@@@ -0,0 +1,9 @@++-- phase: expand++ALTER TABLE tours_places ADD COLUMN guide_why text;++ALTER TABLE tours_places DROP CONSTRAINT tours_places_osm_type;++ALTER TABLE tours_places ADD CONSTRAINT tours_places_osm_type CHECK (osm_type IN ('node', 'way', 'relation', 'wikivoyage', 'wikidata'));++CREATE TABLE tours_wikidata_city_places (++ city_id text COLLATE "C" PRIMARY KEY REFERENCES tours_cities(id) ON DELETE CASCADE,++ fetched_at timestamptz NOT NULL DEFAULT now(),++ payload jsonb NOT NULL++);@@@ -66,13 +66,19 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex/* City / place / route lists */.tr-city-list, .tr-place-list, .tr-route-list, .tr-live-list, .tr-stay-list, .tr-trip-list, .tr-trip-places { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }--.tr-city-row a, .tr-place-row a, .tr-route-list a, .tr-live-list a, .tr-stay-list a, .tr-trip-list a { display: flex; gap: 12px; align-items: center; padding: 10px 12px; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 12px; background: var(--bg-secondary, #101828); color: inherit; text-decoration: none; }--.tr-city-row a:hover, .tr-place-row a:hover, .tr-route-list a:hover, .tr-live-list a:hover, .tr-stay-list a:hover, .tr-trip-list a:hover { border-color: var(--room-accent, #F98942); }++.tr-city-row a, .tr-place-row > a, .tr-route-list a, .tr-live-list a, .tr-stay-list a, .tr-trip-list a { display: flex; gap: 12px; align-items: center; padding: 10px 12px; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 12px; background: var(--bg-secondary, #101828); color: inherit; text-decoration: none; }++.tr-city-row a:hover, .tr-place-row > a:hover, .tr-route-list a:hover, .tr-live-list a:hover, .tr-stay-list a:hover, .tr-trip-list a:hover { border-color: var(--room-accent, #F98942); }++.tr-place-list, .tr-place-row { min-width: 0; }++.tr-place-row figure { margin: 0; min-width: 0; }++.tr-place-row figure img { display: block; width: 100%; max-height: 230px; object-fit: cover; border-radius: 12px 12px 0 0; }++.tr-place-row figcaption { font-size: .78rem; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; padding: 6px 0 8px; }.tr-city-photo { width: 58px; height: 44px; border-radius: 8px; overflow: hidden; flex: none; display: grid; place-items: center; background: var(--bg-primary, #0a0f1c); color: var(--room-accent, #F98942); }.tr-city-photo img { width: 100%; height: 100%; object-fit: cover; }.tr-city-text, .tr-place-row span, .tr-route-list a, .tr-live-list a, .tr-stay-list a { display: grid; gap: 2px; }.tr-city-text strong, .tr-place-row strong, .tr-route-list strong, .tr-live-list strong, .tr-stay-list strong { font-size: 1rem; }.tr-city-img, .tr-place-img { margin: 16px 0 0; }++.tr-gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 14px; }++.tr-gallery .tr-place-img:first-child { grid-column: 1 / -1; }.tr-city-img img, .tr-place-img img { width: 100%; max-height: 380px; object-fit: cover; border-radius: 16px; }.tr-city-img figcaption, .tr-place-img figcaption { font-size: .78rem; color: var(--text-muted, #7386a3); margin-top: 6px; }.tr-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 10px; margin: 18px 0 0; }@@@ -155,3 +161,95 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td[data-label]:not(:first-child)::before { content: attr(data-label); display: block; margin-bottom: 2px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:empty { display: none; }}++++/* ── City and place pages (Opus design pass, 2026-11-08): a guide people browse with their thumbs ── */++.tr-city-hero { padding-top: clamp(14px, 3vw, 28px); }++.tr-city-cover { margin: 0 0 18px; }++.tr-city-cover img { display: block; width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 18px; background: var(--bg-secondary, #111a2e); }++.tr-city-cover figcaption, .tr-hero-shot figcaption, .tr-shot figcaption { margin-top: 6px; font-size: .74rem; line-height: 1.45; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }++.tr-city-intro h1 { margin: 0; }++.tr-city-intro .tr-lede { max-width: 72ch; }++.tr-seg { display: inline-flex; gap: 4px; margin: 18px 0 2px; padding: 4px; border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 999px; }++.tr-seg a { padding: 8px 16px; border-radius: 999px; font-weight: 700; font-size: .92rem; text-decoration: none; color: var(--text-secondary, #96a7c2); min-height: 40px; display: inline-flex; align-items: center; }++.tr-seg a[aria-current="page"] { background: var(--room-accent, #F98942); color: var(--room-on, #0a0f1c); }++.tr-count { display: inline-block; margin-left: 6px; padding: 1px 9px; border-radius: 999px; font-size: .8rem; font-weight: 700; vertical-align: 3px; color: var(--text-secondary, #96a7c2); background: var(--bg-secondary, rgba(255,255,255,.06)); }++.tr-pill { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: .72rem; font-weight: 800; letter-spacing: .02em; text-transform: none; color: #0b3d1f; background: #7ee2a8; vertical-align: 1px; }++.tr-pgrid { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 156px), 1fr)); }++.tr-pcard { position: relative; display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 14px; overflow: hidden; background: var(--bg-card, #0f1729); transition: border-color .15s ease, transform .15s ease; }++.tr-pcard:hover { border-color: var(--room-accent, #F98942); }++.tr-pcard:active { transform: scale(.995); }++.tr-pcard-media { aspect-ratio: 4 / 3; background: var(--bg-secondary, #111a2e); }++.tr-pcard-media img { display: block; width: 100%; height: 100%; object-fit: cover; }++.tr-pcard-pin { display: grid; place-content: center; justify-items: center; gap: 6px; color: var(--room-strong, #F98942); background: color-mix(in srgb, var(--room-accent, #F98942) 9%, var(--bg-card, #0f1729)); font-size: .78rem; font-weight: 700; text-transform: capitalize; }++.tr-pcard-body { padding: 10px 12px 8px; display: grid; gap: 4px; align-content: start; }++.tr-pcard h3 { margin: 0; font-size: .98rem; line-height: 1.3; font-weight: 750; overflow-wrap: anywhere; }++.tr-pcard h3 a { color: var(--text-primary, #e6edf7); text-decoration: none; }++.tr-pcard h3 a::after { content: ""; position: absolute; inset: 0; }++.tr-pcard h3 a:focus-visible { outline: none; }++.tr-pcard:focus-within { outline: 3px solid var(--room-accent, #F98942); outline-offset: 2px; }++.tr-pcard-meta { margin: 0; font-size: .8rem; line-height: 1.4; color: var(--text-muted, #7386a3); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }++.tr-pcard-body .tr-pill { justify-self: start; }++.tr-pcard-credit { position: relative; z-index: 1; margin: auto 0 0; padding: 0 12px 10px; font-size: .68rem; line-height: 1.4; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }++.tr-pgrid-small { grid-template-columns: repeat(auto-fill, minmax(min(100%, 150px), 1fr)); }++++.tr-place-top { display: grid; gap: 18px; padding-top: clamp(10px, 2vw, 22px); }++.tr-place-head h1 { margin: 0; }++.tr-place-head .tr-kicker .tr-pill { margin-left: 6px; }++.tr-place-actions { margin-top: 16px; }++.tr-place-media { min-width: 0; }++.tr-hero-shot { margin: 0; }++.tr-hero-shot img { display: block; width: 100%; height: auto; aspect-ratio: 16 / 10; object-fit: cover; border-radius: 16px; background: var(--bg-secondary, #111a2e); }++.tr-rail-wrap { margin-top: 12px; }++.tr-rail-count { margin: 0 0 6px; font-size: .8rem; font-weight: 700; color: var(--text-secondary, #96a7c2); }++.tr-rail { list-style: none; margin: 0; padding: 0 0 6px; display: flex; gap: 10px; overflow-x: auto; scroll-snap-type: x mandatory; overscroll-behavior-x: contain; }++.tr-rail li { flex: 0 0 min(72%, 260px); scroll-snap-align: start; min-width: 0; }++.tr-shot { margin: 0; }++.tr-shot img { display: block; width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; border-radius: 12px; background: var(--bg-secondary, #111a2e); }++.tr-quick { list-style: none; margin: 22px 0 0; padding: 0; display: grid; gap: 8px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 156px), 1fr)); }++.tr-quick li { min-width: 0; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 12px; background: var(--bg-card, #0f1729); }++.tr-quick li > strong, .tr-quick li > a { display: grid; gap: 2px; padding: 10px 12px; }++.tr-quick li > strong + span { display: block; padding: 0 12px 10px; margin-top: -8px; }++.tr-quick strong { font-size: .92rem; line-height: 1.3; color: var(--text-primary, #e6edf7); overflow-wrap: anywhere; }++.tr-quick span { font-size: .76rem; line-height: 1.35; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }++.tr-quick a { text-decoration: none; }++.tr-quick a strong { color: var(--room-strong, var(--accent-light, #60a5fa)); }++.tr-quick li:has(a):hover { border-color: var(--room-accent, #F98942); }++.tr-guide { max-width: 760px; }++.tr-guide p { line-height: 1.65; }++.tr-band { padding: clamp(16px, 3vw, 24px); border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, rgba(255,255,255,.08)); }++.tr-band h2 { margin-bottom: 4px; }++.tr-remind { margin-top: 12px; }++.tr-sources { margin-top: clamp(32px, 5vw, 48px); padding-top: 14px; border-top: 1px solid var(--border, rgba(255,255,255,.1)); font-size: .84rem; color: var(--text-muted, #7386a3); }++.tr-sources h2 { font-size: .8rem; letter-spacing: .08em; text-transform: uppercase; margin: 0 0 6px; color: var(--text-secondary, #96a7c2); }++.tr-sources p { margin: 4px 0; line-height: 1.5; overflow-wrap: anywhere; }++@media (min-width: 720px) {++ .tr-city-cover img { aspect-ratio: 21 / 9; }++ .tr-pgrid { gap: 16px; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); }++ .tr-place-top { grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); align-items: start; gap: 32px; }++ .tr-place-head { position: sticky; top: 76px; }++ .tr-rail li { flex-basis: 220px; }++}++.tr-pcard-text .tr-pcard-body { padding-top: 12px; }++.tr-pcard-text .tr-pcard-pin { color: var(--room-strong, #F98942); }++.tr-fold { margin-top: 12px; }++.tr-fold > summary { cursor: pointer; display: inline-flex; align-items: center; min-height: 44px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.14)); font-weight: 700; list-style: none; }++.tr-fold > summary::-webkit-details-marker { display: none; }++.tr-fold > summary::after { content: ""; width: 7px; height: 7px; margin-left: 10px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: translateY(-2px) rotate(45deg); }++.tr-fold[open] > summary { margin-bottom: 12px; }++.tr-fold[open] > summary::after { transform: translateY(2px) rotate(-135deg); }++.tr-together { margin-top: 18px; }++.tr-together-h { margin: 0 0 8px; font-size: .78rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }++.tr-together .tr-actions { margin: 0; }++.tr-together .tr-fine { margin: 8px 0 0; }++@media (min-width: 720px) { .tr-place-top-solo { grid-template-columns: minmax(0, 1fr); } .tr-place-top-solo .tr-place-head { position: static; max-width: 760px; } }++.tr-pcard-body > .tr-pcard-pin { display: block; background: none; padding: 0; font-size: inherit; }++.tr-pgrid { align-items: start; }++.tr-place-top .tr-quick { margin-top: 4px; }++@media (min-width: 720px) {++ .tr-place-top { grid-template-areas: "head media" "quick media"; grid-template-rows: auto 1fr; }++ .tr-place-top > .tr-place-head { grid-area: head; position: static; }++ .tr-place-top > .tr-place-media { grid-area: media; }++ .tr-place-top > .tr-quick { grid-area: quick; align-self: start; }++ .tr-place-top-solo { grid-template-areas: "head" "quick"; grid-template-rows: auto auto; }++}++.tr-pcard-media { overflow: hidden; }@@@ -34,6 +34,8 @@ const { assetVersion, send } = require('./render/layout');const { raw } = require('./render/html');const { createStore: dataStore } = require('./store');const { createJobs } = require('./jobs');++const { createDiscover } = require('./places/discover');++const { createNarrative } = require('./places/narrative');const { createSearchIndex } = require('./search-index');const accountDataLib = require('./identity/account-data');const { createNetworkSender } = require('openvibe-sdk/account-data');@@@ -57,6 +59,8 @@ async function createApp(opts = {}) {const sharing = opts.sharing || createSharing({ db: s.db, config, fetchImpl, log });const store = dataStore(s, { config, sharing });const jobs = createJobs({ config, s, store, fetchImpl, log });++ const discover = createDiscover({ config, s, store, jobs, fetchImpl, log });++ const narrative = createNarrative({ config, s, fetchImpl, log });// The places, cities and walks in OpenVibe.Search (started by server/index.js, never under TOURS_DRILL).const searchIndex = createSearchIndex({ config, s, log });@@@ -65,7 +69,7 @@ async function createApp(opts = {}) {? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }): async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Tours cannot answer account events'); });-- const ctx = { config, s, keys, sso, principal, store, jobs, searchIndex, accountData, accountSend, sharing, log };++ const ctx = { config, s, keys, sso, principal, store, jobs, discover, narrative, searchIndex, accountData, accountSend, sharing, log };const app = express();// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.@@@ -27,6 +27,7 @@ function load(env = process.env) {nodeEnv,isProduction,baseUrl,++ indexNow: { key: String(env.INDEXNOW_KEY || '').trim() },trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,drill: bool(env.TOURS_DRILL),limits: {@@@ -38,6 +39,8 @@ function load(env = process.env) {valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:tours:' },networkUrl,++ media: { url: trim(env.OV_MEDIA_URL || 'https://openvibe.media'), scope: 'media.object.upload' },++ ai: { url: trim(env.OV_AI_INTERNAL_URL || ''), scope: 'ai.run.create ai.run.read' },networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),audience: env.TOURS_AUDIENCE || 'openvibe.tours',@@@ -68,9 +71,12 @@ function load(env = process.env) {rentPublic: trim(env.OV_RENT_PUBLIC_URL || 'https://openvibe.rent'),wikivoyage: trim(env.OV_WIKIVOYAGE_URL || 'https://en.wikivoyage.org'),wikidata: trim(env.OV_WIKIDATA_URL || 'https://www.wikidata.org'),++ wikidataQuery: trim(env.OV_WIKIDATA_QUERY_URL || 'https://query.wikidata.org/sparql'),wikipedia: trim(env.OV_WIKIPEDIA_URL || 'https://en.wikipedia.org'),commons: trim(env.OV_COMMONS_URL || 'https://commons.wikimedia.org'),++ openverse: trim(env.OV_OPENVERSE_URL || 'https://api.openverse.org'),overpass: trim(env.OV_OVERPASS_URL || 'https://overpass-api.de/api/interpreter'),++ nominatim: trim(env.OV_NOMINATIM_URL || 'https://nominatim.openstreetmap.org'),timeoutMs: Math.max(1000, int(env.TOURS_UPSTREAM_TIMEOUT_MS, 60_000)),},@@@ -80,11 +86,11 @@ function load(env = process.env) {email: String(env.TOURS_OSM_EMAIL || '').trim(),minIntervalMs: Math.max(0, int(env.TOURS_MIN_INTERVAL_MS, 1100)),retryAfterMs: Math.max(0, int(env.TOURS_RETRY_AFTER_MS, 120_000)),-- radiusM: Math.min(10_000, Math.max(300, int(env.TOURS_OSM_RADIUS_M, 3500))),++ radiusM: Math.min(25_000, Math.max(300, int(env.TOURS_OSM_RADIUS_M, 15_000))),maxPerCity: Math.min(200, Math.max(1, int(env.TOURS_OSM_MAX_PER_CITY, 80))),osmCitiesPerRun: Math.min(100, Math.max(0, int(env.TOURS_OSM_CITIES_PER_RUN, 10))),cityLimit: Math.min(200, Math.max(1, int(env.TOURS_CITY_LIMIT, 100))),-- districtPages: Math.min(4, Math.max(0, int(env.TOURS_WIKIVOYAGE_DISTRICTS, 2))),++ districtPages: Math.min(40, Math.max(0, int(env.TOURS_WIKIVOYAGE_DISTRICTS, 40))),},jobs: {@@@ -31,7 +31,7 @@ const { sameOrigin } = require('./principal');const { document: openapi } = require('../openapi');function createApi(ctx) {-- const { config, s, principal, store } = ctx;++ const { config, s, principal, store, discover } = ctx;const r = asyncRouter();r.use(express.json({ limit: '64kb' }));@@@ -84,13 +84,27 @@ function createApi(ctx) {if (!Number.isFinite(limit) || limit < 1 || limit > 200) return invalid(res, 'limit must be from 1 to 200', 'limit');if (!Number.isFinite(offset) || offset < 0) return invalid(res, 'offset must be 0 or more', 'offset');const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 80) : null;-- const cities = await store.listCities({ limit, offset, q });++ let cities = await store.listCities({ limit, offset, q });++ if (q && !cities.length && offset === 0) {++ try {++ await discover.ensureCity(q);++ cities = await store.listCities({ limit, offset, q });++ } catch (err) { ctx.log.warn(`[Tours city API lookup] ${err.message}`); }++ }okPublic(res);return res.json({ count: cities.length, cities });});r.get('/cities/:slug', async (req, res) => {-- const city = await store.cityBySlug(String(req.params.slug || ''));++ let city = await store.cityBySlug(String(req.params.slug || ''));++ if (!city) {++ try { city = await discover.ensureCity(String(req.params.slug || '').replace(/-/g, ' ')); }++ catch (err) { ctx.log.warn(`[Tours city API lookup] ${err.message}`); }++ }++ else if (city.place_count < 24) {++ try { city = await discover.ensureCity(city.name); }++ catch (err) { ctx.log.warn(`[Tours city API refresh] ${err.message}`); }++ }if (!city) return problem(res, 404, 'tours.city_not_found', 'No such city.');const [places, routes, live, stays] = await Promise.all([store.cityPlaces(city.id, { limit: 100 }), store.cityRoutes(city.id), store.cityLive(city.id), store.cityStays(city.id),@@@ -99,8 +113,19 @@ function createApi(ctx) {return res.json({ city, places, routes, live, stays });});++ r.get('/places', async (req, res) => {++ const q = typeof req.query.q === 'string' ? req.query.q.trim().slice(0, 120) : '';++ if (!q) return invalid(res, 'q is required', 'q');++ let place = null;++ try { place = await discover.ensurePlace(q); }++ catch (err) { ctx.log.warn(`[Tours place API lookup] ${err.message}`); }++ if (!place) return problem(res, 404, 'tours.place_not_found', 'No such place.');++ okPublic(res);++ return res.json({ place });++ });++r.get('/places/:id', async (req, res) => {-- const place = await store.placeById(String(req.params.id || ''));++ const place = await discover.enrichPlace(await store.placeById(String(req.params.id || '')));if (!place) return problem(res, 404, 'tours.place_not_found', 'No such place.');const routes = await store.routesForPlace(place.id);okPublic(res);@@@ -10,6 +10,8 @@ const path = require('path');const seo = require('openvibe-shared/seo');const cache = require('openvibe-shared/cache-policy');const { asyncRouter } = require('./router');++const { placePath } = require('../store');++const { createIndexNow } = require('../index-now');const SITE_NAME = 'OpenVibe.Tours';const DESCRIPTION = 'OpenVibe.Tours is go anywhere: fly, stay, walk, watch. Cities, places and self-guided routes from OpenStreetMap, Wikivoyage and Wikidata; IRL tours live on OpenVibe.Live; stays from OpenVibe.Rent; flights through the official airline-neutral search; and trips a person shares with the people going with them.';@@@ -51,6 +53,9 @@ function createDiscoveryRoutes(ctx) {const TEXT = cache.htmlHeaders({ maxAge: 3600 });const pages = publicPages();++ const indexNow = createIndexNow({ baseUrl: config.baseUrl, key: config.indexNow?.key });++ if (indexNow.enabled) r.use(indexNow.keyFile);++r.get('/robots.txt', (_req, res) => {res.type('text/plain').set('Cache-Control', TEXT).send('# openvibe.tours: public cities, places and routes are for search engines; sign-in, the API and trips are not.\n'@@@ -104,7 +109,7 @@ function createDiscoveryRoutes(ctx) {{ title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) },{ title: 'Cities', pages: cities.map((c) => ({ url: `/city/${c.slug}`, title: c.name, text: [c.country, c.summary].filter(Boolean).join(' · ') })) },{ title: 'Routes', pages: routes.map((r0) => ({ url: `/routes/${r0.id}`, title: 'Self-guided route', text: 'Generated from a Wikivoyage See list.' })) },-- { title: 'Places', pages: places.map((p) => ({ url: `/place/${p.id}`, title: 'Place', text: 'From OpenStreetMap, ODbL.' })) },++ { title: 'Places', pages: places.map((p) => ({ url: placePath(p), title: p.name, text: 'From OpenStreetMap or Wikivoyage.' })) },],}));});@@@ -117,8 +122,9 @@ function createDiscoveryRoutes(ctx) {const urls = [...pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority })),...cities.map((e) => ({ loc: abs(`/city/${e.slug}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'weekly', priority: 0.8 })),++ ...cities.map((e) => ({ loc: abs(`/city/${e.slug}/free`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'weekly', priority: 0.6 })),...routes.map((e) => ({ loc: abs(`/routes/${e.id}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'weekly', priority: 0.6 })),-- ...places.map((e) => ({ loc: abs(`/place/${e.id}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'monthly', priority: 0.4 })),++ ...places.map((e) => ({ loc: abs(placePath(e)), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'monthly', priority: 0.4 })),];res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));});@@@ -14,6 +14,7 @@ const { raw } = require('../render/html');const { send } = require('../render/layout');const views = require('../views');const { createFlights } = require('../flights');++const { placePath } = require('../store');const PUBLIC_CACHE = 'public, max-age=60';const ovServe = require('openvibe-shared/serve');@@@ -21,12 +22,15 @@ const ovServe = require('openvibe-shared/serve');const SHARE_HEAD = `<link rel="stylesheet" href="${ovServe.url('ov-share.css')}"><script src="${ovServe.url('ov-share.js')}" defer></script>`;function createPageRoutes(ctx) {-- const { config, store } = ctx;++ const { config, store, discover, jobs } = ctx;const r = asyncRouter();r.use(express.urlencoded({ extended: false, limit: '64kb' }));const base = () => String(config.baseUrl).replace(/\/+$/, '');const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;++ const imageLd = (p) => views.safeImage(p.image_url) && p.image_credit && p.image_license && p.image_source_url++ ? { '@type': 'ImageObject', contentUrl: p.image_url, url: p.image_source_url,++ creditText: p.image_credit, license: p.image_license_url || p.image_license } : undefined;const flights = createFlights({ config });const notFromHere = (req, res) => page(req, res, {@@@ -67,35 +71,53 @@ function createPageRoutes(ctx) {// ── Cities ───────────────────────────────────────────────r.get('/cities', async (req, res) => {const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 80) : '';-- const cities = await store.listCities({ limit: 100, q: q || null });++ let cities = await store.listCities({ limit: 100, q: q || null });++ if (q && !cities.length) {++ try {++ const city = await discover.ensureCity(q);++ if (city) return res.redirect(303, `/city/${encodeURIComponent(city.slug)}`);++ } catch (err) { ctx.log.warn(`[Tours city lookup] ${err.message}`); }++ }return page(req, res, {index: true, cache: q ? null : PUBLIC_CACHE, title: 'Cities',-- description: 'The top 100 cities from Wikidata and Wikivoyage, with places from OpenStreetMap, self-guided routes, live tours and stays.',++ description: 'Find things to do in any city, with places from OpenStreetMap and Wikivoyage.',jsonLd: [seo.jsonLd.webPage({ name: 'Cities', url: `${base()}/cities`, description: 'The top 100 cities from open data.', siteUrl: base() })],body: raw(views.citiesPage({ cities, q })),});});r.get('/city/:slug', async (req, res) => {-- const city = await store.cityBySlug(String(req.params.slug || ''));++ let city = await store.cityBySlug(String(req.params.slug || ''));++ if (!city) {++ try { city = await discover.ensureCity(String(req.params.slug || '').replace(/-/g, ' ')); }++ catch (err) { ctx.log.warn(`[Tours city lookup] ${err.message}`); }++ }++ else if (city.place_count < 24) {++ try { city = await discover.ensureCity(city.name); }++ catch (err) { ctx.log.warn(`[Tours city refresh] ${err.message}`); }++ }if (!city) return page(req, res, { index: false, title: 'Not found', body: raw(views.notFound()) }, 404);++ if (city.slug !== req.params.slug) return res.redirect(301, `/city/${encodeURIComponent(city.slug)}`);++ if (!city.osm_fetched_at && (!city.osm_attempted_at || Date.now() - new Date(city.osm_attempted_at).getTime() > 15 * 60_000)) {++ try { await jobs.places.populateCity(city); } catch (err) { ctx.log.warn(`[Tours city places] ${err.message}`); }++ }const [places, routes, live, stays] = await Promise.all([store.cityPlaces(city.id, { limit: 60 }), store.cityRoutes(city.id), store.cityLive(city.id), store.cityStays(city.id),]);const attractions = places.slice(0, 10).map((p) => ({-- '@type': 'TouristAttraction', name: p.name, url: `${base()}/place/${p.id}`,++ '@type': 'TouristAttraction', name: p.name, url: `${base()}${placePath(p)}`,geo: { '@type': 'GeoCoordinates', latitude: p.lat, longitude: p.lon },-- ...(p.description ? { description: p.description } : {}), ...(views.safeImage(p.image_url) ? { image: p.image_url } : {}),++ ...(p.description ? { description: p.description } : {}), ...(imageLd(p) ? { image: imageLd(p) } : {}),}));const tripLd = routes.slice(0, 3).map((rt) => ({'@type': 'TouristTrip', name: rt.title, url: `${base()}/routes/${rt.id}`, description: rt.summary || undefined,itinerary: { '@type': 'ItemList', numberOfItems: rt.stop_count },}));return page(req, res, {-- index: true, cache: PUBLIC_CACHE, title: city.name,++ index: true, cache: PUBLIC_CACHE, title: `Things to do in ${city.name}`,description: city.summary ? String(city.summary).slice(0, 180) : `${city.name}: places, self-guided routes, live tours and stays.`,jsonLd: [-- { '@context': 'https://schema.org', '@type': 'TouristDestination', name: city.name, url: `${base()}/city/${city.slug}`, description: city.summary || undefined, geo: { '@type': 'GeoCoordinates', latitude: city.lat, longitude: city.lon }, touristType: 'Independent', ...(city.image_url ? { image: city.image_url } : {}) },++ { '@context': 'https://schema.org', '@type': 'TouristDestination', name: city.name, url: `${base()}/city/${city.slug}`, description: city.summary || undefined, geo: { '@type': 'GeoCoordinates', latitude: city.lat, longitude: city.lon }, touristType: 'Independent', ...(imageLd(city) ? { image: imageLd(city) } : {}) },...(attractions.length ? [{ '@context': 'https://schema.org', '@type': 'ItemList', name: `Places in ${city.name}`, numberOfItems: attractions.length, itemListElement: attractions.map((a, i) => ({ '@type': 'ListItem', position: i + 1, item: a })) }] : []),...tripLd,seo.jsonLd.breadcrumbs([{ name: 'Cities', url: `${base()}/cities` }, { name: city.name, url: `${base()}/city/${city.slug}` }]),@@@ -104,31 +126,72 @@ function createPageRoutes(ctx) {});});++ r.get('/city/:slug/free', async (req, res) => {++ let city = await store.cityBySlug(String(req.params.slug || ''));++ if (city && city.place_count < 24) {++ try { city = await discover.ensureCity(city.name); }++ catch (err) { ctx.log.warn(`[Tours city refresh] ${err.message}`); }++ }++ if (!city) return page(req, res, { index: false, title: 'Not found', body: raw(views.notFound()) }, 404);++ const places = (await store.cityPlaces(city.id, { limit: 200 })).filter((p) => p.fee === 'no');++ return page(req, res, {++ index: true, cache: PUBLIC_CACHE, title: `Free things to do in ${city.name}`,++ description: `Places in ${city.name} that OpenStreetMap marks as free to visit.`,++ jsonLd: [seo.jsonLd.itemList(`Free things to do in ${city.name}`, places.map((p) => ({ name: p.name, url: `${base()}${placePath(p)}` })), { url: `${base()}/city/${city.slug}/free` })],++ body: raw(views.cityPage({ city, places, free: true })),++ });++ });++++ r.get('/places', async (req, res) => {++ const q = typeof req.query.q === 'string' ? req.query.q.trim().slice(0, 120) : '';++ if (!q) return res.redirect(303, '/cities');++ try {++ const place = await discover.ensurePlace(q);++ if (place) return res.redirect(303, placePath(place));++ } catch (err) { ctx.log.warn(`[Tours place lookup] ${err.message}`); }++ return page(req, res, { index: false, title: 'Place not found', body: raw(views.notFound()) }, 404);++ });++++ r.get('/remind-place/:id', async (req, res) => {++ const place = await store.placeById(String(req.params.id || ''));++ const date = String(req.query.date || '');++ if (!place || !/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(`${date}T18:00:00Z`))++ || Date.parse(`${date}T18:00:00Z`) <= Date.now()) return page(req, res, { index: false, title: 'Choose a future date', body: raw(views.notFound()) }, 400);++ const params = new URLSearchParams({++ title: views.placeName(place), at: `${date}T18:00:00Z`, url: `${base()}${placePath(place)}`, from: 'openvibe.tours',++ });++ return res.redirect(303, `https://openvibe.today/remind?${params}`);++ });++// ── Places ───────────────────────────────────────────────r.get('/place/:id', async (req, res) => {-- const place = await store.placeById(String(req.params.id || ''));++ const placeId = String(req.params.id || '').match(/plc_[0-9A-HJKMNP-TV-Z]{26}$/)?.[0];++ const place = placeId ? await discover.enrichPlace(await store.placeById(placeId)) : null;if (!place) return page(req, res, { index: false, title: 'Not found', body: raw(views.notFound()) }, 404);-- const [routeList, trips] = await Promise.all([++ const [routeList, trips, nearby, summary, live] = await Promise.all([store.routesForPlace(place.id),signedIn(req) ? store.tripsForSubject(req.viewer.subject) : Promise.resolve([]),++ store.cityPlaces(place.city_id, { limit: 20 }),++ ctx.narrative.forPlace(place),++ store.placeLive(place.id),]);return page(req, res, {-- index: true, cache: PUBLIC_CACHE, title: views.localName(place) ? `${views.placeName(place)} (${place.name})` : place.name,++ index: true, cache: PUBLIC_CACHE, path: placePath(place), title: views.localName(place) ? `${views.placeName(place)} (${place.name})` : place.name,description: place.description || `${views.placeName(place)} in ${place.city_name}: coordinates, address, opening hours, source and map.`,jsonLd: [{'@context': 'https://schema.org', '@type': 'TouristAttraction', name: views.placeName(place),...(views.localName(place) ? { alternateName: place.name } : {}),-- url: `${base()}/place/${place.id}`, description: place.description || undefined,++ url: `${base()}${placePath(place)}`, description: place.description || undefined,geo: { '@type': 'GeoCoordinates', latitude: place.lat, longitude: place.lon },...(place.address ? { address: { '@type': 'PostalAddress', streetAddress: place.address, addressLocality: place.city_name } } : {}),-- ...(views.safeImage(place.image_url) ? { image: place.image_url } : {}),++ ...(imageLd(place) ? { image: imageLd(place) } : {}),...(place.fee === 'no' ? { isAccessibleForFree: true } : place.fee === 'yes' ? { isAccessibleForFree: false } : {}),sameAs: [place.osm_url],},-- seo.jsonLd.breadcrumbs([{ name: 'Cities', url: `${base()}/cities` }, { name: place.city_name, url: `${base()}/city/${place.city_slug}` }, { name: views.placeName(place), url: `${base()}/place/${place.id}` }]),++ seo.jsonLd.breadcrumbs([{ name: 'Cities', url: `${base()}/cities` }, { name: place.city_name, url: `${base()}/city/${place.city_slug}` }, { name: views.placeName(place), url: `${base()}${placePath(place)}` }]),],-- body: raw(views.placePage({ place, routeList, trips })),++ body: raw(views.placePage({ place, routeList, trips, summary, live, nearby: nearby.filter((p) => p.id !== place.id).slice(0, 6) })),});});nnew file mode 100644@@@ -0,0 +1,15 @@++'use strict';++++const { createIndexNow: sharedIndexNow } = require('openvibe-shared/indexnow');++++function createIndexNow({ baseUrl, key, fetchImpl, log } = {}) {++ const client = sharedIndexNow({ host: baseUrl, key, fetch: fetchImpl, log });++ const origin = String(baseUrl || 'https://openvibe.tours').replace(/\/+$/, '');++ function add(path) {++ if (!/^\/(?:city\/[a-z0-9-]+(?:\/free)?|place\/[a-z0-9-]+-plc_[0-9A-HJKMNP-TV-Z]{26})$/.test(path)) return;++ client.pingSoon(`${origin}${path}`);++ }++ return { ...client, add };++}++++module.exports = { createIndexNow };@@@ -13,6 +13,7 @@ const fs = require('fs');const path = require('path');const { createWikivoyage } = require('../sources/wikivoyage-client');const { createWikidata } = require('../sources/wikidata');++const { createPhotoCache } = require('../places/photo-cache');const { parseCityPage, pageUrl } = require('../sources/wikivoyage');const { slugify } = require('../store');@@@ -32,11 +33,12 @@ function fileTitle(name) {// A run under older rules is not fresh (jobs/index.js): credits-1 also cleans picture credits (and {{unesco}}, rail badges); inline-templates-2 kept// {{convert}}, the unit templates ({{km2}}), {{lang}} and the like in the summaries, so the first boot with it rewrites them.--const CITIES_VERSION = 'credits-1';++const CITIES_VERSION = 'district-see-do-2';function createCitiesJob({ config, s, store, fetchImpl, log = console }) {const wikivoyage = createWikivoyage({ config, fetchImpl, pace: s.pace });const wikidata = createWikidata({ config, fetchImpl, pace: s.pace, log });++ const photoCache = createPhotoCache({ config, s, fetchImpl, log });async function runOnce({ limit = config.openData.cityLimit } = {}) {const catalog = loadCatalog(limit);@@@ -56,7 +58,7 @@ function createCitiesJob({ config, s, store, fetchImpl, log = console }) {const entity = entities.get(resolved.qid);if (!entity || entity.lat == null || entity.lon == null) { result.skipped.push(entry.name); continue; }const country = countries.get(entity.countryQid) || { label: entry.country, code: null };-- const image = images.get(fileTitle(entity.image));++ const image = await photoCache.cache(images.get(fileTitle(entity.image)));const title = entry.wikivoyage || entity.wikivoyageTitle || entry.name;const name = entity.label || entry.name;const city = {@@@ -74,6 +76,7 @@ function createCitiesJob({ config, s, store, fetchImpl, log = console }) {imageCredit: image ? image.credit : null,imageLicense: image ? image.license : null,imageLicenseUrl: image ? image.licenseUrl : null,++ imageSourceUrl: image ? image.sourceUrl : null,source: 'wikidata',sourceUrl: `https://www.wikidata.org/wiki/${entity.qid}`,fetchedAt: entity.fetchedAt || s.now(),@@@ -96,14 +99,14 @@ function createCitiesJob({ config, s, store, fetchImpl, log = console }) {if (main) {const parsed = parseCityPage(main.wikitext, main.title, { districts: config.openData.districtPages });const pages = [{ title: main.title, wikitext: main.wikitext, sourceUrl: main.sourceUrl, fetchedAt: main.fetchedAt }];-- let see = parsed.see.map((x) => ({ ...x, pageUrl: main.sourceUrl }));-- if (see.filter((x) => x.hasCoords).length < 4) {++ let see = [...parsed.see, ...parsed.do].map((x) => ({ ...x, pageUrl: main.sourceUrl }));++ if (parsed.districts.length) {for (const district of parsed.districts.slice(0, config.openData.districtPages)) {try {const sub = await wikivoyage.page(district);if (!sub) continue;const subParsed = parseCityPage(sub.wikitext, sub.title, { districts: 0 });-- see = see.concat(subParsed.see.map((x) => ({ ...x, pageUrl: sub.sourceUrl })));++ see = see.concat([...subParsed.see, ...subParsed.do].map((x) => ({ ...x, pageUrl: sub.sourceUrl })));pages.push({ title: sub.title, wikitext: sub.wikitext, sourceUrl: sub.sourceUrl, fetchedAt: sub.fetchedAt });result.districts += 1;} catch (err) {@@@ -118,7 +118,7 @@ function createJobs({ config, s, store, fetchImpl = globalThis.fetch, log = constimers.length = 0;}-- return { runOnce, runAll, start, stop, isFresh, jobs };++ return { runOnce, runAll, start, stop, isFresh, jobs, places: creators.places };}module.exports = { createJobs };@@@ -19,6 +19,11 @@ function createLiveJob({ config, s, store, fetchImpl, log = console }) {async function runOnce() {const cities = await store.listCities({ limit: 200 });const match = cityMatcher(cities);++ async function location(item) {++ const place = item.placeId ? await store.placeById(item.placeId) : null;++ const city = place ? { id: place.city_id, name: place.city_name } : match(`${item.title} ${item.tags.join(' ')}`);++ return { ...item, placeId: place?.id || null, cityId: city?.id || null, cityName: city?.name || null };++ }const result = { streams: 0, vods: 0, clips: 0, withCity: 0, errors: [] };let streams = [];let recent = [];@@@ -31,28 +36,28 @@ function createLiveJob({ config, s, store, fetchImpl, log = console }) {if (!result.errors.some((e) => e.startsWith('streams:'))) {await store.clearLiveStreams();for (const item of streams) {-- const city = match(`${item.title} ${item.tags.join(' ')}`);-- await store.upsertLive({ ...item, cityId: city ? city.id : null, cityName: city ? city.name : null });++ const located = await location(item);++ await store.upsertLive(located);result.streams += 1;-- if (city) result.withCity += 1;++ if (located.cityId) result.withCity += 1;}}const recordingsOk = !result.errors.some((e) => e.startsWith('recent:')) && !result.errors.some((e) => e.startsWith('clips:'));if (recordingsOk) await store.clearLiveRecordings();if (!result.errors.some((e) => e.startsWith('recent:'))) {for (const item of recent) {-- const city = match(`${item.title} ${item.tags.join(' ')}`);-- await store.upsertLive({ ...item, cityId: city ? city.id : null, cityName: city ? city.name : null });++ const located = await location(item);++ await store.upsertLive(located);result.vods += 1;-- if (city) result.withCity += 1;++ if (located.cityId) result.withCity += 1;}}if (!result.errors.some((e) => e.startsWith('clips:'))) {for (const item of clips) {-- const city = match(`${item.title} `);-- await store.upsertLive({ ...item, cityId: city ? city.id : null, cityName: city ? city.name : null });++ const located = await location(item);++ await store.upsertLive(located);result.clips += 1;-- if (city) result.withCity += 1;++ if (located.cityId) result.withCity += 1;}}await store.trimLiveVods(200);@@@ -3,20 +3,32 @@/*** The places job, in two passes:*-- * 1. Wikivoyage: every city's stored See listings with coordinates become a cited place (CC BY-SA) unless an++ * 1. Wikivoyage: every city's stored See and Do listings with coordinates become a cited place (CC BY-SA) unless an* OpenStreetMap place of the same name or within 120 m is already stored. This makes every city populated* even when Overpass is queueing.-- * 2. OpenStreetMap: one bounded `tourism=*` Overpass query for the cities whose OSM data is oldest, limited by++ * 2. OpenStreetMap: one bounded, named-destination Overpass query for the cities whose OSM data is oldest, limited by* TOURS_OSM_CITIES_PER_RUN so one run never hammers the public instance. ODbL; every place keeps its* openstreetmap.org URL and the fetch time. A failure leaves the previous rows and the Wikivoyage fallback.*/const { createOverpass } = require('../sources/osm');++const { createWikidata } = require('../sources/wikidata');++const { createPhotoCache } = require('../places/photo-cache');++const { createOpenverse } = require('../sources/openverse');++const { fetchJson } = require('../sources/http');const { parseCityPage } = require('../sources/wikivoyage');const { stableId } = require('../store');++const { isPlace } = require('../places/is-place');// A run under older rules is not fresh (jobs/index.js): name-en-1 stored OpenStreetMap's English names; wv-text-1// rewrites the stored Wikivoyage places' text with the current parser.--const PLACES_VERSION = 'wv-text-1';++const PLACES_VERSION = 'notable-places-2';++++function commonsFile(value) {++ const raw = String(value || '').trim();++ if (/^(?:File|Image):/i.test(raw)) return `File:${raw.replace(/^(?:File|Image):/i, '')}`;++ if (/^https:\/\/commons\.wikimedia\.org\/wiki\/File:/i.test(raw)) return `File:${decodeURIComponent(raw.split('/wiki/File:')[1]).replace(/_/g, ' ')}`;++ return null;++}const R = 6_371_000;function distanceM(a, b) {@@@ -27,8 +39,91 @@ function distanceM(a, b) {return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));}++function rankPlace(place, entity, views = 0) {++ return (place.source === 'wikivoyage' ? 300 : 0)++ + Math.min(1000, Number(entity?.sitelinks || 0)) * 20++ + Math.round(Math.log10(Math.max(0, views) + 1) * 1000)++ + (place.imageUrl ? 500 : 0) + (place.description ? 200 : 0);++}++++function whyAfterLead(text) {++ const sentences = String(text || '').match(/[^.!?]+[.!?]+(?:\s|$)/g)?.map((s) => s.trim()) || [];++ return sentences.slice(1, 4).join(' ').slice(0, 600) || null;++}++function createPlacesJob({ config, s, store, fetchImpl, log = console }) {const osm = createOverpass({ config, fetchImpl, pace: s.pace });++ const wikidata = createWikidata({ config, fetchImpl, pace: s.pace, log });++ const photoCache = createPhotoCache({ config, s, fetchImpl, log });++ const openverse = createOpenverse({ config, fetchImpl, pace: s.pace });++++ async function pageviewsFor(entities) {++ const out = new Map();++ if (config.drill) return out;++ const ranked = [...entities.values()].filter((e) => e.wikipediaTitle)++ .sort((a, b) => b.sitelinks - a.sitelinks).slice(0, 5);++ const today = new Date();++ const month = `${new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - 1, 1)).toISOString().slice(0, 7).replace('-', '')}0100`;++ for (const entity of ranked) {++ const title = entity.wikipediaTitle;++ const cached = await s.db.maybe(`SELECT views FROM tours_pageview_cache WHERE wikipedia_title=$1 AND fetched_at > now()-interval '7 days'`, [title]);++ if (cached) { out.set(entity.qid, Number(cached.views)); continue; }++ try {++ const path = encodeURIComponent(title.replace(/ /g, '_'));++ const { json } = await fetchJson(`https://wikimedia.org/api/rest_v1/metrics/pageviews/per-article/en.wikipedia/all-access/user/${path}/monthly/${month}/${month}`,++ { fetchImpl, pace: s.pace, userAgent: config.openData.userAgent, timeoutMs: 8000, retries: 0, label: 'Wikimedia pageviews' });++ const views = Math.max(0, Number(json?.items?.[0]?.views) || 0);++ out.set(entity.qid, views);++ await s.db.exec(`INSERT INTO tours_pageview_cache(wikipedia_title, views) VALUES ($1,$2)++ ON CONFLICT(wikipedia_title) DO UPDATE SET views=EXCLUDED.views, fetched_at=now()`, [title, views]);++ } catch { /* Sitelinks still provide a stable notability signal. */ }++ }++ return out;++ }++++ async function photosFor(places) {++ const qids = places.map((p) => p.wikidataId).filter(Boolean);++ const entities = qids.length ? await wikidata.entities(qids) : new Map();++ const pageviews = await pageviewsFor(entities);++ const files = places.map((p) => {++ const p18 = entities.get(p.wikidataId)?.image;++ return commonsFile(p.imageFile) || (p18 ? commonsFile(p18) || `File:${p18}` : null);++ });++ const galleries = new Map();++ for (let i = 0, searched = 0; i < places.length && searched < 5; i++) {++ const category = /^Category:/i.test(String(places[i].imageFile || ''))++ ? places[i].imageFile : entities.get(places[i].wikidataId)?.commonsCategory;++ if (!category) continue;++ searched += 1;++ try {++ const found = (await wikidata.categoryFiles(category)).slice(0, 3);++ galleries.set(i, found);++ if (!files[i]) files[i] = found[0] || null;++ } catch { /* no verified photo */ }++ }++ const images = await wikidata.imageInfo([...files.filter(Boolean), ...[...galleries.values()].flat()]);++ let fallbacks = 0;++ return Promise.all(places.map(async (p, i) => {++ let image = images.get(files[i]) || (galleries.get(i) || []).map((file) => images.get(file)).find(Boolean);++ if (!image && !config.drill && fallbacks < 3) {++ fallbacks += 1;++ try { image = await openverse.image(p.name); } catch { /* no verified photo */ }++ }++ const cached = image ? await photoCache.cache(image) : null;++ const gallery = [];++ for (const file of galleries.get(i) || []) {++ const candidate = images.get(file);++ if (!candidate || candidate.sourceUrl === cached?.sourceUrl) continue;++ const photo = await photoCache.cache(candidate);++ if (photo) gallery.push({ url: photo.url, credit: photo.credit, license: photo.license,++ licenseUrl: photo.licenseUrl, sourceUrl: photo.sourceUrl });++ }++ const pictured = cached ? { ...p, imageUrl: cached.url, imageCredit: cached.credit,++ imageLicense: cached.license, imageLicenseUrl: cached.licenseUrl, imageSourceUrl: cached.sourceUrl, gallery } : p;++ return { ...pictured, rankScore: Math.max(Number(p.rankScore) || 0,++ rankPlace(pictured, entities.get(p.wikidataId), pageviews.get(p.wikidataId) || 0)) };++ }));++ }async function seedWikivoyage(city) {const source = await store.citySource(city.id);@@@ -40,15 +135,17 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {else if (parsed && Array.isArray(parsed.pages)) {for (const page of parsed.pages) {const p = parseCityPage(page.wikitext || '', page.title || '', { districts: 0 });-- listings = listings.concat(p.see.map((x) => ({ ...x, pageUrl: page.sourceUrl || source.source_url })));++ listings = listings.concat([...p.see, ...p.do].map((x) => ({ ...x, pageUrl: page.sourceUrl || source.source_url })));}}const existing = await store.cityPlaces(city.id, { limit: 200 });let n = 0;++ const prepared = await photosFor(listings.filter((x) => x.wikidata).map((x) => ({ wikidataId: x.wikidata })));++ const photos = new Map(listings.filter((x) => x.wikidata).map((x, i) => [x.wikidata, prepared[i]]));for (const listing of listings) {if (!listing.hasCoords || !Number.isFinite(Number(listing.lat)) || !Number.isFinite(Number(listing.lon))) continue;const name = String(listing.name || '').trim();-- if (!name) continue;++ if (!name || !isPlace(name)) continue;const key = name.toLowerCase();const osmId = stableId('wv', `${city.id}:${key}:${listing.lat}:${listing.lon}`);// A Wikivoyage place already stored is written again, so its text follows the parser (a description stored@@@ -65,11 +162,14 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {lat: Number(listing.lat),lon: Number(listing.lon),description: listing.note || null,++ guideWhy: whyAfterLead(listing.note),wikidataId: listing.wikidata || null,source: 'wikivoyage',sourceUrl: listing.pageUrl || source.source_url,fetchedAt: source.fetched_at,++ ...photos.get(listing.wikidata),};++ place.rankScore = (place.rankScore || 0) + 300;await store.upsertPlace(place);existing.push({ name, lat: place.lat, lon: place.lon, id: null });n += 1;@@@ -77,6 +177,58 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {return n;}++ async function seedWikidata(city) {++ if (!city.wikidata_id) return 0;++ const cached = await s.db.maybe(`SELECT payload FROM tours_wikidata_city_places++ WHERE city_id=$1 AND fetched_at > now()-interval '7 days'`, [city.id]);++ let found = cached?.payload;++ if (typeof found === 'string') found = JSON.parse(found);++ if (!Array.isArray(found)) {++ found = await wikidata.notablePlaces(city.wikidata_id);++ await s.db.exec(`INSERT INTO tours_wikidata_city_places(city_id,payload) VALUES ($1,$2::jsonb)++ ON CONFLICT(city_id) DO UPDATE SET payload=EXCLUDED.payload,fetched_at=now()`, [city.id, JSON.stringify(found)]);++ }++ const existing = await store.cityPlaces(city.id, { limit: 200 });++ const candidates = found.filter((x) => !existing.some((p) => p.wikidata_id === x.qid ||++ p.name.toLowerCase() === x.name.toLowerCase() || distanceM(p, x) <= 120)).map((x) => ({++ cityId: city.id, osmType: 'wikidata', osmId: x.qid, wikidataId: x.qid,++ name: x.name, kind: 'attraction', lat: x.lat, lon: x.lon,++ rankScore: Math.min(1000, x.sitelinks) * 20,++ source: 'wikidata', sourceUrl: x.sourceUrl, fetchedAt: x.fetchedAt,++ }));++ const photographed = await photosFor(candidates);++ for (const place of photographed) await store.upsertPlace(place);++ return photographed.length;++ }++++ async function populateCity(city) {++ const seeded = await seedWikivoyage(city);++ let notable = 0;++ try { notable = await seedWikidata(city); }++ catch (err) { log.warn(`[Tours places] Wikidata ${city.slug}: ${err.message}`); }++ if (city.osm_fetched_at && Date.now() - new Date(city.osm_fetched_at).getTime() < 7 * 86_400_000) return { seeded, notable, osm: 0 };++ await store.markCityOsmAttempted(city.id);++ const { elements, fetchedAt } = await osm.tourism(city.lat, city.lon, config.openData.radiusM);++ const existing = await store.cityPlaces(city.id, { limit: 200 });++ const seen = new Set();++ const candidates = elements.map((el) => osm.placeFromElement(el, city.id, fetchedAt)).filter(Boolean);++ const photographed = await photosFor(candidates);++ let count = 0;++ let ordinaryCount = 0;++ for (const place of photographed.sort((a, b) => (b.rankScore || 0) - (a.rankScore || 0))) {++ if (!place.wikidataId && ordinaryCount >= config.openData.maxPerCity) continue;++ const key = `${place.osmType}:${place.osmId}`;++ if (seen.has(key)) continue;++ seen.add(key);++ if (existing.some((p) => p.source === 'wikivoyage' && (p.name.toLowerCase() === place.name.toLowerCase() || distanceM(p, place) <= 120))) continue;++ await store.upsertPlace(place);++ count += 1;++ if (!place.wikidataId) ordinaryCount += 1;++ }++ await store.markCityOsmFetched(city.id);++ return { seeded, notable, osm: count };++ }++async function runOnce({ limit = config.openData.cityLimit, osmLimit = config.openData.osmCitiesPerRun } = {}) {const result = { cities: 0, wikivoyagePlaces: 0, places: 0, osmCities: 0, empty: [], errors: [], version: PLACES_VERSION };@@@ -97,22 +249,8 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {const osmCities = await store.citiesForOsm(want);for (const [i, city] of osmCities.entries()) {try {-- const { elements, fetchedAt } = await osm.tourism(city.lat, city.lon, config.openData.radiusM);-- const seen = new Set();-- const existing = await store.cityPlaces(city.id, { limit: 200 });-- let n = 0;-- for (const el of elements) {-- if (n >= config.openData.maxPerCity) break;-- const place = osm.placeFromElement(el, city.id, fetchedAt);-- if (!place) continue;-- const key = `${place.osmType}:${place.osmId}`;-- if (seen.has(key)) continue;-- seen.add(key);-- if (existing.some((p) => p.source === 'wikivoyage' && (p.name.toLowerCase() === place.name.toLowerCase() || distanceM(p, place) <= 120))) continue;-- await store.upsertPlace(place);-- n += 1;-- }-- await store.markCityOsmFetched(city.id);++ const filled = await populateCity(city);++ const n = filled.osm;result.places += n;result.osmCities += 1;if (!n) result.empty.push(city.slug);@@@ -125,7 +263,7 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {return result;}-- return { runOnce, seedWikivoyage, distanceM, version: PLACES_VERSION };++ return { runOnce, seedWikivoyage, populateCity, photosFor, distanceM, version: PLACES_VERSION };}--module.exports = { createPlacesJob, distanceM };++module.exports = { createPlacesJob, distanceM, rankPlace };@@@ -99,7 +99,7 @@ const document = {'/api/v1/ping': { get: { summary: 'Liveness', responses: json({ type: 'object', properties: { ok: { type: 'boolean' }, service: { type: 'string' } } }) } },'/api/v1/cities': {get: {-- summary: 'List the top cities', tags: ['Cities'],++ summary: 'List cities; a new city name is resolved and cached on demand', tags: ['Cities'],parameters: [query('limit', { type: 'integer', minimum: 1, maximum: 200, default: 100 }), query('offset', { type: 'integer', minimum: 0, default: 0 }), query('q', { type: 'string', maxLength: 80 })],responses: json({ type: 'object', properties: { count: { type: 'integer' }, cities: { type: 'array', items: { $ref: '#/components/schemas/City' } } } }),},@@@ -110,6 +110,11 @@ const document = {responses: { ...json({ type: 'object', properties: { city: { $ref: '#/components/schemas/City' }, places: { type: 'array', items: { $ref: '#/components/schemas/Place' } }, routes: { type: 'array', items: { $ref: '#/components/schemas/Route' } }, live: { type: 'array', items: { $ref: '#/components/schemas/Live' } }, stays: { type: 'array', items: { $ref: '#/components/schemas/Stay' } } } }), '404': { description: 'No such city' } },},},++ '/api/v1/places': {++ get: { summary: 'Resolve a place name and return its sourced place page data', tags: ['Places'],++ parameters: [query('q', { type: 'string', minLength: 3, maxLength: 120 })],++ responses: { ...json({ type: 'object', properties: { place: { $ref: '#/components/schemas/Place' } } }), '404': { description: 'No such place' } } },++ },'/api/v1/places/{id}': {get: { summary: 'One place', tags: ['Places'], parameters: [pathParam('id', 'plc_01JABCDEFGHJKMNPQRSTVWXYZ0')], responses: { ...json({ type: 'object', properties: { place: { $ref: '#/components/schemas/Place' }, routes: { type: 'array', items: { $ref: '#/components/schemas/Route' } } } }), '404': { description: 'No such place' } } },},nnew file mode 100644@@@ -0,0 +1,167 @@++'use strict';++++const { fetchJson, createPacer } = require('../sources/http');++const { createWikidata } = require('../sources/wikidata');++const { createWikivoyage } = require('../sources/wikivoyage-client');++const { parseCityPage } = require('../sources/wikivoyage');++const { placeFromElement } = require('../sources/osm');++const { slugify } = require('../store');++const { createPhotoCache } = require('./photo-cache');++++function createDiscover({ config, s, store, jobs, fetchImpl = globalThis.fetch, log = console }) {++ const nominatimPace = createPacer(Math.max(1100, config.openData.minIntervalMs));++ const wikidata = createWikidata({ config, fetchImpl, pace: s.pace, log });++ const wikivoyage = createWikivoyage({ config, fetchImpl, pace: s.pace });++ const photoCache = createPhotoCache({ config, s, fetchImpl, log });++ const pending = new Map();++ const keyOf = (q) => String(q || '').trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 120);++++ async function search(q, limit = 5) {++ const url = `${config.upstreams.nominatim}/search?${new URLSearchParams({ q, format: 'jsonv2', addressdetails: '1', extratags: '1', namedetails: '1', limit: String(limit) })}`;++ const { json } = await fetchJson(url, {++ fetchImpl, pace: nominatimPace, userAgent: config.openData.userAgent,++ timeoutMs: Math.min(12_000, config.upstreams.timeoutMs), label: 'Nominatim', retries: 0,++ });++ return Array.isArray(json) ? json : [];++ }++++ async function ensureCity(name, { populate = true } = {}) {++ const key = keyOf(name);++ if (key.length < 2 || key.length > 80) return null;++ const existing = await store.cityBySlug(slugify(key));++ if (existing) {++ if (!populate) return existing;++ if (pending.has(`city:${key}`)) return pending.get(`city:${key}`);++ const task = (async () => {++ const source = await store.citySource(existing.id);++ let stored = null;++ try { stored = JSON.parse(source?.wikitext || 'null'); } catch { /* old source format */ }++ let pages = Array.isArray(stored?.pages) ? [...stored.pages] : [];++ if (!pages.length) {++ const main = await wikivoyage.page(existing.wikivoyage_title || existing.name);++ if (main) pages = [main];++ }++ const districts = pages.length ? parseCityPage(pages[0].wikitext, pages[0].title,++ { districts: config.openData.districtPages }).districts : [];++ const known = new Set(pages.map((p) => p.title));++ const missing = districts.filter((title) => !known.has(title));++ for (const title of missing) {++ try {++ const sub = await wikivoyage.page(title);++ if (sub) pages.push(sub);++ } catch (err) { log.warn(`[Tours discover] Wikivoyage ${title}: ${err.message}`); }++ }++ if (pages.length && (!source || missing.length)) await store.upsertCitySource(existing.id, {++ wikivoyageTitle: pages[0].title, wikitext: JSON.stringify({ pages }),++ sourceUrl: pages[0].sourceUrl, fetchedAt: pages[0].fetchedAt,++ });++ const osmFresh = existing.osm_fetched_at && Date.now() - new Date(existing.osm_fetched_at).getTime() < 7 * 86_400_000;++ if (missing.length || !osmFresh) {++ try { await jobs.places.populateCity(existing); }++ catch (err) { log.warn(`[Tours discover] places ${existing.name}: ${err.message}`); }++ }++ return store.cityBySlug(existing.slug);++ })().finally(() => pending.delete(`city:${key}`));++ pending.set(`city:${key}`, task);++ return task;++ }++ const cached = await s.db.maybe('SELECT city_id FROM tours_city_lookup WHERE query=$1 AND expires_at > now()', [key]);++ if (cached) return cached.city_id ? store.cityById(cached.city_id) : null;++ if (pending.has(`city:${key}`)) return pending.get(`city:${key}`);++ const task = (async () => {++ const hits = await search(name);++ const hit = hits.find((h) => ['city', 'town', 'municipality', 'village'].includes(h.addresstype || h.type)) || hits[0];++ const cityName = hit && (hit.address?.city || hit.address?.town || hit.address?.municipality || hit.address?.village || hit.name);++ const qid = hit?.extratags?.wikidata || null;++ const resolved = qid ? { qid } : (await wikidata.resolveQids([cityName || name])).get(cityName || name);++ if (!hit || !resolved || !/^Q[1-9][0-9]{0,11}$/.test(resolved.qid)) {++ await s.db.exec(`INSERT INTO tours_city_lookup(query, city_id, expires_at) VALUES ($1,NULL,now()+interval '1 day')++ ON CONFLICT(query) DO UPDATE SET city_id=NULL, expires_at=EXCLUDED.expires_at`, [key]);++ return null;++ }++ const entity = (await wikidata.entities([resolved.qid])).get(resolved.qid);++ const nameOut = entity?.label || cityName || String(name).trim();++ const file = entity?.image ? (/^File:/i.test(entity.image) ? entity.image : `File:${entity.image}`) : null;++ const image = await photoCache.cache(file ? (await wikidata.imageInfo([file])).get(file) : null);++ const now = new Date().toISOString();++ const city = {++ name: nameOut, slug: slugify(nameOut), country: hit.address?.country || 'Unknown',++ countryCode: hit.address?.country_code?.toUpperCase() || null, region: hit.address?.state || null,++ lat: Number(hit.lat), lon: Number(hit.lon), population: entity?.population || null,++ wikidataId: resolved.qid, wikipediaTitle: nameOut, wikivoyageTitle: entity?.wikivoyageTitle || nameOut,++ imageUrl: image?.url || null, imageCredit: image?.credit || null, imageLicense: image?.license || null,++ imageLicenseUrl: image?.licenseUrl || null, imageSourceUrl: image?.sourceUrl || null,++ source: 'wikidata', sourceUrl: `https://www.wikidata.org/wiki/${resolved.qid}`, fetchedAt: now,++ };++ let page = null;++ try { page = await wikivoyage.page(city.wikivoyageTitle); } catch (err) { log.warn(`[Tours discover] Wikivoyage ${nameOut}: ${err.message}`); }++ if (page) {++ city.summary = parseCityPage(page.wikitext, page.title, { districts: 0 }).summary;++ city.wikivoyageTitle = page.title;++ }++ const cityId = await store.upsertCity(city);++ if (page) {++ const pages = [page];++ const districts = parseCityPage(page.wikitext, page.title, { districts: config.openData.districtPages }).districts;++ for (const district of districts) {++ try {++ const sub = await wikivoyage.page(district);++ if (sub) pages.push(sub);++ } catch (err) { log.warn(`[Tours discover] Wikivoyage ${district}: ${err.message}`); }++ }++ await store.upsertCitySource(cityId, {++ wikivoyageTitle: page.title, wikitext: JSON.stringify({ pages }),++ sourceUrl: page.sourceUrl, fetchedAt: page.fetchedAt,++ });++ }++ await s.db.exec(`INSERT INTO tours_city_lookup(query, city_id, expires_at) VALUES ($1,$2,now()+interval '30 days')++ ON CONFLICT(query) DO UPDATE SET city_id=EXCLUDED.city_id, expires_at=EXCLUDED.expires_at`, [key, cityId]);++ const stored = await store.cityById(cityId);++ if (populate) {++ try { await jobs.places.populateCity(stored); } catch (err) { log.warn(`[Tours discover] places ${nameOut}: ${err.message}`); }++ }++ return store.cityById(cityId);++ })().finally(() => pending.delete(`city:${key}`));++ pending.set(`city:${key}`, task);++ return task;++ }++++ async function ensurePlace(name) {++ const key = keyOf(name);++ if (key.length < 3 || key.length > 120) return null;++ if (pending.has(`place:${key}`)) return pending.get(`place:${key}`);++ const task = (async () => {++ const hits = await search(name, 3);++ const hit = hits.find((h) => h.osm_type && h.osm_id && h.name && Number.isFinite(Number(h.lat)) && Number.isFinite(Number(h.lon)));++ if (!hit) return null;++ const cityName = hit.address?.city || hit.address?.town || hit.address?.municipality || hit.address?.village;++ if (!cityName) return null;++ const city = await ensureCity(cityName, { populate: false });++ if (!city) return null;++ const tags = { name: hit.name, ...hit.extratags };++ if (!tags.tourism && !tags.leisure) tags[['park', 'garden'].includes(hit.type) ? 'leisure' : 'tourism'] = hit.type || 'attraction';++ const place = placeFromElement({ type: hit.osm_type, id: hit.osm_id, lat: hit.lat, lon: hit.lon, tags }, city.id, new Date().toISOString());++ if (!place) return null;++ const [photographed] = await jobs.places.photosFor([place]);++ const id = await store.upsertPlace(photographed);++ return enrichPlace(await store.placeById(id));++ })().finally(() => pending.delete(`place:${key}`));++ pending.set(`place:${key}`, task);++ return task;++ }++++ async function enrichPlace(place) {++ if (!place || place.description || (!place.wikipedia && !place.wikidata_id)) return place;++ try {++ const fromTag = String(place.wikipedia || '').match(/^en:(.{2,120})$/)?.[1] || null;++ const entity = !fromTag && place.wikidata_id ? (await wikidata.entities([place.wikidata_id])).get(place.wikidata_id) : null;++ const title = fromTag || entity?.wikipediaTitle;++ const guide = title ? await wikidata.article(title) : null;++ return guide ? store.updatePlaceGuide(place.id, guide) : place;++ } catch (err) { log.warn(`[Tours discover] Wikipedia ${place.id}: ${err.message}`); return place; }++ }++++ return { ensureCity, ensurePlace, enrichPlace, search };++}++++module.exports = { createDiscover };nnew file mode 100644@@@ -0,0 +1,10 @@++'use strict';++++// A ticket product, seller or shop is never a destination, even if a source tags it as an attraction.++function isPlace(name, { shop, office, instanceLabel = '' } = {}) {++ if (shop || office) return false;++ const text = `${name || ''} ${instanceLabel}`;++ return !/\b(?:city\s*pass|citypass|pass(?:es)?|ticket(?:s|ing)?|tours|excursions?|tour\s*(?:operator|company|agency)|travel\s*agency|shop|store|retail(?:er)?)\b/i.test(text);++}++++module.exports = { isPlace };nnew file mode 100644@@@ -0,0 +1,39 @@++'use strict';++++const crypto = require('crypto');++++function createNarrative({ config, s, fetchImpl = globalThis.fetch, log = console }) {++ let ai = null;++ if (config.ai.url && config.oauth.clientSecret && !config.drill) {++ const { serviceAuth } = require('openvibe-contracts');++ const { createAiClient } = require('openvibe-publishing/ai');++ const tokenClient = serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret, audience: 'openvibe.ai', scope: config.ai.scope, fetchImpl,++ });++ ai = createAiClient({ baseUrl: config.ai.url, tokenClient, fetch: fetchImpl, waitMs: 4000, timeoutMs: 6000, pollMs: 500 });++ }++++ async function forPlace(place) {++ if (!place?.description || !place.source_url) return null;++ const old = await s.db.maybe('SELECT summary, run_id FROM tours_place_summaries WHERE place_id=$1 AND source_fetched_at=$2::timestamptz', [place.id, place.fetched_at]);++ if (old) return { text: old.summary, runId: old.run_id, sourceUrl: place.source_url };++ if (!ai?.enabled) return null;++ try {++ const key = crypto.createHash('sha256').update(`${place.id}:${place.fetched_at}:${place.description}`).digest('hex');++ const r = await ai.run('ai.summarize', {++ text: `${place.name}: ${place.description}`, max_words: 70, style: 'a short, inviting visitor guide using only the source facts',++ }, { target: { service: 'tours', type: 'place', id: place.id }, idempotencyKey: `tours-place-${key}` });++ const summary = String(r.output?.text || '').trim().slice(0, 800);++ if (!summary || r.synthetic) return null;++ await s.db.exec(`INSERT INTO tours_place_summaries(place_id, source_fetched_at, summary, run_id)++ VALUES ($1,$2::timestamptz,$3,$4) ON CONFLICT(place_id) DO UPDATE SET++ source_fetched_at=EXCLUDED.source_fetched_at, summary=EXCLUDED.summary, run_id=EXCLUDED.run_id, updated_at=now()`,++ [place.id, place.fetched_at, summary, r.runId]);++ return { text: summary, runId: r.runId, sourceUrl: place.source_url };++ } catch (err) { log.warn(`[Tours narrative] ${place.id}: ${err.message}`); return null; }++ }++ return { forPlace };++}++++module.exports = { createNarrative };nnew file mode 100644@@@ -0,0 +1,52 @@++'use strict';++++// A checked Commons image is copied once into Media as a public object. The file's Commons page,++// author and licence stay on the place row; Media serves only the smaller image bytes.++function createPhotoCache({ config, s, fetchImpl = globalThis.fetch, log = console }) {++ let objects;++ function client() {++ if (!objects) {++ const { createServiceTokenClient } = require('openvibe-sdk/auth');++ const { createObjectsClient } = require('openvibe-sdk/media');++ const tokenClient = createServiceTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret, audience: 'openvibe.media', scope: config.media.scope,++ });++ objects = createObjectsClient({ app: 'tours', baseUrl: config.media.url, tokenClient });++ }++ return objects;++ }++ async function cache(image) {++ if (!image?.sourceUrl || !image?.url) return image;++ if (config.drill) return image.provider === 'openverse' ? null : image;++ if (!config.oauth.clientSecret) return null;++ const old = await s.db.maybe('SELECT media_url FROM tours_photo_assets WHERE source_url=$1', [image.sourceUrl]);++ if (old) return { ...image, url: old.media_url };++ try {++ const src = new URL(image.url);++ const commons = /^(?:thumb|upload)\.wikimedia\.org$/.test(src.hostname);++ const openverse = src.hostname === 'api.openverse.org' && /^\/v1\/images\/[0-9a-f-]{36}\/thumb\/$/i.test(src.pathname);++ if (src.protocol !== 'https:' || (!commons && !openverse)) return null;++ if (commons) src.pathname = src.pathname.replace(/\/\d+px-([^/]+)$/, '/640px-$1');++ const res = await fetchImpl(src.toString(), { headers: { 'User-Agent': config.openData.userAgent }, signal: AbortSignal.timeout(12_000) });++ const type = String(res.headers.get('content-type') || '').split(';')[0].toLowerCase();++ const length = Number(res.headers.get('content-length') || 0);++ if (!res.ok || !/^image\/(?:jpeg|png|webp)$/.test(type) || length > 4_000_000) return null;++ const bytes = Buffer.from(await res.arrayBuffer());++ if (bytes.length > 4_000_000) return null;++ const file = image.sourceUrl.split('/').pop().slice(0, 100) || 'place-photo.jpg';++ const obj = await client().upload(bytes, { kind: 'file', visibility: 'public', mimeType: type, filename: file });++ const url = obj.public_url || obj.url;++ if (!url || !/^https:\/\/openvibe\.media\//.test(url)) return null;++ await s.db.exec(`INSERT INTO tours_photo_assets(source_url, media_id, media_url) VALUES ($1,$2,$3)++ ON CONFLICT(source_url) DO NOTHING`, [image.sourceUrl, obj.id, url]);++ return { ...image, url };++ } catch (err) {++ log.warn(`[Tours photos] Media copy unavailable: ${err.message}`);++ return null;++ }++ }++ return { cache };++}++++module.exports = { createPhotoCache };@@@ -89,7 +89,7 @@ function createSearchIndex({ config, s, log = console, outbox = null }) {owner: 'tours', db, outbox: out, baseUrl: config.baseUrl, now: s.now, log,types: {place: {-- page: (row) => `/place/${row.id}`,++ page: (row) => require('./store').placePath(row),document: describePlace,rows: page(`SELECT p.id, p.name, p.name_en, p.kind, p.description, p.address, p.fee, p.wheelchair, p.created_at, p.updated_at,c.name AS city_name, c.slug AS city_slug, c.country@@@ -30,6 +30,7 @@ function categoryOf(s) {}function isIrl(s) {++ if (placeIdOf(s)) return true;const tags = parseTags(s.tags).map((t) => t.toLowerCase());if (tags.includes('irl') || tags.includes('travel')) return true;if (IRL_WORDS.test(String(s.title || ''))) return true;@@@ -39,6 +40,11 @@ function isIrl(s) {return IRL_CATEGORIES.has(String((s && s.category) || '').toLowerCase());}++function placeIdOf(s) {++ const url = String(s.place_url || '');++ return url.match(/^https:\/\/openvibe\.tours\/place\/[a-z0-9-]+-(plc_[0-9A-HJKMNP-TV-Z]{26})$/)?.[1] || null;++}++function streamOf(s, { fetchedAt, liveUrl, videoBase }) {const username = String(s.username || '').slice(0, 64);if (!username) return null;@@@ -58,6 +64,7 @@ function streamOf(s, { fetchedAt, liveUrl, videoBase }) {fetchedAt,tags: parseTags(s.tags),videoBase,++ placeId: placeIdOf(s),};}@@@ -93,6 +100,7 @@ function clipOf(c, { fetchedAt, videoBase }) {endedAt: utc(c.created_at),fetchedAt,tags: [],++ placeId: placeIdOf(c),};}@@@ -144,4 +152,4 @@ function createLive({ config, fetchImpl, pace }) {return { streams, recent, clips, isIrl, parseTags, categoryOf };}--module.exports = { createLive, isIrl, streamOf, vodOf, clipOf, parseTags, utc, IRL_CATEGORIES };++module.exports = { createLive, isIrl, streamOf, vodOf, clipOf, placeIdOf, parseTags, utc, IRL_CATEGORIES };nnew file mode 100644@@@ -0,0 +1,34 @@++'use strict';++++const { fetchJson } = require('./http');++const ALLOWED = new Set(['by', 'by-sa', 'cc0', 'pdm']);++++function asImage(row, query) {++ if (!row || row.mature || !ALLOWED.has(String(row.license || '').toLowerCase())) return null;++ const title = String(row.title || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();++ const phrase = String(query || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();++ if (!phrase || !title.includes(phrase) || !row.creator || !row.foreign_landing_url || !row.id) return null;++ const source = String(row.foreign_landing_url);++ const thumb = `https://api.openverse.org/v1/images/${encodeURIComponent(row.id)}/thumb/`;++ if (!/^https:\/\//.test(source) || !/^[0-9a-f-]{36}$/i.test(row.id)) return null;++ const license = String(row.license).toLowerCase();++ return {++ url: thumb, credit: String(row.creator).slice(0, 250),++ license: license === 'pdm' ? 'PDM' : license === 'cc0' ? 'CC0' : `CC ${license.toUpperCase()} ${String(row.license_version || '').trim()}`.trim(),++ licenseUrl: row.license_url || null, sourceUrl: source, provider: 'openverse',++ };++}++++function createOpenverse({ config, fetchImpl, pace }) {++ async function image(name) {++ const q = String(name || '').trim().slice(0, 100);++ if (q.length < 3) return null;++ const url = `${config.upstreams.openverse}/v1/images/?${new URLSearchParams({ q, license: 'by,by-sa,cc0,pdm', mature: 'false', page_size: '5' })}`;++ const { json } = await fetchJson(url, { fetchImpl, pace, timeoutMs: Math.min(config.upstreams.timeoutMs, 10_000),++ userAgent: config.openData.userAgent, label: 'Openverse', retries: 0 });++ return ((json && json.results) || []).map((r) => asImage(r, q)).find(Boolean) || null;++ }++ return { image };++}++++module.exports = { createOpenverse, asImage };@@@ -1,17 +1,23 @@'use strict';/**-- * OpenStreetMap through Overpass: one bounded `around:` query per city, tourism=* only, `out center tags`.++ * OpenStreetMap through Overpass: one bounded `around:` query per city, named destinations only.* ODbL; every place keeps its openstreetmap.org URL and the query's fetch time. No bulk download.*/const { fetchText } = require('./http');++const { isPlace } = require('../places/is-place');// What a traveller means by "place". Lodging is Rent's; a hotel is not an attraction.--const INTERESTING = new Set(['attraction', 'museum', 'viewpoint', 'artwork', 'gallery', 'zoo', 'theme_park', 'aquarium', 'information', 'picnic_site', 'botanical_garden']);++const INTERESTING = new Set(['attraction', 'museum', 'viewpoint', 'gallery', 'zoo', 'theme_park', 'aquarium', 'park', 'garden', 'nature_reserve']);--function overpassQuery(lat, lon, radiusM) {-- const r = Math.max(100, Math.min(10_000, Math.round(Number(radiusM) || 3500)));-- return `[out:json][timeout:25];nwr["tourism"](around:${r},${Number(lat)},${Number(lon)});out center tags 160;`;++function overpassQuery(lat, lon, radiusM, tagged = false) {++ const r = Math.max(100, Math.min(25_000, Math.round(Number(radiusM) || 15_000)));++ const group = (wikidata) => {++ const wd = wikidata ? '["wikidata"]' : '';++ return `(nwr["tourism"~"^(attraction|museum|gallery|viewpoint|zoo|aquarium|theme_park)$"]["name"]${wd}(around:${r},${Number(lat)},${Number(lon)});nwr["leisure"~"^(park|garden|nature_reserve)$"]["name"]${wd}(around:${r},${Number(lat)},${Number(lon)}););`;++ };++ // Overpass emits the tagged group in full before the capped general group, in one paced request.++ return `[out:json][timeout:25];${group(true)}out center tags;${tagged ? '' : `${group(false)}out center tags 400;`}`;}function commonsImage(tag) {@@@ -30,8 +36,8 @@ function addressOf(tags) {function placeFromElement(el, cityId, fetchedAt) {const tags = el.tags || {};-- const kind = String(tags.tourism || '').toLowerCase();-- if (!INTERESTING.has(kind) || !tags.name) return null;++ const kind = String(tags.tourism || tags.leisure || '').toLowerCase();++ if (!INTERESTING.has(kind) || !tags.name || !isPlace(tags.name, tags)) return null;const lat = Number.isFinite(Number(el.lat)) ? Number(el.lat) : Number(el.center && el.center.lat);const lon = Number.isFinite(Number(el.lon)) ? Number(el.lon) : Number(el.center && el.center.lon);if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;@@@ -53,8 +59,10 @@ function placeFromElement(el, cityId, fetchedAt) {fee: ['yes', 'no'].includes(tags.fee) ? tags.fee : null,wikidataId: /^Q[1-9][0-9]{0,11}$/.test(tags.wikidata || '') ? tags.wikidata : null,wikipedia: tags.wikipedia || null,-- imageUrl: commonsImage(tags.image) || commonsImage(tags.wikimedia_commons),-- imageCredit: 'OpenStreetMap contributor',++ // OSM tags do not carry an image licence or author. The places job resolves these through Commons.++ imageFile: tags.image || tags.wikimedia_commons || null,++ imageUrl: null,++ imageCredit: null,imageLicense: null,description: tags.description ? String(tags.description).slice(0, 800) : null,source: 'openstreetmap',@@@ -72,8 +80,15 @@ function createOverpass({ config, fetchImpl, pace }) {headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body,});let json;-- try { json = JSON.parse(out.text); } catch { throw new Error('Overpass answered something that is not JSON'); }-- return { elements: (json && json.elements) || [], fetchedAt: out.fetchedAt };++ try { json = JSON.parse(out.text); }++ catch { throw new Error('Overpass answered something that is not JSON'); }++ const seen = new Set();++ return { elements: (json?.elements || []).filter((el) => {++ const key = `${el.type}:${el.id}`;++ if (seen.has(key)) return false;++ seen.add(key);++ return true;++ }), fetchedAt: out.fetchedAt };}return { tourism, placeFromElement, overpassQuery, INTERESTING };}@@@ -10,6 +10,14 @@* imageInfo(files) a Commons file's 1200px URL and its licence/credit (extmetadata)*/const { fetchJson, SourceError } = require('./http');++const { isPlace } = require('../places/is-place');++++// Commercial pages use only licences that permit reuse and derivatives. A Commons record without++// both an author and a source is no better than an unlicensed hotlink.++function reusableImage({ license, credit, url, sourceUrl }) {++ return Boolean(url && sourceUrl && credit && license && /^(?:CC0|CC BY(?:-SA)?(?:\s|$)|Public Domain|PDM)/i.test(license)++ && !/\b(?:NC|ND)\b/i.test(license));++}const chunk = (list, n) => { const out = []; for (let i = 0; i < list.length; i += n) out.push(list.slice(i, i + n)); return out; };@@@ -28,9 +36,11 @@ function parseEntity(entity) {const coord = firstClaim(entity, 'P625');const pop = firstClaim(entity, 'P1082');const image = firstClaim(entity, 'P18');++ const commonsCategory = firstClaim(entity, 'P373');const country = firstClaim(entity, 'P17');const label = (entity.labels && entity.labels.en && entity.labels.en.value) || null;const sitelink = entity.sitelinks && entity.sitelinks.enwikivoyage && entity.sitelinks.enwikivoyage.title;++ const wikipediaTitle = entity.sitelinks && entity.sitelinks.enwiki && entity.sitelinks.enwiki.title;return {qid: entity.id,label,@@@ -38,14 +48,61 @@ function parseEntity(entity) {lon: coord && Number.isFinite(Number(coord.longitude)) ? Number(coord.longitude) : null,population: pop && Number.isFinite(Number(String(pop.amount || '').replace(/^\+/, ''))) ? Number(String(pop.amount).replace(/^\+/, '')) : null,image: image ? String(image) : null,++ commonsCategory: typeof commonsCategory === 'string' ? `Category:${commonsCategory}` : null,countryQid: country && typeof country === 'object' && country.id ? country.id : null,wikivoyageTitle: sitelink || null,++ wikipediaTitle: wikipediaTitle || null,++ sitelinks: Object.keys(entity.sitelinks || {}).length,};}++function extractGuide(extract, title) {++ const text = String(extract || '').replace(/\r/g, '').trim();++ const lead = text.split(/\n\s*==/)[0].trim();++ const sentences = lead.match(/[^.!?]+[.!?]+(?:\s|$)/g)?.map((s) => s.trim()) || [];++ const intro = sentences[0] || '';++ if (!intro || intro.length < 20) return null;++ const why = sentences.slice(1, 4).join(' ').slice(0, 600) || null;++ const section = (name) => text.match(new RegExp(`== ${name} ==\\n([\\s\\S]*?)(?=\\n== |$)`, 'i'))?.[1] || '';++ const features = section('Features and programs').match(/Major features[^.]*\./i)?.[0] || null;++ const visit = text.match(/[^.\n]*(?:public tour|guided tour|free tour)[^.\n]*\./i)?.[0]?.trim() || null;++ // An unrelated plant's biology is not advice about when to visit this destination.++ const named = String(title || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');++ const seasonal = /\b(?:spring|summer|fall|autumn|winter|bloom(?:ing|s)?|foliage|season(?:al|s)?|January|February|March|April|May|June|July|August|September|October|November|December)\b/i;++ const season = text.split(/(?<=[.!?])\s+|\n+/).map((s) => s.trim())++ .find((s) => seasonal.test(s) && new RegExp(`\\b${named}\\b`, 'i').test(s)) || null;++ return { intro: intro.slice(0, 600), why, features: features?.slice(0, 400) || null,++ visit: visit?.slice(0, 400) || null, season: season?.slice(0, 400) || null,++ url: `https://en.wikipedia.org/wiki/${encodeURIComponent(String(title).replace(/ /g, '_'))}` };++}++function createWikidata({ config, fetchImpl, pace, log = console }) {const opts = { fetchImpl, pace, timeoutMs: config.upstreams.timeoutMs, userAgent: config.openData.userAgent, label: 'Wikidata' };++ async function notablePlaces(cityQid) {++ if (!/^Q[1-9][0-9]{0,11}$/.test(String(cityQid || ''))) return [];++ // WDQS times out joining P131+ to P279* on a large city. Its P31 English labels are bounded after the++ // administrative-chain match; two rows for one item are collapsed below before the top 60 are kept.++ const query = `SELECT DISTINCT ?item ?label ?coordinate ?links ?instanceLabel WHERE {++ ?item wdt:P131+ wd:${cityQid}; wdt:P625 ?coordinate; wdt:P31 ?kind; wikibase:sitelinks ?links; rdfs:label ?label.++ ?kind rdfs:label ?instanceLabel.++ FILTER(LANG(?label) = "en" && LANG(?instanceLabel) = "en" &&++ REGEX(?instanceLabel, "museum|park|garden|landmark|viewpoint|zoo|aquarium|observation tower|monument|tourist attraction|art gallery|historic site", "i"))++ } ORDER BY DESC(?links) LIMIT 120`;++ const url = `${config.upstreams.wikidataQuery}?${new URLSearchParams({ query, format: 'json' })}`;++ const { json, fetchedAt } = await fetchJson(url, { ...opts, label: 'Wikidata Query Service', retries: 0 });++ const seen = new Set();++ return (json?.results?.bindings || []).map((b) => {++ const qid = String(b.item?.value || '').match(/\/Q[1-9][0-9]{0,11}$/)?.[0]?.slice(1);++ const coord = String(b.coordinate?.value || '').match(/^Point\((-?[\d.]+) (-?[\d.]+)\)$/);++ const name = b.label?.value || '';++ if (!qid || seen.has(qid) || !coord || !isPlace(name, { instanceLabel: b.instanceLabel?.value })) return null;++ seen.add(qid);++ return { qid, name, lon: Number(coord[1]), lat: Number(coord[2]), sitelinks: Number(b.links?.value || b.sitelinks?.value || 0),++ sourceUrl: `https://www.wikidata.org/wiki/${qid}`, fetchedAt };++ }).filter(Boolean).slice(0, 60);++ }++/** Wikipedia titles → Q ids. Returns a Map keyed by the requested title (redirects resolved). */async function resolveQids(titles) {const wanted = [...new Set((titles || []).map((t) => String(t || '').trim()).filter(Boolean))];@@@ -70,6 +127,17 @@ function createWikidata({ config, fetchImpl, pace, log = console }) {return out;}++ async function article(title) {++ const name = String(title || '').trim();++ if (!name || name.length > 120) return null;++ const url = `${config.upstreams.wikipedia}/w/api.php?${new URLSearchParams({++ action: 'query', format: 'json', formatversion: '2', prop: 'extracts', explaintext: '1', titles: name,++ })}`;++ const { json } = await fetchJson(url, { ...opts, label: 'Wikipedia' });++ const page = json?.query?.pages?.[0];++ return page && !page.missing ? extractGuide(page.extract, page.title || name) : null;++ }++/** Q ids → parsed entities (all props the city job needs). */async function entities(qids) {const wanted = [...new Set((qids || []).filter((q) => /^Q[1-9][0-9]{0,11}$/.test(String(q))))];@@@ -77,7 +145,7 @@ function createWikidata({ config, fetchImpl, pace, log = console }) {for (const group of chunk(wanted, 40)) {const url = `${config.upstreams.wikidata}/w/api.php?${new URLSearchParams({action: 'wbgetentities', format: 'json', ids: group.join('|'), props: 'claims|labels|sitelinks',-- languages: 'en', sitefilter: 'enwikivoyage',++ languages: 'en',})}`;const { json, fetchedAt } = await fetchJson(url, { ...opts, label: 'Wikidata' });const entitiesObj = (json && json.entities) || {};@@@ -137,18 +205,31 @@ function createWikidata({ config, fetchImpl, pace, log = console }) {const ENT = { '&': '&', '"': '"', ''': "'", '<': '<', '>': '>', ' ': ' ' };const strip = (s) => (s ? s.replace(/<[^>]*>/g, ' ').replace(/&(amp|quot|#39|lt|gt|nbsp);/g, (e) => ENT[e]).replace(/\s+/g, ' ').replace(/\s+([.,;:!?])/g, '$1').replace(/[\s.]+$/, '').trim() || null : null);-- out.set(String(page.title), {++ const image = {url: info.thumburl || info.url || null,-- credit: strip(val('Artist')) || strip(val('Credit')) || 'Wikimedia Commons',++ credit: strip(val('Artist')) || strip(val('Credit')),license: strip(val('LicenseShortName')),licenseUrl: val('LicenseUrl'),-- });++ sourceUrl: `https://commons.wikimedia.org/wiki/${encodeURIComponent(String(page.title).replace(/ /g, '_'))}`,++ };++ if (reusableImage(image)) out.set(String(page.title), image);}}return out;}-- return { resolveQids, entities, countryInfo, imageInfo, parseEntity };++ async function categoryFiles(category) {++ const name = String(category || '').trim();++ if (!/^Category:[^|#?]{3,120}$/i.test(name)) return [];++ const url = `${config.upstreams.commons}/w/api.php?${new URLSearchParams({++ action: 'query', format: 'json', formatversion: '2', list: 'categorymembers',++ cmtitle: name, cmtype: 'file', cmlimit: '5',++ })}`;++ const { json } = await fetchJson(url, { ...opts, label: 'Commons category' });++ return ((json && json.query && json.query.categorymembers) || []).map((r) => r.title).filter((t) => /^File:/i.test(t));++ }++++ return { resolveQids, entities, countryInfo, imageInfo, categoryFiles, article, notablePlaces, parseEntity };}--module.exports = { createWikidata, parseEntity, claimValues, firstClaim, chunk, SourceError };++module.exports = { createWikidata, parseEntity, extractGuide, claimValues, firstClaim, chunk, SourceError, reusableImage };@@@ -170,7 +170,7 @@ function coordFrom(params) {}/** Every {{see}} (and old {{listing|type=see}}) on a page, with coordinates when the listing has them. */--function seeListings(wikitext) {++function seeListings(wikitext, types = ['see']) {const text = String(wikitext || '');const out = [];const titles = new Set();@@@ -180,8 +180,8 @@ function seeListings(wikitext) {if (end < 0) break;const body = text.slice(i + 2, end - 2);const head = (body.split('|')[0] || '').trim().toLowerCase().replace(/^subst:/, '');-- const isSee = head === 'see';-- const isListing = head === 'listing' && /(?:^|\|)\s*type\s*=\s*see\b/i.test(body);++ const isSee = types.includes(head);++ const isListing = head === 'listing' && types.some((type) => new RegExp(`(?:^|\\|)\\s*type\\s*=\\s*${type}\\b`, 'i').test(body));if (!isSee && !isListing) { i = end - 1; continue; }const p = paramsOf(body);const name = stripWiki(p.name || p.title || '', { max: 200 });@@@ -233,6 +233,7 @@ function parseCityPage(wikitext, title, { districts = 2 } = {}) {return {summary: leadSummary(wikitext),see: seeListings(wikitext),++ do: seeListings(wikitext, ['do']),districts: districtLinks(wikitext, title, districts),};}@@@ -28,6 +28,7 @@ function slugify(value) {return String(value == null ? '' : value).normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 90);}++const placePath = (p) => `/place/${slugify(p.name_en || p.name) || 'place'}-${p.id}`;const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));const day = (v) => (v == null ? null : (v instanceof Date ? v.toISOString().slice(0, 10) : String(v).slice(0, 10)));@@@ -45,7 +46,10 @@ function wireCity(row) {wikidata_id: row.wikidata_id, wikipedia_title: row.wikipedia_title || null, wikivoyage_title: row.wikivoyage_title || null,summary: row.summary || null, image_url: row.image_url || null, image_credit: row.image_credit || null,image_license: row.image_license || null, image_license_url: row.image_license_url || null,++ image_source_url: row.image_source_url || null,source: row.source, source_url: row.source_url, fetched_at: iso(row.fetched_at),++ osm_fetched_at: iso(row.osm_fetched_at),++ osm_attempted_at: iso(row.osm_attempted_at),created_at: iso(row.created_at), updated_at: iso(row.updated_at),...(row.place_count != null ? { place_count: Number(row.place_count) } : {}),...(row.route_count != null ? { route_count: Number(row.route_count) } : {}),@@@ -60,9 +64,12 @@ function wirePlace(row) {address: row.address || null, website: row.website || null, opening_hours: row.opening_hours || null,wheelchair: row.wheelchair || null, fee: row.fee || null, wikidata_id: row.wikidata_id || null,wikipedia: row.wikipedia || null, image_url: row.image_url || null, image_credit: row.image_credit || null,-- image_license: row.image_license || null, description: row.description || null,++ image_license: row.image_license || null, image_license_url: row.image_license_url || null,++ image_source_url: row.image_source_url || null, gallery: typeof row.gallery === 'string' ? JSON.parse(row.gallery) : (row.gallery || []), description: row.description || null,++ guide_why: row.guide_why || null, guide_features: row.guide_features || null, guide_visit: row.guide_visit || null,++ guide_season: row.guide_season && String(row.guide_season).toLowerCase().includes(String(row.name).toLowerCase()) ? row.guide_season : null,source: row.source, source_url: row.source_url, fetched_at: iso(row.fetched_at),-- osm_url: row.osm_type === 'wikivoyage' ? null : `https://www.openstreetmap.org/${row.osm_type}/${row.osm_id}`,++ osm_url: ['wikivoyage', 'wikidata'].includes(row.osm_type) ? null : `https://www.openstreetmap.org/${row.osm_type}/${row.osm_id}`,map_url: `https://www.openstreetmap.org/?mlat=${row.lat}&mlon=${row.lon}#map=16/${row.lat}/${row.lon}`,};}@@@ -119,6 +126,8 @@ function wireTrip(row) {function createStore(s, { config = {}, sharing = null } = {}) {const db = s.db;++ const indexNow = require('./index-now').createIndexNow({ baseUrl: config.baseUrl,++ key: config.isProduction && !config.drill ? config.indexNow?.key : '' });const q = (t) => t || db;/** The viewer as openvibe-sdk/share wants it. Tours' pages and API both name the person by subject. */@@@ -165,13 +174,18 @@ function createStore(s, { config = {}, sharing = null } = {}) {return wireCity(row);}++ async function cityById(id) {++ return wireCity(await db.maybe('SELECT * FROM tours_cities WHERE id=$1', [String(id || '')]));++ }++async function cityPlaces(cityId, { limit = 60 } = {}) {const rows = await db.many(`SELECT p.*, c.slug AS city_slug, c.name AS city_nameFROM tours_places p JOIN tours_cities c ON c.id = p.city_idWHERE p.city_id = $1-- The places a visitor can use first: a picture, a description, a name they can read; then by kind and name-- (by kind alone, Chongqing's list opened on an artwork with a Chinese-only name, 2026-11-02).-- ORDER BY (p.image_url IS NOT NULL) DESC, (p.description IS NOT NULL) DESC,++ ORDER BY (p.image_url IS NOT NULL AND p.image_credit IS NOT NULL AND p.image_license IS NOT NULL AND p.image_source_url IS NOT NULL) DESC,++ p.rank_score DESC, (p.description IS NOT NULL) DESC,(p.name_en IS NOT NULL OR p.name ~ '[A-Za-z]') DESC, p.kind, p.name LIMIT $2`, [String(cityId), Math.min(200, Math.max(1, int(limit, 60)))]);return rows.map(wirePlace);}@@@ -193,11 +207,20 @@ function createStore(s, { config = {}, sharing = null } = {}) {await db.exec('UPDATE tours_cities SET osm_fetched_at = now() WHERE id = $1', [String(cityId)]);}++ async function markCityOsmAttempted(cityId) {++ await db.exec('UPDATE tours_cities SET osm_attempted_at = now() WHERE id = $1', [String(cityId)]);++ }++async function cityLive(cityId) {const rows = await db.many(`SELECT * FROM tours_live WHERE city_id = $1 ORDER BY kind, fetched_at DESC LIMIT 12`, [String(cityId)]);return rows.map(wireLive);}++ async function placeLive(placeId) {++ const rows = await db.many('SELECT * FROM tours_live WHERE place_id = $1 ORDER BY kind, fetched_at DESC LIMIT 12', [String(placeId)]);++ return rows.map(wireLive);++ }++async function cityStays(cityId) {const rows = await db.many(`SELECT * FROM tours_stays WHERE city_id = $1 ORDER BY price LIMIT 12`, [String(cityId)]);return rows.map(wireStay);@@@ -210,6 +233,15 @@ function createStore(s, { config = {}, sharing = null } = {}) {return wirePlace(row);}++ async function updatePlaceGuide(id, guide) {++ await db.exec(`UPDATE tours_places SET description=$2, guide_features=$3, guide_visit=$4, guide_season=$5,++ source='wikipedia', source_url=$6, guide_why=$7, updated_at=now() WHERE id=$1`,++ [String(id), clean(guide.intro, 600), guide.features ? clean(guide.features, 400) : null,++ guide.visit ? clean(guide.visit, 400) : null, guide.season ? clean(guide.season, 400) : null, guide.url,++ guide.why ? clean(guide.why, 600) : null]);++ return placeById(id);++ }++async function routeById(id) {const row = await db.maybe(`SELECT r.*, c.slug AS city_slug, c.name AS city_nameFROM tours_routes r JOIN tours_cities c ON c.id = r.city_id WHERE r.id = $1`, [String(id || '')]);@@@ -298,7 +330,7 @@ function createStore(s, { config = {}, sharing = null } = {}) {return await db.many('SELECT id, updated_at FROM tours_routes ORDER BY id LIMIT $1', [limit]);}async function publicPlacesForSitemap(limit = 20000) {-- return await db.many('SELECT id, updated_at FROM tours_places ORDER BY id LIMIT $1', [limit]);++ return await db.many('SELECT id, name, name_en, updated_at FROM tours_places ORDER BY id LIMIT $1', [limit]);}async function publicCitiesForLlms(limit = 200) {return await db.many('SELECT slug, name, country, summary FROM tours_cities ORDER BY population DESC NULLS LAST LIMIT $1', [limit]);@@@ -310,20 +342,23 @@ function createStore(s, { config = {}, sharing = null } = {}) {const slug = c.slug || slugify(c.name);await db.exec(`INSERT INTO tours_cities(id, slug, name, country, country_code, region, lat, lon, population, wikidata_id, wikipedia_title, wikivoyage_title,-- summary, image_url, image_credit, image_license, image_license_url, source, source_url, fetched_at, updated_at)-- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,now())++ summary, image_url, image_credit, image_license, image_license_url, source, source_url, fetched_at, updated_at, image_source_url)++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,now(),$21)ON CONFLICT (id) DO UPDATE SETslug = EXCLUDED.slug, name = EXCLUDED.name, country = EXCLUDED.country, country_code = EXCLUDED.country_code,region = EXCLUDED.region, lat = EXCLUDED.lat, lon = EXCLUDED.lon, population = EXCLUDED.population,wikidata_id = EXCLUDED.wikidata_id, wikipedia_title = EXCLUDED.wikipedia_title, wikivoyage_title = EXCLUDED.wikivoyage_title,summary = COALESCE(EXCLUDED.summary, tours_cities.summary), image_url = EXCLUDED.image_url,image_credit = EXCLUDED.image_credit, image_license = EXCLUDED.image_license, image_license_url = EXCLUDED.image_license_url,-- source = EXCLUDED.source, source_url = EXCLUDED.source_url, fetched_at = EXCLUDED.fetched_at, updated_at = now()`,++ source = EXCLUDED.source, source_url = EXCLUDED.source_url, fetched_at = EXCLUDED.fetched_at,++ image_source_url = EXCLUDED.image_source_url, updated_at = now()`,[id, slug, clean(c.name, 120), clean(c.country, 120), c.countryCode || null, c.region ? clean(c.region, 120) : null,Number(c.lat), Number(c.lon), c.population == null ? null : Number(c.population), c.wikidataId,c.wikipediaTitle || null, c.wikivoyageTitle || null, c.summary ? clean(c.summary, 1200) : null,c.imageUrl || null, c.imageCredit ? clean(c.imageCredit, 250) : null, c.imageLicense ? clean(c.imageLicense, 120) : null,-- c.imageLicenseUrl || null, c.source || 'wikidata', c.sourceUrl, new Date(c.fetchedAt || s.now()).toISOString()]);++ c.imageLicenseUrl || null, c.source || 'wikidata', c.sourceUrl, new Date(c.fetchedAt || s.now()).toISOString(), c.imageSourceUrl || null]);++ indexNow.add(`/city/${slug}`);++ indexNow.add(`/city/${slug}/free`);return id;}@@@ -348,21 +383,25 @@ function createStore(s, { config = {}, sharing = null } = {}) {const id = stableId('plc', `${p.osmType}:${p.osmId}`);await db.exec(`INSERT INTO tours_places(id, city_id, osm_type, osm_id, name, kind, lat, lon, address, website, opening_hours, wheelchair, fee,-- wikidata_id, wikipedia, image_url, image_credit, image_license, description, source, source_url, fetched_at, name_en, updated_at)-- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,now())++ wikidata_id, wikipedia, image_url, image_credit, image_license, description, source, source_url, fetched_at, name_en, updated_at, image_license_url, image_source_url, gallery, rank_score, guide_why)++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,now(),$24,$25,$26::jsonb,$27,$28)ON CONFLICT (osm_type, osm_id) DO UPDATE SETcity_id = EXCLUDED.city_id, name = EXCLUDED.name, name_en = EXCLUDED.name_en, kind = EXCLUDED.kind, lat = EXCLUDED.lat, lon = EXCLUDED.lon,address = EXCLUDED.address, website = EXCLUDED.website, opening_hours = EXCLUDED.opening_hours,wheelchair = EXCLUDED.wheelchair, fee = EXCLUDED.fee, wikidata_id = EXCLUDED.wikidata_id, wikipedia = EXCLUDED.wikipedia,image_url = EXCLUDED.image_url, image_credit = EXCLUDED.image_credit, image_license = EXCLUDED.image_license,-- description = EXCLUDED.description, source = EXCLUDED.source, source_url = EXCLUDED.source_url,++ image_license_url = EXCLUDED.image_license_url, image_source_url = EXCLUDED.image_source_url, gallery = EXCLUDED.gallery, rank_score = EXCLUDED.rank_score,++ description = EXCLUDED.description, guide_why = COALESCE(EXCLUDED.guide_why, tours_places.guide_why), source = EXCLUDED.source, source_url = EXCLUDED.source_url,fetched_at = EXCLUDED.fetched_at, updated_at = now()`,[id, p.cityId, p.osmType, String(p.osmId), clean(p.name, 200), p.kind, Number(p.lat), Number(p.lon),p.address ? clean(p.address, 250) : null, p.website || null, p.openingHours ? clean(p.openingHours, 200) : null,p.wheelchair || null, p.fee || null, p.wikidataId || null, p.wikipedia || null, p.imageUrl || null,p.imageCredit ? clean(p.imageCredit, 250) : null, p.imageLicense ? clean(p.imageLicense, 120) : null,p.description ? clean(p.description, 800) : null, p.source || 'openstreetmap', p.sourceUrl,-- new Date(p.fetchedAt || s.now()).toISOString(), p.nameEn ? clean(p.nameEn, 200) : null]);++ new Date(p.fetchedAt || s.now()).toISOString(), p.nameEn ? clean(p.nameEn, 200) : null,++ p.imageLicenseUrl || null, p.imageSourceUrl || null, JSON.stringify(p.gallery || []), Math.max(0, int(p.rankScore)),++ p.guideWhy ? clean(p.guideWhy, 600) : null]);++ indexNow.add(placePath({ id, name: p.name, name_en: p.nameEn }));return id;}@@@ -399,18 +438,18 @@ function createStore(s, { config = {}, sharing = null } = {}) {const id = stableId('liv', `${item.kind}:${item.externalId}`);await db.exec(`INSERT INTO tours_live(id, kind, external_id, username, display_name, title, category, thumbnail_url, viewer_count, city_id, city_name,-- url, started_at, ended_at, fetched_at)-- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)++ url, started_at, ended_at, fetched_at, place_id)++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)ON CONFLICT (kind, external_id) DO UPDATE SETusername = EXCLUDED.username, display_name = EXCLUDED.display_name, title = EXCLUDED.title,category = EXCLUDED.category, thumbnail_url = EXCLUDED.thumbnail_url, viewer_count = EXCLUDED.viewer_count,-- city_id = EXCLUDED.city_id, city_name = EXCLUDED.city_name, url = EXCLUDED.url,++ city_id = EXCLUDED.city_id, city_name = EXCLUDED.city_name, place_id = EXCLUDED.place_id, url = EXCLUDED.url,started_at = EXCLUDED.started_at, ended_at = EXCLUDED.ended_at, fetched_at = EXCLUDED.fetched_at`,[id, item.kind, String(item.externalId), item.username, item.displayName ? clean(item.displayName, 80) : null,clean(item.title, 240), item.category ? clean(item.category, 40) : null, item.thumbnailUrl || null,Math.max(0, int(item.viewerCount)), item.cityId || null, item.cityName ? clean(item.cityName, 120) : null,item.url, item.startedAt ? new Date(item.startedAt).toISOString() : null,-- item.endedAt ? new Date(item.endedAt).toISOString() : null, new Date(item.fetchedAt || s.now()).toISOString()]);++ item.endedAt ? new Date(item.endedAt).toISOString() : null, new Date(item.fetchedAt || s.now()).toISOString(), item.placeId || null]);return id;}@@@ -647,8 +686,8 @@ function createStore(s, { config = {}, sharing = null } = {}) {}return {-- counts, listCities, cityBySlug, cityPlaces, cityRoutes, cityLive, cityStays, citiesForOsm, markCityOsmFetched,-- placeById, routeById, routes, routesForPlace, placeOfTheDay, liveNow, recentTours, stays,++ counts, listCities, cityBySlug, cityById, cityPlaces, cityRoutes, cityLive, placeLive, cityStays, citiesForOsm, markCityOsmFetched, markCityOsmAttempted,++ placeById, updatePlaceGuide, routeById, routes, routesForPlace, placeOfTheDay, liveNow, recentTours, stays,latestJobRuns, recordJobStart, recordJobFinish,publicCitiesForSitemap, publicRoutesForSitemap, publicPlacesForSitemap, publicCitiesForLlms,upsertCity, upsertCitySource, citySourcesMissing, citySource, upsertPlace, replaceRoutesForCity,@@@ -658,4 +697,4 @@ function createStore(s, { config = {}, sharing = null } = {}) {};}--module.exports = { createStore, stableId, slugify, wireCity, wirePlace, wireRoute, wireStop, wireLive, wireStay, wireTrip, SLUG_RE, SUBJECT_RE, VISIBILITIES, LIVE_KINDS };++module.exports = { createStore, stableId, slugify, placePath, wireCity, wirePlace, wireRoute, wireStop, wireLive, wireStay, wireTrip, SLUG_RE, SUBJECT_RE, VISIBILITIES, LIVE_KINDS };@@@ -11,6 +11,7 @@ const { esc, raw } = require('../render/html');const sc = require('openvibe-shared/showcase');const kit = require('openvibe-shared/kit');const { stateLabel } = require('./labels');++const { placePath } = require('../store');const SOURCE = {osm: '<a href="https://www.openstreetmap.org/copyright" rel="noopener">© OpenStreetMap contributors</a>, ODbL 1.0',@@@ -33,12 +34,14 @@ const safeImage = (v) => {try {const { protocol, hostname, pathname } = new URL(u);if (protocol !== 'https:' || !IMAGE_HOSTS.test(hostname)) return null;-- return /wikimedia\.org$/i.test(hostname) || /\.(jpe?g|png|webp|gif|avif|svg)$/i.test(pathname) ? u : null;++ return /wikimedia\.org$/i.test(hostname) || (hostname === 'openvibe.media' && /^\/api\/v2\/tours\/objects\//.test(pathname))++ || /\.(jpe?g|png|webp|gif|avif|svg)$/i.test(pathname) ? u : null;} catch { return null; }};--// Wikimedia answers a width through the Special:FilePath redirect (OSM/place pictures) or the--// "<n>px-" segment of a thumb URL (Commons' iiurlwidth, city pictures). A stored original can be--// turned into a thumb path. Any other picture is left as it is.++const licensedPhoto = (p) => p && p.image_credit && p.image_license && p.image_source_url && safeImage(p.image_url);++const credit = (p) => `<figcaption>Photo: ${esc(p.image_credit)} · ${esc(p.image_license)} · <a href="${esc(p.image_source_url)}" rel="noopener">source</a>${p.image_license_url ? ` · <a href="${esc(p.image_license_url)}" rel="noopener">licence</a>` : ''}</figcaption>`;++// Wikimedia's imageinfo thumbnail URL is already a verified derivative. Only Special:FilePath++// supports a requested width reliably; Media serves the cached production thumbnail directly.function imageWidth(u, w) {if (!u) return null;try {@@@ -48,16 +51,10 @@ function imageWidth(u, w) {return url.toString();}if (/(^|\.)wikimedia\.org$/i.test(url.hostname)) {-- const thumb = url.pathname.match(/^(.*\/thumb\/.*\/)(\d+)px-([^/]+)$/);-- if (thumb) {-- url.pathname = `${thumb[1]}${w}px-${thumb[3]}`;-- return url.toString();-- }-- const original = url.pathname.match(/^\/(wikipedia\/[^/]+)\/([0-9a-f])\/([0-9a-f]{2})\/([^/]+)$/i);-- if (original) {-- url.pathname = `/${original[1]}/thumb/${original[2]}/${original[3]}/${original[4]}/${w}px-${original[4]}`;-- return url.toString();-- }++ // Commons only guarantees the exact thumbnail URL returned by imageinfo. Asking for a new width++ // can answer 400 for a file whose derivatives have not been rendered yet.++ // Commons returns an original URL when the requested thumbnail would enlarge a small image.++ // Turning that original back into a 640px thumbnail makes Wikimedia answer 400.}} catch { /* not a URL: use it unchanged */ }return null;@@@ -70,10 +67,10 @@ function pictureImg(u, { alt = '', sizes = '(max-width: 720px) 80vw, 330px', widconst srcset = pairs.length ? ` srcset="${esc(pairs.map(([w, v]) => `${v} ${w}w`).join(', '))}" sizes="${esc(sizes)}"` : '';return `<img src="${esc(src)}"${srcset} width="${width}" height="${height}" alt="${esc(alt)}"${eager ? ' fetchpriority="high"' : ' loading="lazy"'} decoding="async">`;}--/** kit.card with a Wikimedia picture resized to the card's 16:9 shape; any other card is unchanged. */++/** Give safe card pictures a stable 16:9 box, using the verified Commons or Media URL. */function card(opts) {const html = kit.card(opts);-- if (!imageWidth(opts.image, IMAGE_WIDTHS[0])) return html;++ if (!safeImage(opts.image)) return html;return html.replace(/<img\b[^>]*>/, pictureImg(opts.image, { alt: opts.alt || '', sizes: '(max-width: 720px) 80vw, 330px' }));}const money = (n, c) => `${Number(n).toLocaleString('en-US', { maximumFractionDigits: 2 })} ${c}`;@@@ -105,7 +102,7 @@ const cityCard = (c) => card({href: `/city/${c.slug}`,title: c.name,type: 'map',-- image: safeImage(c.image_url) || '',++ image: licensedPhoto(c) || '',alt: c.image_credit ? `${c.name} (${c.image_credit})` : c.name,badge: c.country || '',who: c.country || '',@@@ -122,10 +119,10 @@ const localName = (p) => (p && p.name_en && p.name && p.name !== p.name_en ? p.nconst kindUnlessNamed = (p) => { const k = String(p.kind || '').replace(/_/g, ' '); return k && !placeName(p).toLowerCase().endsWith(k.toLowerCase()) ? k : ''; };const placeCard = (p) => card({-- href: `/place/${p.id}`,++ href: placePath(p),title: placeName(p),type: 'map',-- image: safeImage(p.image_url) || '',++ image: licensedPhoto(p) || '',badge: p.kind ? p.kind.replace(/_/g, ' ') : '',who: p.city_name || '',peek: p.description ? String(p.description).slice(0, 120) : '',@@@ -175,8 +172,8 @@ const clip = (s, n) => { const t = String(s || '').trim(); if (t.length <= n) refunction placeOfDayCard(p) {if (!p) return '';return `<article class="tr-pod" data-place="${esc(p.id)}">--<a class="tr-pod-link" href="/place/${esc(p.id)}">--${safeImage(p.image_url) ? pictureImg(p.image_url, { alt: placeName(p) }) : '<span class="tr-pod-noimg" aria-hidden="true">◆</span>'}++<a class="tr-pod-link" href="${esc(placePath(p))}">++${licensedPhoto(p) ? pictureImg(p.image_url, { alt: placeName(p) }) : '<span class="tr-pod-noimg" aria-hidden="true">◆</span>'}<span class="tr-pod-body"><span class="tr-kicker">Place of the day · ${esc(p.city_name || '')}</span><strong>${esc(placeName(p))}</strong><span class="tr-muted">${esc(p.kind ? p.kind.replace(/_/g, ' ') : 'place')}${p.description ? ` · ${esc(clip(p.description, 150))}` : ''}</span></span></a></article>`;@@@ -184,12 +181,12 @@ ${safeImage(p.image_url) ? pictureImg(p.image_url, { alt: placeName(p) }) : '<sp/** The home's questions: the accordion on the page and the FAQPage data (server/http/discovery.js) are this one list. */const HOME_FAQ = [-- ['Where does the data come from?', 'OpenStreetMap (Overpass, tourism=*) under ODbL, Wikivoyage under CC BY-SA, Wikidata under CC0, OpenVibe.Live\'s public stream list and OpenVibe.Rent\'s public listings. Every city, place, route and source row shows its source and fetch time.'],++ ['Where does the data come from?', 'Wikivoyage (CC BY-SA), Wikidata (CC0), Wikipedia (CC BY-SA) and OpenStreetMap (ODbL) for cities and places; photos from Wikimedia Commons and Openverse under CC BY, CC BY-SA, CC0 or public domain, each with its author and licence; OpenVibe.Live\'s public streams and OpenVibe.Rent\'s public listings. Every city, place, route and source row shows its source and fetch time.'],['Are the routes real guided tours?', 'No. They are self-guided walks generated from Wikivoyage\'s See lists and marked as generated. Check opening hours and access before you go; the note at each stop is the Wikivoyage text, cited.'],['Do you sell flights or stays?', 'No. There is no flight API key in v1: the flight form opens the official airline-neutral search with the city pair filled in, with no affiliate parameter. Stays link to the listing on OpenVibe.Rent; Tours never takes a payment.'],['Is there audio on the stops?', 'Not yet. OpenVibe.AI has no speech-synthesis route today, so every stop says audio is off instead of shipping a broken player. The audio fields are in the schema for when an API exists.'],['Who can see my trip?', 'Only you, until you share it. Everyone and Anyone with the link are readable by whoever has the address; Specific people is only the accounts you add, each Can view or Can edit; Only me is you. Your Share button changes one trip, and /trips sets what a new trip starts as. Delete it at any time; account deletion erases your trips.'],-- ['Why is a city missing?', 'The v1 catalogue is the top 100 cities by population; every city is fetched from Wikidata and Wikivoyage. A failed source run leaves the previous data and says so in the job receipt rather than inventing a place.'],++ ['Why is a city missing?', 'It is not missing: search it. Any city or town opens on demand from OpenStreetMap, Wikidata and Wikivoyage (the first visit takes a moment while its places are gathered). A failed source run leaves the previous data and says so in the job receipt rather than inventing a place.'],];/** The walk scene: the first real route's own first stops, each linked from the route page. */@@@ -234,8 +231,8 @@ function home({ counts = {}, live = [], recent = [], placeOfDay = null, cities =room: 'tours',hero: {title: 'Go anywhere: fly, stay, walk, watch',-- lede: 'Places, notes and pictures from open data, IRL tours live on OpenVibe.Live, stays from OpenVibe.Rent, and a map for every walk. Every fact names its source.',-- search: { action: '/cities', name: 'q', placeholder: 'A city: Lisbon, Tokyo, Mexico City…', hints: ['Lisbon', 'Tokyo', 'Mexico City', 'Barcelona'], label: 'Find a city' },++ lede: 'Places worth going in any city, with real photos and what to look for; plans and lists to go together; streams from the places themselves. Every fact names its source.',++ search: { action: '/cities', name: 'q', placeholder: 'Any city: Seattle, Lisbon, Tokyo…', hints: ['Seattle', 'Lisbon', 'Tokyo', 'Mexico City'], label: 'Find a city' },chips: [{ label: 'Live tours', href: '/live' }, { label: 'Routes', href: '/routes' }, { label: 'Stays', href: '/stays' }, { label: 'Flights', href: '/flights' }],proof,motif: heroMotif(),@@@ -264,8 +261,8 @@ function home({ counts = {}, live = [], recent = [], placeOfDay = null, cities =explore: {title: 'What a trip is made of',items: [-- { title: 'Cities', text: 'The top 100 first, from Wikidata coordinates and Wikivoyage pages.', href: '/cities', icon: 'map' },-- { title: 'Places', text: 'Tourism=* from OpenStreetMap, with the OSM link and fetch time.', href: '/cities', icon: 'pin' },++ { title: 'Cities', text: 'Any city, on demand: things to do and free things to do, from Wikivoyage, Wikidata and OpenStreetMap.', href: '/cities', icon: 'map' },++ { title: 'Places', text: 'Sights, parks, museums and viewpoints, each with a credited photo, why go, what to look for and how to visit.', href: '/cities', icon: 'pin' },{ title: 'Live tours', text: 'IRL streams now and recent VODs from OpenVibe.Live.', href: '/live', icon: 'live' },{ title: 'Stays', text: 'Rentals from OpenVibe.Rent; no payment is handled here.', href: '/stays', icon: 'home' },{ title: 'Flights', text: 'No key yet: a form opens the official airline-neutral search.', href: '/flights', icon: 'plane' },@@@ -292,55 +289,150 @@ function home({ counts = {}, live = [], recent = [], placeOfDay = null, cities =function citiesPage({ cities = [], q = '' } = {}) {return `<div class="tr-wrap" id="tr-cities-page">--<header class="tr-page-h"><p class="tr-kicker">Cities</p><h1>The top 100, from open data</h1>--<p class="tr-lede">Coordinates and population from Wikidata, the page and its See list from Wikivoyage, places from OpenStreetMap. A city page lists its places, routes, live tours and stays.</p>++<header class="tr-page-h"><p class="tr-kicker">Explore</p><h1>Things to do anywhere</h1>++<p class="tr-lede">Search for any city or place. Pages are built from OpenStreetMap, Wikivoyage and Wikidata when you need them.</p><form class="tr-form tr-form-inline" method="get" action="/cities" role="search"><label class="tr-field"><span>Find a city</span><input type="search" name="q" value="${esc(q)}" maxlength="80" placeholder="Lisbon"></label><button class="tr-btn" type="submit">Search</button></form></header>++<form class="tr-form tr-form-inline" method="get" action="/places" role="search"><label class="tr-field"><span>Find a place</span><input type="search" name="q" maxlength="120" placeholder="Kubota Garden"></label><button class="tr-btn" type="submit">Find place</button></form>${cities.length ? `<ol class="tr-city-list">${cities.map((c) => `<li class="tr-city-row"><a href="/city/${esc(c.slug)}">--<span class="tr-city-photo">${safeImage(c.image_url) ? pictureImg(c.image_url, { alt: '', sizes: '58px', width: 58, height: 44, widths: [160, 330] }) : '<span aria-hidden="true">◆</span>'}</span>++<span class="tr-city-photo">${licensedPhoto(c) ? pictureImg(c.image_url, { alt: '', sizes: '58px', width: 58, height: 44, widths: [160, 330] }) : '<span aria-hidden="true">◆</span>'}</span><span class="tr-city-text"><strong>${esc(c.name)}</strong><span class="tr-muted">${esc(c.country)}${c.population ? ` · ${Number(c.population).toLocaleString('en-US')} people` : ''}</span><span class="tr-muted">${Number(c.place_count) || 0} places · ${Number(c.route_count) || 0} routes</span></span></a></li>`).join('')}</ol>` : empty('No city matched that search.', '/cities', 'See all cities')}<p class="tr-fine">${SOURCE.wikidata} · ${SOURCE.wikivoyage} · ${SOURCE.osm}</p></div>`;}--function cityPage({ city, places = [], routes = [], live = [], stays = [] } = {}) {-- const img = safeImage(city.image_url);++const OSM_DAY = { Mo: 'Mon', Tu: 'Tue', We: 'Wed', Th: 'Thu', Fr: 'Fri', Sa: 'Sat', Su: 'Sun', PH: 'holidays' };++/** OpenStreetMap opening_hours in words for the common shapes ("Mo-Su 06:00-22:00" → "Every day 6:00–22:00"); any++ * other syntax is shown exactly as written, never guessed. */++function hoursInWords(value) {++ const raw = String(value || '').trim();++ if (!raw) return '';++ if (raw === '24/7') return 'Open 24 hours';++ const day = '(?:Mo|Tu|We|Th|Fr|Sa|Su|PH)';++ const span = `${day}(?:-${day})?`;++ const re = new RegExp(`^(${span}(?:,${span})*)\\s+(off|closed|\\d{1,2}:\\d{2}-\\d{1,2}:\\d{2}(?:,\\d{1,2}:\\d{2}-\\d{1,2}:\\d{2})*)$`);++ const parts = raw.split(/\s*;\s*/).filter(Boolean).map((part) => {++ const m = part.match(re);++ if (!m) return null;++ const days = m[1] === 'Mo-Su' ? 'Every day' : m[1].replace(/Mo|Tu|We|Th|Fr|Sa|Su|PH/g, (d) => OSM_DAY[d]).replace(/-/g, '–').replace(/,/g, ', ');++ const times = /^(off|closed)$/.test(m[2]) ? 'closed' : m[2].replace(/\b0(\d):/g, '$1:').replace(/-/g, '–').replace(/,/g, ', ');++ return `${days} ${times}`;++ });++ return parts.length && parts.every(Boolean) ? parts.join(' · ') : raw;++}++/** "Nov 8, 2026" from a stored ISO time. */++const updatedOn = (iso) => {++ const d = new Date(iso);++ return Number.isFinite(d.getTime()) ? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }) : '—';++};++const hostOf = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return 'website'; } };++const sourceName = (p) => (p.source === 'wikivoyage' ? 'Wikivoyage' : p.source === 'wikipedia' ? 'Wikipedia' : 'OpenStreetMap');++/** A short credit for a photo inside a card: author, licence, source link (every image keeps all three). */++const shortCredit = (p) => `<p class="tr-pcard-credit">Photo: ${esc(p.image_credit)} · ${esc(p.image_license)} · <a href="${esc(p.image_source_url)}" rel="noopener">source</a></p>`;++++/** One place in a city's grid: the photo (or its map pin when no licensed photo exists), name, kind and facts. */++function placeTile(p) {++ const photo = licensedPhoto(p);++ const facts = [localName(p), kindUnlessNamed(p), p.address, p.wheelchair === 'yes' ? 'step-free' : '', p.fee === 'no' ? 'free to visit' : ''].filter(Boolean).join(' · ');++ return `<li class="tr-pcard${photo ? '' : ' tr-pcard-text'}">++${photo ? `<div class="tr-pcard-media">${pictureImg(p.image_url, { alt: placeName(p), width: 640, height: 480, sizes: '(max-width: 560px) 46vw, (max-width: 900px) 30vw, 280px' })}</div>` : ''}++<div class="tr-pcard-body">${photo ? '' : '<svg class="tr-pcard-pin" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M12 22s7-6.2 7-12a7 7 0 1 0-14 0c0 5.8 7 12 7 12z" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="10" r="2.6" fill="currentColor"/></svg>'}<h3><a href="${esc(placePath(p))}">${esc(placeName(p))}</a></h3>${facts ? `<p class="tr-pcard-meta">${esc(facts)}</p>` : ''}${p.fee === 'no' ? '<span class="tr-pill">Free</span>' : ''}</div>++${photo ? shortCredit(p) : ''}++</li>`;++}++++function cityPage({ city, places = [], routes = [], live = [], stays = [], free = false } = {}) {++ const img = licensedPhoto(city);++ // Photographed places first (rank order kept within each group): rows of the grid stay even.++ places = [...places.filter((p) => licensedPhoto(p)), ...places.filter((p) => !licensedPhoto(p))];++ const mapHref = `https://www.openstreetmap.org/?mlat=${city.lat}&mlon=${city.lon}#map=12/${city.lat}/${city.lon}`;return `<div class="tr-wrap" id="tr-city-page">--<header class="tr-page-h tr-city-head">++<nav class="crumbs" aria-label="Breadcrumbs"><a href="/cities">Cities</a> / ${esc(city.name)}</nav>++<header class="tr-city-hero${img ? '' : ' tr-city-hero-plain'}">++${img ? `<figure class="tr-city-cover">${pictureImg(img, { alt: city.name, sizes: '(max-width: 720px) 100vw, 1180px', width: 1200, height: 560, widths: [640, 1200], eager: true })}<figcaption>Photo: ${esc(city.image_credit)} · ${esc(city.image_license)} · <a href="${esc(city.image_source_url)}" rel="noopener">source</a>${city.image_license_url ? ` · <a href="${esc(city.image_license_url)}" rel="noopener">licence</a>` : ''}</figcaption></figure>` : ''}++<div class="tr-city-intro"><p class="tr-kicker">${esc(city.country)}${city.region ? ` · ${esc(city.region)}` : ''}</p>--<h1>${esc(city.name)}</h1>++<h1>${free ? 'Free things to do' : 'Things to do'} in ${esc(city.name)}</h1>${city.summary ? `<p class="tr-lede">${esc(city.summary)}</p>` : note('No Wikivoyage summary was fetched for this city yet.', 'warn')}--<p class="tr-fine">${city.population ? `${Number(city.population).toLocaleString('en-US')} people · ` : ''}<a href="https://www.openstreetmap.org/?mlat=${city.lat}&mlon=${city.lon}#map=11/${city.lat}/${city.lon}" rel="noopener">Map</a> · <a href="${esc(city.source_url)}" rel="noopener">${esc(city.source === 'wikivoyage' ? 'Wikivoyage' : 'Wikidata')}</a> · fetched ${when(city.fetched_at)}</p>--${img ? `<figure class="tr-city-img"><img src="${esc(img)}" alt="${esc(city.name)}" loading="lazy" decoding="async"><figcaption>${esc(city.image_credit || 'Wikimedia Commons')}${city.image_license ? ` · ${esc(city.image_license)}` : ''}${city.image_license_url ? ` · <a href="${esc(city.image_license_url)}" rel="noopener">licence</a>` : ''}</figcaption></figure>` : ''}++<p class="tr-fine">${city.population ? `${Number(city.population).toLocaleString('en-US')} people · ` : ''}<a href="${esc(mapHref)}" rel="noopener">Map</a> · <a href="${esc(city.source_url)}" rel="noopener">${esc(city.source === 'wikivoyage' ? 'Wikivoyage' : 'Wikidata')}</a> · fetched ${esc(updatedOn(city.fetched_at))}</p>++</div></header>--${routes.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Self-guided routes</h2><span class="tr-muted">generated from Wikivoyage's See list</span></div><ol class="tr-route-list">${routes.map((r) => `<li><a href="/routes/${esc(r.id)}"><strong>${esc(r.title)}</strong><span class="tr-muted">${Number(r.stop_count)} stops · ${km(r.distance_m)} · ${Number(r.duration_min)} min</span></a></li>`).join('')}</ol></section>` : ''}--<section class="tr-sec" id="tr-city-places"><div class="tr-sec-head"><h2>Places</h2><a class="tr-more" href="https://www.openstreetmap.org/?mlat=${city.lat}&mlon=${city.lon}#map=13/${city.lat}/${city.lon}" rel="noopener">OpenStreetMap</a></div>--${places.length ? `<ol class="tr-place-list">${places.map((p) => `<li class="tr-place-row"><a href="/place/${esc(p.id)}"><strong>${esc(placeName(p))}</strong><span class="tr-muted">${esc([localName(p), kindUnlessNamed(p), p.address, p.wheelchair === 'yes' ? 'step-free' : '', p.fee === 'no' ? 'free to visit' : ''].filter(Boolean).join(' · '))}</span></a></li>`).join('')}</ol>` : empty('No OpenStreetMap place was stored for this city yet. The places job fills it.', '/cities', 'Another city')}++<nav class="tr-seg" aria-label="Which places">++<a href="/city/${esc(city.slug)}"${free ? '' : ' aria-current="page"'}>All places</a>++<a href="/city/${esc(city.slug)}/free"${free ? ' aria-current="page"' : ''}>Free things to do</a>++</nav>++<section class="tr-sec" id="tr-city-places"><div class="tr-sec-head"><h2>${free ? 'Verified free places' : 'Places worth going'}${places.length ? ` <span class="tr-count">${places.length}</span>` : ''}</h2><a class="tr-more" href="${esc(mapHref)}" rel="noopener">Open the map</a></div>++${free ? '<p class="tr-muted">Free means OpenStreetMap currently marks the place fee=no. Check the place’s own site before you go.</p>' : ''}++${places.length ? `<ol class="tr-pgrid">${places.slice(0, 24).map(placeTile).join('')}</ol>${places.length > 24 ? `<details class="tr-fold"><summary>More places (${places.length - 24})</summary><ol class="tr-pgrid">${places.slice(24).map(placeTile).join('')}</ol></details>` : ''}` : empty(free ? 'No place has a confirmed free admission tag yet.' : 'No sourced place was stored for this city yet.', '/cities', 'Another city')}<p class="tr-fine">${SOURCE.osm}</p></section>--${live.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Live tours and recordings</h2><a class="tr-more" href="/live">All live</a></div><ol class="tr-live-list">${live.map((l) => `<li><a href="${esc(l.url)}" rel="noopener"><strong>${esc(l.title)}</strong><span class="tr-muted">${esc(l.kind === 'stream' ? 'Live now' : l.kind === 'clip' ? 'Clip' : 'Recording')} · ${esc(l.display_name || l.username)}${l.city_name ? ` · ${esc(l.city_name)}` : ''}${l.kind === 'stream' ? ` · ${Number(l.viewer_count)} watching` : ''}</span></a></li>`).join('')}</ol><p class="tr-fine">OpenVibe.Live's public stream list · fetched ${when(live[0].fetched_at)}</p></section>` : ''}++${routes.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Self-guided routes</h2><span class="tr-muted">from Wikivoyage's See list</span></div><ol class="tr-route-list">${routes.map((r) => `<li><a href="/routes/${esc(r.id)}"><strong>${esc(r.title)}</strong><span class="tr-muted">${Number(r.stop_count)} stops · ${km(r.distance_m)} · ${Number(r.duration_min)} min</span></a></li>`).join('')}</ol></section>` : ''}++${live.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Live tours and recordings</h2><a class="tr-more" href="/live">All live</a></div><ol class="tr-live-list">${live.map((l) => `<li><a href="${esc(l.url)}" rel="noopener"><strong>${esc(l.title)}</strong><span class="tr-muted">${esc(l.kind === 'stream' ? 'Live now' : l.kind === 'clip' ? 'Clip' : 'Recording')} · ${esc(l.display_name || l.username)}${l.city_name ? ` · ${esc(l.city_name)}` : ''}${l.kind === 'stream' ? ` · ${Number(l.viewer_count)} watching` : ''}</span></a></li>`).join('')}</ol><p class="tr-fine">OpenVibe.Live's public stream list · fetched ${esc(updatedOn(live[0].fetched_at))}</p></section>` : ''}${stays.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Stays</h2><a class="tr-more" href="/stays">All stays</a></div><ol class="tr-stay-list">${stays.map((s0) => `<li><a href="${esc(s0.url)}" rel="noopener"><strong>${esc(s0.title)}</strong><span class="tr-muted">${esc(s0.kind)} · ${money(s0.price, s0.currency)} / ${esc(s0.period)}${s0.neighbourhood ? ` · ${esc(s0.neighbourhood)}` : ''}</span></a></li>`).join('')}</ol><p class="tr-fine">OpenVibe.Rent's public listings · never a payment on Tours.</p></section>` : ''}--<section class="tr-sec"><div class="tr-sec-head"><h2>Plan a trip here</h2></div><p class="tr-muted">Sign in, pick dates, save places from this city and add a route and a stay.</p><p><a class="tr-btn" href="/trips/new?city=${encodeURIComponent(city.slug)}">Start a trip in ${esc(city.name)}</a> <a class="tr-btn tr-btn-quiet" href="/flights?to=${encodeURIComponent(city.name)}">Find a flight</a></p></section>++<section class="tr-sec tr-band"><h2>Plan a trip here</h2><p class="tr-muted">Pick dates, save places from ${esc(city.name)} and add a route and a stay.</p><p class="tr-actions"><a class="tr-btn" href="/trips/new?city=${encodeURIComponent(city.slug)}">Start a trip in ${esc(city.name)}</a> <a class="tr-btn tr-btn-quiet" href="/flights?to=${encodeURIComponent(city.name)}">Find a flight</a></p></section></div>`;}--function placePage({ place, routeList = [], trips = [] } = {}) {++function placePage({ place, routeList = [], trips = [], nearby = [], live = [], summary = null } = {}) {++ const sentences = (place.description || '').split(/(?<=[.!?])\s+/).filter(Boolean);++ const seasonal = sentences.find((s) => /\b(spring|summer|autumn|fall|winter|bloom|blossom|season)\b/i.test(s));++ const guided = sentences.find((s) => /\b(guided tour|free tour)\b/i.test(s));++ const now = new Date();++ const springYear = now.getUTCMonth() < 2 || (now.getUTCMonth() === 2 && now.getUTCDate() < 21) ? now.getUTCFullYear() : now.getUTCFullYear() + 1;++ const springDate = `${springYear}-03-21`;++ const name = placeName(place);++ const lede = sentences[0] || `${name} in ${place.city_name}.`;++ // Why go never repeats the lede: the AI summary when there is one, else the description after its first sentence.++ const why = summary?.text || sentences.slice(1).join(' ');++ const lookFor = place.guide_features && place.guide_features !== place.description ? place.guide_features : '';++ const season = place.guide_season || seasonal || '';++ const visit = place.guide_visit || guided || '';++ const planHref = `https://openvibe.fun/plans/new?${new URLSearchParams({ place: name, city: place.city_name, url: `https://openvibe.tours${placePath(place)}`, lat: String(place.lat), lon: String(place.lon) })}`;++ const listHref = `https://openvibe.fun/places/new?${new URLSearchParams({ place: name, url: `https://openvibe.tours${placePath(place)}` })}`;++ const liveHref = `https://openvibe.live/broadcast?${new URLSearchParams({ place: name, place_url: `https://openvibe.tours${placePath(place)}`, title: `At ${name}` })}`;++ const photos = [];++ if (licensedPhoto(place)) photos.push({ url: place.image_url, credit: place.image_credit, license: place.image_license, sourceUrl: place.image_source_url, licenseUrl: place.image_license_url });++ for (const photo of place.gallery || []) {++ if (photos.length >= 6) break;++ if (photos.some((x) => x.url === photo.url)) continue;++ if (licensedPhoto({ image_url: photo.url, image_credit: photo.credit, image_license: photo.license, image_source_url: photo.sourceUrl })) photos.push(photo);++ }++ const figure = (photo, i) => `<figure class="${i ? 'tr-shot' : 'tr-hero-shot'}">${pictureImg(photo.url, i++ ? { alt: `${name}, photo ${i + 1} of ${photos.length}`, sizes: '(max-width: 720px) 70vw, 280px', width: 440, height: 300 }++ : { alt: name, sizes: '(max-width: 720px) 100vw, 760px', width: 1000, height: 620, widths: [640, 1000], eager: true })}<figcaption>Photo: ${esc(photo.credit)} · ${esc(photo.license)} · <a href="${esc(photo.sourceUrl)}" rel="noopener">source</a>${photo.licenseUrl ? ` · <a href="${esc(photo.licenseUrl)}" rel="noopener">licence</a>` : ''}</figcaption></figure>`;++ const hours = hoursInWords(place.opening_hours);++ const admission = place.fee === 'no' ? 'Free to visit' : place.fee === 'yes' ? 'Paid admission' : 'Admission not confirmed';++ const reminder = `<form method="get" action="/remind-place/${esc(place.id)}" class="tr-form tr-form-inline tr-remind"><label class="tr-field"><span>Remind us on a date</span><input type="date" name="date" required></label><button class="tr-btn tr-btn-quiet" type="submit">Set reminder</button></form>`;return `<div class="tr-wrap" id="tr-place-page"><nav class="crumbs" aria-label="Breadcrumbs"><a href="/cities">Cities</a> / <a href="/city/${esc(place.city_slug)}">${esc(place.city_name)}</a></nav>--<header class="tr-page-h"><p class="tr-kicker">${esc(String(place.kind || '').replace(/_/g, ' '))}</p><h1>${esc(placeName(place))}</h1>${localName(place) ? `\n<p class="tr-local-name">${esc(localName(place))}</p>` : ''}--<p class="tr-lede">${esc(place.description || `${placeName(place)} in ${place.city_name}, from OpenStreetMap.`)}</p>--<p class="tr-fine">${place.osm_url ? `<a href="${esc(place.osm_url)}" rel="noopener">OpenStreetMap ${esc(place.osm_type)} ${esc(place.osm_id)}</a> · ` : ''}<a href="${esc(place.source_url)}" rel="noopener">${esc(place.source === 'wikivoyage' ? 'Wikivoyage' : 'source')}</a> · fetched ${when(place.fetched_at)}${place.wikidata_id ? ` · <a href="https://www.wikidata.org/wiki/${esc(place.wikidata_id)}" rel="noopener">Wikidata</a>` : ''}</p></header>--${safeImage(place.image_url) ? `<figure class="tr-place-img"><img src="${esc(place.image_url)}" alt="${esc(placeName(place))}" loading="lazy" decoding="async"><figcaption>${esc(place.image_credit || 'OpenStreetMap contributor')}${place.image_license ? ` · ${esc(place.image_license)}` : ''}</figcaption></figure>` : ''}--<dl class="tr-facts">--${place.address ? `<div><dt>Address</dt><dd>${esc(place.address)}</dd></div>` : ''}--${place.opening_hours ? `<div><dt>Opening hours</dt><dd>${esc(place.opening_hours)}</dd></div>` : ''}--${place.wheelchair ? `<div><dt>Step-free</dt><dd>${esc(place.wheelchair)}</dd></div>` : ''}--${place.fee ? `<div><dt>Fee</dt><dd>${esc(place.fee)}</dd></div>` : ''}--<div><dt>Coordinates</dt><dd>${Number(place.lat).toFixed(5)}, ${Number(place.lon).toFixed(5)} · <a href="${esc(place.map_url)}" rel="noopener">map</a></dd></div>--${safeHttp(place.website) ? `<div><dt>Website</dt><dd><a href="${esc(place.website)}" rel="nofollow noopener">${esc(place.website)}</a></dd></div>` : ''}--</dl>++<div class="tr-place-top${photos.length ? '' : ' tr-place-top-solo'}">++<header class="tr-place-head">++<p class="tr-kicker">${esc(String(place.kind || 'place').replace(/_/g, ' '))}${place.fee === 'no' ? ' <span class="tr-pill">Free</span>' : ''}</p>++<h1>${esc(name)}</h1>${localName(place) ? `\n<p class="tr-local-name">${esc(localName(place))}</p>` : ''}++<p class="tr-lede">${esc(lede)}</p>++<div class="tr-together"><p class="tr-together-h">Go here together</p><p class="tr-actions"><a class="tr-btn" href="${esc(planHref)}">Make a plan together</a> <a class="tr-btn tr-btn-quiet" href="${esc(listHref)}">Add to Our places</a></p><p class="tr-fine">A date poll for your people, or a shared list of places you want to see.</p></div>++</header>++${photos.length ? `<div class="tr-place-media">${figure(photos[0], 0)}${photos.length > 1 ? `<div class="tr-rail-wrap"><p class="tr-rail-count">${photos.length - 1} more ${photos.length - 1 === 1 ? 'photo' : 'photos'}</p><ul class="tr-rail" aria-label="More photos of ${esc(name)}">${photos.slice(1).map((p, i) => `<li>${figure(p, i + 1)}</li>`).join('')}</ul></div>` : ''}</div>` : ''}++<ul class="tr-quick" aria-label="At a glance">++${place.fee === 'no' ? '<li><strong>Free to visit</strong><span>OpenStreetMap</span></li>' : ''}++${hours ? `<li><strong>${esc(hours)}</strong><span>opening hours</span></li>` : ''}++${place.wheelchair ? `<li><strong>${place.wheelchair === 'yes' ? 'Step-free' : place.wheelchair === 'limited' ? 'Partly step-free' : 'Not step-free'}</strong><span>access</span></li>` : ''}++<li><a href="${esc(place.map_url)}" rel="noopener"><strong>Directions</strong><span>${place.address ? esc(place.address) : 'open the map'}</span></a></li>++${safeHttp(place.website) ? `<li><a href="${esc(place.website)}" rel="nofollow noopener"><strong>Official site</strong><span>${esc(hostOf(place.website))}</span></a></li>` : ''}++${place.fee === 'no' ? '' : `<li><strong>${esc(admission)}</strong><span>${place.fee ? 'OpenStreetMap' : 'check before you go'}</span></li>`}++</ul>++</div>++<div class="tr-guide">++${why ? `<section class="tr-sec"><h2>Why go</h2><p>${esc(why)}</p><p class="tr-fine">${summary ? 'A summary by OpenVibe.AI of the cited sources · ' : ''}Source: <a href="${esc(place.source_url)}" rel="noopener">${esc(sourceName(place))}</a></p></section>` : ''}++${lookFor ? `<section class="tr-sec"><h2>Look for</h2><p>${esc(lookFor)}</p><p class="tr-fine">Source: <a href="${esc(place.source_url)}" rel="noopener">${esc(sourceName(place))}</a></p></section>` : ''}++<section class="tr-sec"><h2>How to visit</h2><p>${esc(admission)}${place.fee ? ' (OpenStreetMap)' : ''}. Check the hours and access before you travel.</p>${visit ? `<p>${esc(visit)} <a href="${esc(place.source_url)}" rel="noopener">Source</a></p>` : ''}${place.address ? `<p class="tr-muted">${esc(place.address)}</p>` : ''}</section>++${season ? `<section class="tr-sec tr-season"><h2>Best time</h2><p>${esc(season)}</p><p class="tr-fine"><a href="${esc(place.source_url)}" rel="noopener">Source</a></p><p class="tr-actions"><a class="tr-btn tr-btn-quiet" href="/remind-place/${esc(place.id)}?date=${springDate}">Remind us in spring</a></p>${reminder}</section>` : ''}++</div>++${season ? '' : `<section class="tr-sec"><h2>Come back later</h2><p class="tr-muted">Ask for a reminder on the day you want to go.</p>${reminder}</section>`}++<section class="tr-sec"><div class="tr-sec-head"><h2>See it live</h2><a class="tr-btn tr-btn-quiet" href="${esc(liveHref)}">Stream it from here</a></div>${live.length ? `<ol class="tr-live-list">${live.map((l) => `<li><a href="${esc(l.url)}" rel="noopener"><strong>${esc(l.title)}</strong><span class="tr-muted">${esc(l.kind === 'stream' ? 'Live now' : l.kind === 'clip' ? 'Clip' : 'Recording')} · ${esc(l.display_name || l.username)}</span></a></li>`).join('')}</ol>` : '<p class="tr-muted">No public streams or clips from here yet. Stream it and you are the first: OpenVibe.Live opens its go-live setup, and you choose when to start.</p>'}</section>++${nearby.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Nearby</h2></div><ol class="tr-pgrid tr-pgrid-small">${nearby.map(placeTile).join('')}</ol></section>` : ''}${routeList.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>On these routes</h2></div><ol class="tr-route-list">${routeList.map((r) => `<li><a href="/routes/${esc(r.id)}"><strong>${esc(r.title)}</strong><span class="tr-muted">${esc(r.city_name)} · ${Number(r.stop_count)} stops</span></a></li>`).join('')}</ol></section>` : ''}--${trips.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Save it to a trip</h2></div><form class="tr-form tr-form-inline" method="post" action="/trips/${esc(trips[0].slug)}/places"><input type="hidden" name="place_id" value="${esc(place.id)}"><label class="tr-field"><span>Trip</span><select name="trip">${trips.map((t) => `<option value="${esc(t.slug)}">${esc(t.title)}</option>`).join('')}</select></label><label class="tr-field"><span>Note (optional)</span><input name="note" maxlength="500"></label><button class="tr-btn" type="submit">Save place</button></form></section>` : `<section class="tr-sec"><p class="tr-muted"><a href="/auth/login?next=${encodeURIComponent(`/place/${place.id}`)}">Sign in</a> to save this place to a trip.</p></section>`}--<p class="tr-fine">${SOURCE.osm}</p>++${trips.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Save it to a trip</h2></div><form class="tr-form tr-form-inline" method="post" action="/trips/${esc(trips[0].slug)}/places"><input type="hidden" name="place_id" value="${esc(place.id)}"><label class="tr-field"><span>Trip</span><select name="trip">${trips.map((t) => `<option value="${esc(t.slug)}">${esc(t.title)}</option>`).join('')}</select></label><label class="tr-field"><span>Note (optional)</span><input name="note" maxlength="500"></label><button class="tr-btn" type="submit">Save place</button></form></section>` : `<section class="tr-sec"><p class="tr-muted"><a href="/auth/login?next=${encodeURIComponent(placePath(place))}">Sign in</a> to save this place to a trip.</p></section>`}++<footer class="tr-sources"><h2>Sources</h2><p>${place.osm_url ? `<a href="${esc(place.osm_url)}" rel="noopener">OpenStreetMap</a> · ` : ''}<a href="${esc(place.source_url)}" rel="noopener">${esc(sourceName(place))}</a>${place.wikidata_id ? ` · <a href="https://www.wikidata.org/wiki/${esc(place.wikidata_id)}" rel="noopener">Wikidata</a>` : ''} · fetched ${esc(updatedOn(place.fetched_at))}</p><p>${SOURCE.osm}</p></footer></div>`;}@@@ -478,7 +570,7 @@ ${canEdit ? `<section class="tr-sec tr-card"><h2>Share and edit</h2><p class="tr<label class="tr-field"><span>Notes</span><textarea name="notes" rows="3" maxlength="2000">${esc(trip.notes || '')}</textarea></label><button class="tr-btn" type="submit">Save the trip</button></form></details></section>` : ''}<section class="tr-sec"><div class="tr-sec-head"><h2>Saved places</h2>${canEdit ? '' : `<span class="tr-muted">${places.length} places</span>`}</div>--${places.length ? `<ol class="tr-trip-places">${places.map((p) => `<li><span class="tr-stop-num" aria-hidden="true">${Number(p.position)}</span><div><a href="/place/${esc(p.id)}"><strong>${esc(placeName(p))}</strong></a><span class="tr-muted">${localName(p) ? `${esc(localName(p))} · ` : ''}${esc(p.city_name)}${p.trip_note ? ` · ${esc(p.trip_note)}` : ''}</span>${canEdit ? `<form class="tr-inline-form" method="post" action="/trips/${esc(trip.slug)}/places/${esc(p.id)}/remove"><button class="tr-link-btn" type="submit">Remove</button></form>` : ''}</div></li>`).join('')}</ol>` : empty('No place saved yet. Open a city and a place, then save it here.', trip.city_slug ? `/city/${trip.city_slug}` : '/cities', 'Find places')}++${places.length ? `<ol class="tr-trip-places">${places.map((p) => `<li><span class="tr-stop-num" aria-hidden="true">${Number(p.position)}</span><div><a href="${esc(placePath(p))}"><strong>${esc(placeName(p))}</strong></a><span class="tr-muted">${localName(p) ? `${esc(localName(p))} · ` : ''}${esc(p.city_name)}${p.trip_note ? ` · ${esc(p.trip_note)}` : ''}</span>${canEdit ? `<form class="tr-inline-form" method="post" action="/trips/${esc(trip.slug)}/places/${esc(p.id)}/remove"><button class="tr-link-btn" type="submit">Remove</button></form>` : ''}</div></li>`).join('')}</ol>` : empty('No place saved yet. Open a city and a place, then save it here.', trip.city_slug ? `/city/${trip.city_slug}` : '/cities', 'Find places')}${canEdit ? `<form class="tr-form tr-form-inline" method="post" action="/trips/${esc(trip.slug)}/places"><label class="tr-field"><span>Place id</span><input name="place_id" required placeholder="plc_…" pattern="plc_[0-9A-HJKMNP-TV-Z]{26}"></label><label class="tr-field"><span>Note</span><input name="note" maxlength="500"></label><button class="tr-btn" type="submit">Save place</button></form><p class="tr-fine">The place id is on the place page. In the API: POST /api/v1/trips/${esc(trip.slug)}/places.</p>` : ''}</section><section class="tr-sec"><div class="tr-sec-head"><h2>Route</h2>${canEdit ? `<a class="tr-more" href="${trip.city_slug ? `/city/${trip.city_slug}` : '/routes'}">Find a route</a>` : ''}</div>nnew file mode 100644@@@ -0,0 +1,89 @@++'use strict';++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { reusableImage } = require('../server/sources/wikidata');++const { overpassQuery } = require('../server/sources/osm');++const { streamOf, clipOf, placeIdOf } = require('../server/sources/live');++const { rankPlace } = require('../server/jobs/places');++const { extractGuide, parseEntity } = require('../server/sources/wikidata');++++(async () => {++ await check('only credited, reusable Commons licences are accepted', async () => {++ const base = { url: 'https://thumb.wikimedia.org/a.jpg', sourceUrl: 'https://commons.wikimedia.org/wiki/File:A.jpg', credit: 'A photographer' };++ for (const license of ['CC BY 4.0', 'CC BY-SA 4.0', 'CC0', 'PDM']) assert.ok(reusableImage({ ...base, license }), license);++ for (const license of ['CC BY-NC 4.0', 'CC BY-ND 4.0', null]) assert.ok(!reusableImage({ ...base, license }), String(license));++ assert.ok(!reusableImage({ ...base, credit: null, license: 'CC BY 4.0' }));++ assert.match(overpassQuery(47.6, -122.3, 15000), /leisure.*park\|garden/);++ assert.ok(rankPlace({ imageUrl: 'photo' }, { sitelinks: 50 }, 10_000) > rankPlace({}, { sitelinks: 2 }, 100));++ const guide = extractGuide('Kubota Garden is a public Japanese garden in Seattle.\n== Features and programs ==\nMajor features include the Bamboo Grove and Necklace of Ponds. A public tour runs in summer.\n== Flora ==\nJapanese maples change colour in the fall.', 'Kubota Garden');++ assert.match(guide.features, /Bamboo Grove/);++ assert.match(guide.visit, /public tour/);++ assert.strictEqual(guide.season, null, 'a maple species description is not a best-time recommendation');++ assert.strictEqual(extractGuide('Kubota Garden is a public Japanese garden in Seattle. Visitors cross its bridge. Kubota Garden blooms in spring.', 'Kubota Garden').season,++ 'Kubota Garden blooms in spring.');++ assert.strictEqual(extractGuide('Kubota Garden is a public Japanese garden in Seattle. Visitors cross its bridge.', 'Kubota Garden').why,++ 'Visitors cross its bridge.');++ assert.strictEqual(parseEntity({ id: 'Q1', claims: { P373: [{ mainsnak: { datavalue: { value: 'Kubota Garden' } } }] } }).commonsCategory, 'Category:Kubota Garden');++ });++ await check('only canonical Tours place links attach Live streams and clips', async () => {++ const id = 'plc_0123456789ABCDEFGHJKMNPQRS';++ const place_url = `https://openvibe.tours/place/kubota-garden-${id}`;++ assert.strictEqual(placeIdOf({ place_url }), id);++ assert.strictEqual(placeIdOf({ place_url: 'https://elsewhere.example/place/kubota-garden-' + id }), null);++ const options = { fetchedAt: new Date().toISOString(), liveUrl: 'https://openvibe.live', videoBase: 'https://openvibe.video' };++ assert.strictEqual(streamOf({ id: 1, username: 'ada', place_url }, options).placeId, id);++ assert.strictEqual(clipOf({ id: 2, username: 'ada', place_url }, options).placeId, id);++ });++ const t = await boot();++ try {++ t.upstreams.state.nominatim.seattle = [{++ name: 'Seattle', type: 'city', addresstype: 'city', lat: '47.6062', lon: '-122.3321',++ address: { city: 'Seattle', state: 'Washington', country: 'United States', country_code: 'us' },++ extratags: { wikidata: 'Q5083' },++ }];++ t.upstreams.state.nominatim['kubota garden'] = [{++ name: 'Kubota Garden', type: 'garden', osm_type: 'way', osm_id: 42006992,++ lat: '47.51282', lon: '-122.26607', address: { city: 'Seattle' },++ extratags: { website: 'https://www.kubotagarden.org/', wikidata: 'Q6441303' },++ }];++ await check('a city outside the seed is built once and cached', async () => {++ const api = await t.get('/api/v1/cities?q=seattle');++ assert.strictEqual(api.status, 200);++ assert.strictEqual(api.json().count, 1);++ const first = await t.get('/city/seattle');++ assert.strictEqual(first.status, 200);++ assert.match(first.text, /Things to do in Seattle/);++ assert.match(first.text, /Kubota Garden/);++ assert.match(first.text, /Photo: A photographer/);++ const second = await t.get('/city/seattle');++ assert.strictEqual(second.status, 200);++ const calls = t.upstreams.state.requests.filter((r) => r.path === '/search');++ assert.strictEqual(calls.length, 1);++ assert.match(calls[0].headers['user-agent'], /OpenVibeTours/);++ });++ await check('free view includes only explicit fee=no and place actions hand off safely', async () => {++ const city = await t.ctx.store.cityBySlug('seattle');++ const free = await t.get('/city/seattle/free');++ assert.strictEqual(free.status, 200);++ assert.match(free.text, /Free things to do in Seattle/);++ assert.match(free.text, /The old tower/);++ assert.doesNotMatch(free.text, /Museum of the city/);++ const place = (await t.ctx.store.cityPlaces(city.id)).find((p) => p.name === 'Kubota Garden');++ const detail = await t.get(`/place/${place.id}`);++ assert.match(detail.text, /Go here together/);++ assert.match(detail.text, /openvibe\.fun\/plans\/new/);++ assert.match(detail.text, /openvibe\.live\/broadcast\?/);++ const placeSearch = await t.get('/api/v1/places?q=Kubota%20Garden');++ assert.strictEqual(placeSearch.status, 200);++ assert.strictEqual(placeSearch.json().place.name, 'Kubota Garden');++ const date = new Date(Date.now() + 30 * 86_400_000).toISOString().slice(0, 10);++ const reminder = await t.get(`/remind-place/${place.id}?date=${date}`);++ assert.strictEqual(reminder.status, 303);++ const target = new URL(reminder.headers.get('location'));++ assert.strictEqual(target.host, 'openvibe.today');++ assert.strictEqual(target.searchParams.get('from'), 'openvibe.tours');++ assert.strictEqual(target.searchParams.get('at'), `${date}T18:00:00Z`);++ });++ } finally { await t.close(); }++ done();++})().catch((err) => { process.stdout.write(String(err.stack || err)); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,79 @@++{++ "retrieved": "2026-11-08",++ "source": "https://en.wikivoyage.org/wiki/Seattle",++ "districts": [++ "Seattle/Downtown",++ "Seattle/Pioneer Square-International District",++ "Seattle/Queen Anne-South Lake Union",++ "Seattle/Capitol Hill-Central District",++ "Seattle/Ballard",++ "Seattle/Fremont",++ "Seattle/University District",++ "Seattle/North",++ "Seattle/Sodo-Georgetown",++ "Seattle/South",++ "Seattle/West"++ ],++ "pages": [++ {++ "title": "Seattle",++ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle",++ "wikitext": "{{see\n| name=Seattle CityPASS | alt= | url=https://www.citypass.com/seattle | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree=\n| hours= | price=$127 for adult, $97 for children (ages 5-12), plus tax and a $2 processing fee per ticket\n| lastedit=2024-07-07\n| content=A discount pass that includes admission to five attractions for half the normal fee combined. You are entitled to up to two visits (within 24 hours) to the '''Space Needle''' and a visit to the '''Seattle Aquarium'''. You also get three choices of an '''Argosy Cruises Harbor Tour''', admission to the '''Museum of Pop Culture''', the '''Woodland Park Zoo''', the '''Pacific Science Center''' the '''Museum of Flight''', or '''Chihuly Garden & Glass'''. A CityPASS is valid for 9 consecutive days starting with the use of your first ticket. Transportation and parking are not included.\n}}\n{{do\n| name=Kenmore Air | alt= | url=https://kenmoreair.com | email=\n| address=950 Westlake Ave N | lat= | long= | directions=\n| phone=+1-866-435-9524 | tollfree= | fax=\n| hours= | price=$99.50 per person\n| content=20 minute plane tours over Seattle that are narrated by the pilot, with spectacular views of the city. Tours begin and end at the west side of Lake Union. Reservations required.\n}}\n{{do\n| name=Argosy Cruises | alt= | url=https://www.argosycruises.com/ | email=\n| address=1101 Alaskan Way, Pier 55 | lat= | long= | directions=\n| phone= | tollfree= | fax=\n| hours=Cruise schedule varies by season. Ticket booth: Daily 10AM-4:05PM | price=Harbor cruise $30.50. Locks cruise $42.50, bus connection $5 additional. Discounts for kids 4-12 and seniors\n| lastedit=2018-03-15\n| content=Boat tour company with special dinner and sightseeing cruises. The most common tour is the hour-long '''Harbor Cruise''' on Elliott Bay, which offers excellent views of the Space Needle, Downtown skyline, freight harbor, and usually some sea lions, with a barrage of informative commentary about local history or interesting ships or cargo passing through that day. The '''Locks Cruise''' is a 2-hour one-way trip (in alternating directions) through the locks separating Elliot Bay and Lake Union, with an optional bus ride if you prefer a round trip. Seasonal cruises include the '''Summer Views Cruise''' with a sunset on Puget Sound, and '''Husky Football Cruises''' on Lake Washington before the games. Most cruises offer a decent full-service bar with large drinks and some snacks.\n}}\n{{do\n| name=Waterways Cruises | alt= | url=https://www.waterwayscruises.com/ | email=\n| address=901 Fairview Ave N Suite A100 | lat=47.62784 | long=-122.33318 | directions=\n| phone=+1 206-223 2060 | tollfree= | fax=\n| hours=Cruises on Sat-Sun and sometimes Fri; see schedule online. Office: Mon-Fri 9AM-5PM | price=Brunch $65, Sunday supper $60, dinner $85\n| lastedit=2018-02-02\n| content=Luxury dining cruises touring Lake Union and Lake Washington. Regular cruises include 2-hour weekend brunch and Sunday supper buffets, and a 2½-hour four-course dinner (business casual attire).\n}}\n{{do\n| name=Chinese Lunar New Year | alt= | url=https://www.seattlechinatownid.com/experiences/lunar-new-year | email=\n| address= | lat= | long= | directions=International District\n| phone= | tollfree= | fax=\n| hours=Jan/Feb | price=\n| content=Numerous stalls and performances, and don't forget to partake in all the cheap food!\n}}\n{{do\n| name=Seattle International Film Festival | alt=SIFF | url=https://www.siff.net/ | [email protected]\n| address= | lat= | long= | directions=Downtown, Seattle Center, Uptown\n| phone=+1 206 464-5830 | tollfree= | fax=\n| hours=May | price=\n| wikidata=Q2399132\n| content=One of the largest film festivals in North America, showing international cinema. Watch indie films at Seattle area screens and vote for your favorite; each category winner receives a Golden Space Needle trophy. SIFF also screens films year-round at its venues.\n}}\n{{do\n| name=Northwest Folklife Festival | alt= | url=https://nwfolklife.org/ | [email protected]\n| address=305 Harrison St | lat= | long= | directions=Seattle Center\n| phone=+1-206-684-7300 | tollfree= | fax=\n| hours=Memorial Day weekend | price=Free ($10 donation per person per day encouraged)\n|lastedit=2026-01-21| content=A more low-key and local version of Bumbershoot, mainly family-friendly.\n}}\n{{do\n| name=Fremont Fair | alt= | url=https://fremontfair.com/ | email=\n| address= | lat= | long= | directions=Fremont\n| phone= | tollfree= | fax=\n| hours=a weekend in mid or late June | price=\n| content=Home of the Solstice Parade (including the nude bike ride), and a really fun drunken time all over Fremont. Vendors, bad live music and eclectic crowds at the bars make for an interesting time. Friends who live in Fremont become especially valuable for a place to crash during the fair.\n}}\n{{do\n| name=Seattle PrideFest | alt= | url=https://www.seattlepridefest.org/ | email=\n| address= | lat= | long= | directions=Seattle Center\n| phone= | tollfree= | fax=\n| hours=A weekend in June or July | price=\n| content=One of the biggest gay pride festivals in the country. Food carts, beer gardens, adult theme performances, and the eagerly anticipated Pride Parade.\n}}\n{{do\n| name=Bite of Seattle | alt= | url=https://www.biteofseattle.com/ | email=\n| address= | lat= | long= | directions=Seattle Center\n| phone= | tollfree= | fax=\n| hours=mid/late July | price=\n| content=Part of Seafair festivities. Enjoy some of the best delicacies of Seattle.\n}}\n{{do\n| name=Capitol Hill Block Party | alt= | url=https://www.capitolhillblockparty.com/ | email=\n| address= | lat= | long= | directions=Capitol Hill\n| phone= | tollfree= | fax=\n| hours=a mid-summer weekend | price=\n| content=Yearly live music event held on Capitol Hill over a weekend in mid-summer (usually the end of July). Consists primarily of local independent bands of various styles, coupled with some bigger name independent label acts.\n}}\n{{do\n| name=Seafair | alt= | url=https://www.seafair.org/ | email=\n| address= | lat= | long= | directions=everywhere\n| phone= | tollfree= | fax=\n| hours=July-early August | price=\n| content=Seattle's biggest festival, signifying the arrival of summer. Neighborhood events such as parades and street fairs run throughout the festival, with the downtown Torchlight Parade in late July. Seafair culminates in early August when hydroplane races and the Blue Angels bring loud, fast boats and planes to Lake Washington.\n}}\n{{do\n| name=Bumbershoot | alt= | url=https://bumbershoot.com/ | email=\n| address= | lat= | long= | directions=Seattle Center\n| phone= | tollfree= | fax=\n| hours=Labor Day weekend (early September) | price=\n| content=Seattle's largest music and arts festival, featuring dozens of local and world-class musical acts.\n}}\n{{do\n| name=Night Market & Autumn Moon Festival | alt= | url=https://www.seattlechinatownid.com/experiences/c-id-night-market | email=\n| address= | lat= | long= | directions=International District\n| phone= | tollfree= | fax=\n| hours=Early September | price=\n| content=Chinese festival of food and outdoor market.\n}}\n{{do\n| name=Luminata | alt= | url=https://fremontartscouncil.org/luminata | email=\n| address= | lat= | long= | directions=Green Lake\n| phone= | tollfree= | fax=\n| hours=Evening of the autumnal equinox | price=\n| content=A festival of lights at Green Lake, including a procession from the old Aqua Theatre to a clearing about 3/4 mile north.\n}}\n{{do\n| name=Festál Cultural Center | alt= | url=https://seattlecenter.com/events/featured-events/festal | email=\n| address= | lat= | long= | directions=Seattle Center\n| phone= | tollfree= | fax=\n| hours=Year-round except December | price=\n| content=Celebrate the world with festivals from about 25 countries represented, one country nearly every 2 weeks.\n}}"++ },++ {++ "title": "Seattle/Downtown",++ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/Downtown",++ "wikitext": "{{see\n| name=Olympic Sculpture Park | alt= | url=https://www.seattleartmuseum.org/visit/olympic-sculpture-park | email=\n| address=2901 Western Ave | lat=47.61676 | long=-122.3553 | directions=\n| phone= | tollfree=\n| hours=Opens 30 minutes before sunrise, closes 30 minutes after sunset | price=Free\n| wikidata=Q7089122\n| lastedit=2025-03-14\n| content={{acre|9}} of green space on Seattle’s waterfront with a collection of monumental sculptures.\n}}\n{{see\n| name=Seattle Art Museum | alt=SAM | url=https://www.seattleartmuseum.org/ | [email protected]\n| address=100 University St | lat=47.6072 | long=-122.338 | directions=at 1st Ave\n| phone=+1 206 654-3100 | tollfree=\n| hours=Tue Wed Fri-Sun 10AM-5PM, Thu 10AM-9PM, closed Mon | price=Suggested admission: $19.50 adult, $17.50 senior/military, $2.50 students/youth, children 12 and under free. Admission is free on first Thursday of the month\n| wikipedia=Seattle Art Museum | image=SAM Art Ladder 02.jpg | wikidata=Q1816301\n| lastedit=2023-06-17\n| content=The museum displays a good assortment of art from the Pacific Northwest and around the world. Though the permanent exhibitions only occasionally delve deeply into a specific subject (such as the enormous variety of pieces in the porcelain room), extensive special exhibitions fill the gap. Tours available.\n}}\n{{see\n| name=Seattle Public Library-Central Library | alt=Seattle Central Library | url=https://www.spl.org/hours-and-locations/central-library | email=\n| address=1000 4th Ave | lat=47.6061 | long=-122.333 | directions=\n| phone=+1 206 386-4636 | tollfree= | fax=\n| hours=Mon-Thu 10AM-8PM, Fri Sat 10AM-6PM, Sun noon-6PM | price=Free\n| wikipedia=Seattle Central Library | image=Seattle Central Library, Seattle, Washington - 20060418.jpg | wikidata=Q2531939\n|lastedit=2023-06-17| content=A dramatic glass and steel structure in the heart of Downtown, designed by Rem Koolhaas. This is not an average public library and has become a tourist destination in its own right. A popular way to experience the unique architecture of the building is to take an elevator to the 10th floor, the highest observation deck in the building. From here you can walk down to the main floor through the Book Spiral; the core of the structure which organizes the library's books in one continuous path organized by the Dewey decimal system.\n}}\n{{see\n| name=Sky View Observatory | alt=Columbia Center | url=https://skyviewobservatory.com/ | email=\n| address=701 5th Ave | lat=47.604525 | long=-122.330539 | directions=Take an elevator to the 40th floor, then another elevator to the right to the 73rd floor.\n| phone=+1 206 386-5564 | tollfree= | fax=\n| hours=Daily 10AM-8PM (check website for extended summer hours and special hours for holidays) | price=$22 adults, $19 seniors and military with ID, $16.for kids 5 to 13\n| wikidata=Q908703\n|lastedit=2023-06-17| content=This observatory sits on the 73rd floor of the Columbia Center, one of the tallest buildings on the West Coast and the tallest public observation deck in the Pacific Northwest, sitting 932 feet (284.2 meters) above the ground and more than 330 feet taller than the Space Needle. Great views are offered from the top and some people claim the view is better from here than from the Space Needle. Another advantage over the Space Needle is that this building isn't well known as a tourist attraction, so there's usually little to no line to get to the top. Tickets are available for purchase online ($2 less expensive or in the atrium at the Columbia Center). There is a cafe in the observation deck serving sandwiches and drinks (including beer and wine) and a food court at the bottom of the Columbia Center.\n}}\n{{see\n| name=Tilikum Place | alt= | url=https://www.seattle.gov/parks/allparks/tilikum-place | email=\n| address=2701 5th Ave | lat=47.61834 | long=-122.34746 | directions=\n| phone=+1 206 684-4075 | tollfree=\n| hours=6AM-10PM | price=\n| wikipedia=Tilikum Place | wikidata=Q7802152\n|lastedit=2023-06-17| content=Etymologically meaning \"welcome\" in the native Chinook language, Tilikum is home to a statue of Chief Seattle, the chief of the Duwamish tribe that gives this city its name. Sit in the park or in the Tilikum Place Cafe's outdoor seating and enjoy views of the Space Needle while sipping a coffee or enjoying a lunch, and watch the monorail go by.\n}}\n{{see\n| name=Miner's Landing at Pier 57 | alt= | url=https://www.minerslanding.com/ | email=\n| address=1301 Alaskan Way | lat=47.60414 | long=-122.33961 | directions=on the waterfront below Pike Place Market\n| phone=+1 206 623-8600 | tollfree= | fax=+1 206 343-9173\n| hours= | price=\n| wikipedia=Pier 57 (Seattle) | wikidata=Q16983554\n|lastedit=2023-06-17| content=Named for once being the main departure point during the Seattle gold rush, Miner's Landing now hosts a food court and a variety of gourmet restaurants, a sports merchandise store, a carousel for kids and the Seattle Great Wheel.\n}}\n{{see\n| name=Seattle Great Wheel | alt= | url=https://seattlegreatwheel.com/ | email=\n| address=1301 Alaskan Way | lat=47.606139 | long=-122.342528 | directions=\n| phone=+1 206 623-8600 | tollfree= | fax=\n| hours=Hours vary by season; closes Fri Sat at midnight, 11PM (July–September) or 10PM (other months) all other days | price=$13, $8.50 ages 4-11\n| wikipedia=Seattle Great Wheel | image=Seattle Great Wheel.jpg | wikidata=Q7442108\n|lastedit=2023-06-17| content=Rising 175 feet in the air, this Ferris wheel offers a 15-minute ride in one of the wheel's 42 gondolas, each of which can hold up to 8 people. You'll get a wonderful view of the Seattle waterfront, the Downtown skyline, as well as the Puget Sound, the snow-covered Olympics, and the green city hills.\n}}\n{{see\n| name=Seattle Aquarium | alt= | url=https://www.seattleaquarium.org/ | email=\n| address=1483 Alaskan Way (on Pier 59 on the waterfront) | lat=47.6077 | long=-122.343 | directions=\n| phone=+1 206 386-4300 | tollfree= | fax=\n| hours=Exhibits open 9:30AM-6PM; last admission at 5PM | price=$21.95 adults, $14.95 children (4-12), children 3 and under free\n| wikipedia=Seattle Aquarium | image=Seattle - Pier 59 - 01.jpg | wikidata=Q4849544\n|lastedit=2023-06-17| content=Showcases native fish and mammals of the Pacific Northwest. Among the highlights are the Windows on Washington (WOW) exhibit, two touch tanks featuring animals of the inland sea and outer coast, and a display that holds two giant pacific octopuses, gill sharks and Pacific coral reefs. The outdoor exhibit upstairs features a collection of birds, harbor and Northern fur seals, as well as river and sea otters. At a separate area on the lower level, you can go inside an underwater dome to get up-close with swimming animals from the Puget Sound.\n}}\n{{do\n| name=Argosy Cruises | alt= | url=https://www.argosycruises.com/ | email=\n| address=1101 Alaskan Way, Pier 55 | lat=47.60476 | long=-122.33983 | directions=\n| phone= | tollfree= | fax=\n| hours=Cruise times vary by season | price=Prices vary according to your itinerary; $23.75 for harbor cruise\n|lastedit=2023-06-17| content=Offers special dinner and sightseeing cruises. The most common tour visitors take is the hour-long journey on Elliott Bay, which gives you an excellent view not just of the Space Needle and the Downtown skyline, but the freight harbor to the south as well.\n}}\n{{do\n| name=Seattle Convention Center | alt=formerly the Washington State Convention Center | url=https://seattleconventioncenter.com/ | email=\n| address=705 Pike St | lat= | long= | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q7972232\n| lastedit=2025-02-10\n| content=The largest events space in the state, and the host of PAX West, Emerald City Comic Con, Sakura-Con, the Northwest Flower and Garden Show, and many others. The {{sqft|414,722}} Arch building spans across Pike Street, 8th Avenue, and I-5. The {{sqft|573,770}} Summit building, opened in 2023, is a block north.\n}}\n{{do\n| name=Benaroya Hall | alt= | url=https://www.seattlesymphony.org/benaroyahall | email=\n| address=200 University St | lat=47.60810 | long=-122.33697 | directions=at 2nd Ave\n| phone=+1 206 215-4800 | tollfree= | fax=\n| hours=Free tours offered Tue and Fri at noon and 1PM | price=\n| wikipedia=Benaroya Hall | wikidata=Q3892069\n| content=An aesthetically and acoustically beautiful new concert hall. The official home of the Seattle Symphony Orchestra, it also houses other classical and performing arts roadshows.\n}}\n{{do\n| name=5th Avenue Theatre | alt= | url=https://www.5thavenue.org/ | [email protected]\n| address=1308 5th Ave | lat=47.60916 | long=-122.33398 | directions=between Union and University Streets on 5th Avenue\n| phone=+1 206-625-1900 | tollfree=\n| hours= | price=\n| wikidata=Q386819\n| content=The main Seattle venue for touring Broadway musical shows; also hosts a number of original musicals that later head to Broadway.\n}}\n{{do\n| name=Unexpected Productions | alt= | url=https://unexpectedproductions.org/ | [email protected]\n| address=1428 Post Alley | lat=47.60833 | long=-122.34026 | directions=Pike Place Market, look for the gum-covered wall in front\n| phone=+1 206-587-2414 | tollfree=\n| hours= | price=\n| wikidata=Q7884255\n| lastedit=2020-11-13\n| content=Seattle's longest running improv theater.\n}}\n{{do\n| name=Seattle International Film Festival (SIFF)| alt= | url=https://www.siff.net/ | [email protected]\n| address=[https://www.siff.net/cinema/cinema-venues SIFF Cinemas] and other venues | lat= | long= | directions=\n| phone=+1 206-464-5830 | tollfree= | fax=\n| hours=May | price=\n| wikidata=Q2399132\n| content=One of the largest film festivals in North America. Screenings are at the SIFF Cinema Uptown (3 screens), the SIFF Cinema Downtown, and two venues at Seattle Center, the SIFF Film Center and the PACCAR IMAX Theater.\n}}\n{{do\n| name=SIFF Cinema Downtown | url=https://www.siff.net/cinema/cinema-venues/siff-cinema-downtown | [email protected]\n| address=2100 4th Ave | lat=47.61394 | long=-122.34133 | directions=\n| phone=+1 206-464-5830 | tollfree= | fax=\n| hours= | price=[https://www.siff.net/cinema/box-office Variable pricing]\n| wikidata=Q7442077\n| content=One of just three Cinerama theaters in the world, this cinema shows art, indie, and foreign films and documentaries.\n}}"++ },++ {++ "title": "Seattle/Pioneer Square-International District",++ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/Pioneer_Square-International_District",++ "wikitext": "{{see\n| name=Grand Opera House | alt= | url= | email=\n| address=213–217 Cherry St | lat=47.6031 | long=-122.332 | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q5594850\n| lastedit=2020-08-29\n| content=Formerly the city's leading theater. Only its exterior survives as the shell of a parking garage.\n}}\n{{see\n| name=Klondike Gold Rush National Historic Park - Seattle Unit | alt= | url=https://www.nps.gov/klse/ | email=\n| address=319 2nd Ave S | lat=47.59938 | long=-122.33189 | directions=at S Jackson St\n| phone=+1 206- 220-4240 | tollfree=\n| hours=Daily 9AM-5PM except Thanksgiving, Christmas, and New Year's Day | price=Free\n| wikipedia=Klondike Gold Rush National Historical Park | image=Seattle - Cadillac Hotel 01.jpg | wikidata=Q1774856\n| content=A key attraction in the Pioneer Square area, this is the Seattle branch of the Klondike Gold Rush National Historical Park, the remainder of which is in [[Alaska]]. The site highlights the city's key role as the \"Gateway to the Gold Fields\" for most of the Klondike stampeders of 1897-1898. National Park Service Rangers and volunteers staff the museum and can provide information and perspective not only on the gold rush but also on Seattle's past. The museum contains many artifacts and historical photographs related to the Gold Rush, and movies about the gold rush and Seattle's history are shown upon request (except in summer, when they're shown on a regular schedule). Rangers also conduct free walking tours of the Pioneer Square area in the summer and demonstrate gold panning to school groups.\n}}\n{{see\n| name=Nihonmachi Alley | alt= | url=https://www.wingluke.org/exhibit-nihonmachi-alley | email=\n| address= | lat=47.59965 | long=-122.32571 | directions=from Jackson St to Main St, between 6th Ave and Maynard Ave\n| phone= | tollfree=\n| hours= | price=\n| wikidata=\n| lastedit=2026-05-29\n| content=Japantown (Nihonmachi) once stretched 20 blocks, but the residents were forced into internment camps during World War II, and Nihonmachi never fully recovered. This alley features murals and exhibits of four landmark businesses that persisted after incarceration.\n}}\n{{see\n| name=Seattle Buddhist Church | alt= | url=https://seattlebetsuin.com/ | email=\n| address=1427 S Main St | lat=47.5997 | long=-122.313 | directions=south of Yesler Way\n| phone=+1 206-329-0800 | tollfree=\n| hours= | price=\n| wikipedia=Seattle Buddhist Church | image=Seattle Buddhist Church 01.jpg | wikidata=Q7442060\n| content=In the summer the community hosts a Japanese ''bon odori'' festival on the street out front.\n}}\n{{see\n| name=Smith Tower | alt= | url=https://www.smithtower.com/ | [email protected]\n| address=506 2nd Ave | lat=47.601925 | long=-122.331731 | directions=\n| phone=+1 206 624 0414 | tollfree=\n| hours=Mon-Wed Noon-7PM, Thu and Sun Noon-8PM, Fri and Sat Noon-9PM | price=Self tour and observatory: adults $27, seniors/military $24, students/children 5-13 $21, under 5 Free, locals with Washington state ID $18. Guided 45 minute tour is additional\n| wikipedia=Smith Tower | image=SmithTower Seattle WA USA2.jpg | wikidata=Q1196348\n| lastedit=2026-04-25\n| content=Built in 1914, the Smith Tower was Seattle's first skyscraper, and the tallest building on the West Coast for nearly half a century before being overtaken by the Space Needle. Uniformed attendants operate the old copper and brass elevators from which you can peer into the different floors and offices as you ascend, an experience most people today have probably only witnessed in old movies. The outdoor '''Observatory''' on the 35th floor wraps completely around the four sides of the tower, providing panoramic views of the Olympic and Cascade mountain ranges, including Mt. Rainier, as well as of Elliott Bay, Downtown Seattle, and Pioneer Square. The interior of the 35th floor contains a speakeasy-themed bar, formerly known as the '''Chinese Room''' for its ornately carved ceiling and a number of decorative flourishes imported from China in the early 20th century. There is an excellent 45 minute guided tour with a very knowledgeable and entertaining tour guide (separate admission fee) about Seattle's history from the 1920s and beyond that is available. Also, there is an excellent independent coffee shop (Caffe Vita) in the Smith Tower (hours M–F: 7AM-5PM, Sa–Su: 8AM–3PM). Washington state residents can get admission for only $18 (with Washington state ID) and after 4pm Monday to Thursday admission is available to all for only $18 to the observation deck and bar only (museum not included for this price).\n}}\n{{see\n| name=Wing Luke Museum of the Asian Pacific American Experience | alt=The Wing | url=https://www.wingluke.org/ | email=\n| address=719 S King St | lat=47.59825 | long=-122.32294 | directions=\n| phone=+1 206 623-5124 | tollfree=\n| hours=Tue-Sun 10AM-5PM | price=$25/adult, $22/senior, $18/students, $15/child\n| wikipedia=Wing Luke Museum of the Asian Pacific American Experience | image=Seattle - East Kong Yick Building 02.jpg | wikidata=Q8025096\n| lastedit=2026-03-15\n| content=The first Smithsonian affiliate in the Pacific Northwest, this museum features exhibits and programs related to pan-Asian American art, history, and culture. Admission includes a first-come-first-served guided tour of the preserved rooms in its historic 1910 building: an import grocery store, rooms of a hotel and a Chinese family association (allow 45-60 minutes). Neighborhood walking tours are also offered on weekends. Bruce Lee fans will enjoy the exhibits of his personal items. They are open until 8PM on the first Thursday of the month.\n}}\n{{see\n| name=Hing Hay Park | alt= | url=https://www.seattle.gov/parks/allparks/hing-hay-park | email=\n| address=423 Maynard Ave S | lat=47.59859 | long=-122.3254 | directions=\n| phone=+1 206 684-4075 | tollfree=\n| hours= | price=6AM-10PM\n| wikipedia=Hing Hay Park (Seattle) | image=Hing Hay Park 01.jpg | wikidata=Q14713670\n| lastedit=2019-05-28\n| content=A small park in the center of Chinatown. Though you're not too likely to see old folks doing tai chi or playing checkers, it still has character.\n}}\n{{see\n| name=Occidental Park | alt= | url=https://www.seattle.gov/parks/allparks/occidental-square | email=\n| address=Occidental Ave between Main St and Washington St | lat=47.6 | long=-122.3329 | directions=\n| phone=+1 206 684-4075 | tollfree=\n| hours=6AM-10PM | price=Free\n| wikipedia=Occidental Park (Seattle) | image=Seattle - Occidental Park 02.jpg | wikidata=Q7075692\n| lastedit=2019-05-28\n| content=This urban park is best known for its four totem poles carved by local artist Duane Pasco. It also has bocce courts, ping pong and foosball tables.\n}}\n{{see\n| name=Pioneer Square | alt= | url=https://www.seattle.gov/parks/find/parks/pioneer-square | email=\n| address= | lat=47.60199 | long=-122.33388 | directions=Yesler Way and 1st Avenue\n| phone= | tollfree= | fax=\n| hours= | price=\n| image=Seattle - Pioneer Square Park pano 01.jpg\n| content=Pioneer Square proper is just a small corner park that's often occupied by homeless folks, but generally safe during the day.\n}}\n{{see\n| name=Waterfall Garden | alt= | url= | email=\n| address=2nd Ave at S Main St | lat=47.60021 | long=-122.33198 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikidata=Q28153990| wikipedia=Waterfall Garden Park\n| content=This small, tranquil park features a 22-foot high waterfall cascading over granite boulders. The park occupies the site where two 19-year olds, James Casey and Claude Ryan, began a messenger service in 1907 in the basement of a tavern, which eventually grew up to become United Parcel Service (UPS). The Annie E. Casey Foundation, started by James Casey and his siblings in honor of their mother, created the park and maintains it \"in honor of the men and women of United Parcel Service.\"\n}}\n{{do\n| name=Art Walk | alt= | url=https://pioneersquare.org/art-walks/ | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree=\n| hours= | price=\n| content=The Art Walk is almost synonymous with Pioneer Square and takes place on the first Thursday of every month. Galleries are open until 9PM. Show up early for the free wine and hors d'oeuvres.\n}}\n{{do\n| name=Seattle Pinball Museum | alt= | url=https://www.facebook.com/pages/Seattle-Pinball-Museum/131237786915560 | [email protected]\n| address=508 Maynard Ave S | lat=47.59800 | long=-122.32487 | directions=\n| phone=+1 206 623-0759 | tollfree= | fax=\n| hours=Sun-Mon noon-5PM, Thu-Sat noon-10PM | price=$13/adult, $10/child\n| content=A pinball machine museum where all the machines are on free play once you pay admission. There is soda and beer available.\n}}\n{{do\n| name=Theatre Off Jackson | alt= | url=https://theatreoffjackson.org/ | email=\n| address=409 7th Ave S | lat=47.59886 | long=-122.32396 | directions=\n| phone=+1 206-340-1049 | tollfree=\n| hours= | price=\n| lastedit=2017-01-28\n| content=A venue for cutting-edge theatrical productions.\n}}\n{{do\n| name=Bill Speidel's Underground Tour | alt= | url=http://www.undergroundtour.com/ | email=\n| address=614 1st Ave | lat=47.602 | long=-122.334 | directions=\n| phone=+1 206 682-4646 | tollfree= | fax=\n| hours=Apr-Sep: daily 9AM-7PM, Oct-Mar: daily 10AM-6PM. Tours on the hour; Jun-Aug: also on the half-hour daily 9:30AM-4:30AM | price=Adult $22, senior/student $20, youth (7-12) $10\n| image=Seattle Underground - Skylights from underneath current street level.JPG\n| content=The original Underground tour. Tours last 75 minutes, including a 15 minute introduction.\n}}\n{{do\n| name=Beneath the Streets | alt= | url=https://www.beneath-the-streets.com/ | email=\n| address=102 Cherry St | lat=47.60275 | long=-122.33416 | directions=\n| phone=+1 206 624-1237 | tollfree= | fax=\n| hours=10AM-4:30PM, tours every 30 minutes | price=$19\n| content=Groups are limited to 25 people for a more personal experience. Tours last 60 minutes. Spanish language tours available by appointment.\n}}\n{{do\n| name=Beneath the Streets' Red Light District Tour | alt= | url=https://www.beneath-the-streets.com/ | email=\n| address=102 Cherry St | lat=47.60275 | long=-122.33416 | directions=\n| phone=+1 206 624-1237 | tollfree= | fax=\n| hours=Thu-Mon 6:30PM | price=$22\n| content=An adults-only tour with a focus on the \"underside\" of underground history: prostitution, gambling, drinking and vice. 18 and older. Tours last 90 minutes.\n}}\n{{do\n| name=Chinese Lunar New Year | alt= | url=https://www.seattlechinatownid.com/experiences/lunar-new-year | email=\n| address= | lat= | long= | directions=King St, Hing Hay Park\n| phone= | tollfree= | fax=\n| hours=Jan/Feb | price=\n| content=Numerous stalls and performances, and don't forget to partake in all the cheap food!\n}}\n{{do\n| name=Night Market & Autumn Moon Festival | alt= | url=https://www.seattlechinatownid.com/experiences/c-id-night-market | email=\n| address= | lat= | long= | directions=Centered on Hing Hay Park\n| phone= | tollfree= | fax=\n| hours=Early September | price=\n| content=Chinese festival of food and outdoor market.\n}}"++ },++ {++ "title": "Seattle/Queen Anne-South Lake Union",++ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/Queen_Anne-South_Lake_Union",++ "wikitext": "{{see\n| name=Discovery Park | alt= | url=https://www.seattle.gov/parks/allparks/discovery-park | email=\n| address=3801 W Government Way | lat=47.660530 | long=-122.415720 | directions=\n| phone=+1 206-684-4075 | tollfree=\n| hours=4ː30AM-11PM | price=\n| wikipedia=Discovery Park (Seattle) | wikidata=Q3030228\n| lastedit=2026-01-09\n| content=The largest park in the city can be thought of as Seattle's unofficial central park given its huge, somewhat secluded nature, with a number of trails that meander through the green forests down to the lighthouse at the beach. Discovery Park used to be an army installation called Fort Lawton, and several of the fort's buildings remain. The park features the highest point in the city and wildlife you don't typically find in a large city. A visitors' center with information about the wildlife living in the park and the best trails to hike are in the center of the park. The visitor center is closed until summer 2027.\n}}\n{{see\n| name=Kerry Park | alt= | url=https://www.seattle.gov/parks/allparks/kerry-park | email=\n| address=211 W Highland Dr | lat=47.629600 | long=-122.359400 | directions=\n| phone=+1 206-684-4075 | tollfree=\n| hours=6AM-10PM | price=\n| wikipedia=Kerry Park (Seattle) | image=Seattle 4.jpg | wikidata=Q6394618\n| lastedit=2026-01-09\n| content=A photographer's paradise, this is perhaps the most obvious spectacle in Queen Anne. This park affords the quintessential view of Downtown Seattle with the Space Needle in the foreground, and (depending on the weather) Mount Rainier and the Cascades, as well as across the Puget Sound to Bainbridge Island.\n}}\n{{see\n| name=Bhy Kracke Park | alt= | url=https://www.seattle.gov/parks/parks/bhy-kracke-park | email=\n| address=1215 5th Ave N | lat= | long= | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q130286718\n| lastedit=2026-04-21\n| content=Pronounced \"By Crackie\". You can get a view comparable to that from the Space Needle for free.\n}}\n{{see\n| name=Fishermen's Terminal | alt= | url=https://www.portseattle.org/maritime/fishermen-terminal | email=\n| address= | lat=47.65578 | long=-122.38063 | directions=just southwest of the Ballard Bridge\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Fishermen's Terminal | wikidata=Q5454928\n| content=Carefully wander the docks (avoiding stepping on ropes or nets) and look at the boats. Many of them sell fresh crab or fish right off of the boat in season. Much of the Alaska crab fishing fleet moors here (or elsewhere in Lake Union) during the off-season, including vessels featured in the long-running ''Deadliest Catch'' television series. Visit the '''[https://www.seattlefishermensmemorial.org/ Fishermen's Memorial]''' dedicated to all of those who have lost their lives at sea while engaged in commercial fishing.\n}}\n{{see\n| name=Space Needle | alt= | url=https://www.spaceneedle.com/ | [email protected]\n| address=400 Broad St | lat=47.6204 | long=-122.3491 | directions=Seattle monorail is very close to the Needle and multiple buses have a stop nearby\n| phone=+1 206-905-2100 | tollfree=\n| hours=Fri Sat 8:30AM-11:30PM, Sun 8:30AM-10:30PM, Mon-Thu 9AM-10:30PM | price=standard admission is $55 (discounts of up to $20 per ticket if reservation made at lease 1 day in advance)\n| wikipedia=Space Needle | image=Space needle Seattle1.jpg | wikidata=Q5317\n| lastedit=2019-02-26\n| content=A symbol of Seattle, built for the 1962 World's Fair and still a wildly popular attraction. The view is spectacular on a clear day when the sun sets, when Downtown Seattle contrasts beautifully with the ocean to the west and the snow-capped mountains in every other direction. There are floor-to-ceiling windows on both levels, a rotating glass floor (\"The Loupe\") on the lower level that completes one revolution every 45 minutes, and open-air glass walls where you can lean backwards and take a selfie. The revolving Sky City restaurant has been closed due to renovations, but the wine bar is still open.\n}}\n{{see\n| name=Pacific Science Center | alt= | url=https://www.pacificsciencecenter.org/ | [email protected]\n| address=200 Sue Bird Ct N | lat=47.619200 | long=-122.351000 | directions=\n| phone=+1 206-443-2844 | tollfree= | fax=+1 206-443-3631\n| hours=Wed-Sun 10AM–5PM | price=Adults from̩ $22.95-̩$28.20 depending on day. Planetarium and daytime laser shows included, but requires timed tickets\n| wikipedia=Pacific Science Center | image=Pacific Science Center at night 04.jpg | wikidata=Q3848341\n| lastedit=2026-01-09\n| content=An interactive science museum featuring permanent and temporary exhibits, a butterfly atrium, IMAX theater, planetarium, and laser shows.\n}}\n{{see\n| name=Museum of Pop Culture | alt=MoPOP | url=https://www.mopop.org/ | [email protected]\n| address=325 5th Ave N | lat=47.6215 | long=-122.3486 | directions=\n| phone=+1 206-770-2700 | tollfree=\n| hours=Summer (Memorial Day-Labor Day) 10AM-7PM daily; rest of year 10AM-5PM daily | price=$28 adults, $25 seniors/students, $22 military, $19 youth, children 4 and under free. $2 discount if purchased online. Additional $8 for special exhibit. Outdoor playground free\n| wikipedia=Museum of Pop Culture | image=Aerial view of EMPSFM.jpg | wikidata=Q1384356\n| lastedit=2019-01-11\n| content=An excellent pop culture museum housed inside a visually striking building designed by Frank Gehry to \"evoke the rock 'n' roll experience\", with exhibits on all manner of popular culture: film, television, music, video games, fashion, etc. There's a particular focus on science fiction and Seattle musicians, with a sci-fi hall of fame and exhibits dedicated to Jimi Hendrix and Nirvana. Top-notch special exhibits have included the likes of David Bowie, ''Star Trek'', the Marvel comic and movie universe, and ''Minecraft''. There are many interactive exhibits, but don't expect to get a turn without a long wait. Outside, the large playground is a big hit with kids.\n}}\n{{see\n| name=Seattle Children's Museum | alt= | url=https://seattlechildrensmuseum.org/ | email=\n| address=200 2nd Ave N | lat=47.6214 | long=-122.351 | directions=\n| phone=+1 206-441-1768 | tollfree= | fax=\n| hours=Mon-Fri 10AM-5PM, Sat Sun 10AM-6PM | price=General exhibits: $7.50, $6.50 seniors, children under 1 free; no adults admitted without a child\n| wikipedia=Seattle Children's Museum | image=Children’s Museum (2891578446).jpg | wikidata=Q7442070\n|lastedit=2023-06-17| content=An independent non-profit museum which hosts popular traveling exhibits and features permanent exhibits and programs geared towards children.\n}}\n{{see\n| name=Chihuly Garden and Glass | alt= | url=https://www.chihulygardenandglass.com/ | [email protected]\n| address=305 Harrison St | lat=47.6206 | long=-122.35 | directions=\n| phone=+1 407 956-3527 | tollfree= | fax=\n| hours=Mon-Thu 10AM–10PM, Fri-Sun 10AM-11PM; last ticket sold one hour prior to closing; combined tickets with Space Needle available | price=$19 adults, $17 seniors, $12 children, children 3 and under free\n| wikipedia=Chihuly Garden and Glass | image=| wikidata=Q5097315\n|lastedit=2023-06-17| content=This museum provides a look at the inspiration and influences that inform the career of artist Dale Chihuly. The on-site exhibition hall contains eight galleries and three drawing walls, offering visitors a comprehensive look at Chihuly’s significant series of work. The centerpiece of the museum is a glasshouse with a suspended 1,400-piece, 100-foot-long sculpture. Also on the grounds is a lush garden, which serves as a backdrop for four monumental sculptures and other installations.\n}}\n{{see\n| name=Lake Union Park | alt= | url=https://www.seattle.gov/parks/find/parks/lake-union-park | email=\n| address=860 Terry Ave N | lat=47.626380 | long=-122.336640 | directions=on the Lake Union Waterfront\n| phone=+1 206-684-4075 | tollfree=\n| hours=4AM-11ː30PM | price=\n| wikipedia=Lake Union Park | image=Aerial South Lake Union Park May 2012.JPG | wikidata=Q6478207\n| lastedit=2026-01-09\n| content=Home to the Center for Wooden Boats and the Museum of History and Industry. Spray park open Memorial Day to Labor Day.\n}}\n{{see\n| name=The Center for Wooden Boats | alt= | url=https://www.cwb.org/ | email=\n| address=1010 Valley St | lat=47.62711 | long=-122.33587 | directions=\n| phone=+1 206-382-2628 | tollfree= | fax=\n| hours=Opens daily at 10AM except Mondays November through mid-April; closing hours vary by season. Free boat sails Sun 10AM (signup), 11AM-3PM (cruise) | price=Free\n| wikipedia=Center for Wooden Boats | image=Seattle — The Center for Wooden Boats, IMG04.jpg | wikidata=Q5059922\n|lastedit=2023-06-17| content=An intriguing museum where you can learn about maritime culture and experience sailing traditional wooden boats. The center's programs allow you to get a hands-on feel, putting in control of crafting and sailing your own wooden craft. On Sundays, rain or shine, you can get a free boat ride on a classic wooden boat around the lake for an hour. At times artist-in-residence Saaduuts leads programs.\n}}\n{{see\n| name=Museum of History and Industry | alt=MOHAI | url=https://mohai.org/ | email=\n| address=860 Terry Ave. N | lat=47.6276 | long=-122.337 | directions=\n| phone=+1 206-324-1126 | tollfree= | fax=\n| hours=Daily 10AM–5PM, Thursdays until 8PM | price=$25 adults, $20 seniors, children under 14 free; free first Thursday of the month\n| wikipedia=Museum of History & Industry (MOHAI) | image=MOHAI Armory building at Lake Union Park west side.jpg | wikidata=Q6940877\n|lastedit=2026-04-21| content=The museum focuses on the history of Seattle and the greater Puget Sound region, with exhibitions focusing on the maritime history of the city and the rise of the region's technology industry, as well as a set of changing exhibits.\n}}\n{{see\n| name=Northwest Seaport/Maritime Heritage Center | alt= | url=https://www.nwseaport.org/ | email=\n| address=1002 Valley St | lat=47.62808 | long=-122.33665 | directions=in Lake Union Park\n| phone=+1 206-447-9800 | tollfree= | fax=\n| hours= | price=\n| wikipedia=Northwest Seaport | wikidata=Q7060198\n|lastedit=2023-06-17| content=Home to a number of historic ships, including a schooner, a tugboat, a fireboat, a lightship, a steamer, and several others.\n}}\n{{see\n| name=South Lake Union Discovery Center | url=https://www.discoverslu.com/ | email=\n| address=101 Westlake Ave N | lat=47.61883 | long=-122.33879 | directions=\n| phone=+1 206-342-5900 | tollfree= | fax=\n| hours=11AM-6PM daily | price=\n|lastedit=2023-06-17| content=An introduction to the neighborhood, with displays explaining the history of South Lake Union. There is also a model of the whole neighborhood in the building.\n}}\n{{see\n| name=Amazon HQ tour | url=https://www.seattlespheres.com/visit-hq | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree= | fax=\n| hours=Wed 10AM, 2PM | price=\n| content=90-minute tour of Amazon's urban campus including Doppler, Day 1, and the Spheres. There is also a '''self-guided audio tour'''; you can even get a free pair of headphones to keep from the receptionists at Day 1 (2121 7th Ave; M-F 7AM-6PM).\n}}\n{{see\n| name=Seattle Spheres | url=https://www.seattlespheres.com/the-spheres-weekend-public-visits | email=\n| address=2117 7th Ave | lat= | long= | directions=\n| phone= | tollfree= | fax=\n| hours=1st and 3rd Sat of the month 10AM-6PM | price=Free; reservations required, available 30 days in advance\n| wikidata=Q48596158\n| content=A building made of three glass domes filled with lush plants. Take an unguided stroll through the tropical heart of the Spheres.\n}}\n{{see\n| name=Understory | alt= | url=https://www.seattlespheres.com/visit-understory | [email protected]\n| address=2101 7th Ave | lat= | long= | directions=\n| phone= | tollfree=\n| hours=Tue-Sat 10AM-6PM | price=Free\n| lastedit=2026-01-04\n| content=Visitor center about Amazon and the Spheres' flora and design. It's in the bottom floor of the Spheres and doesn't get you access to the upper floors where the plants live, but it's available 5 days a week unlike public access to the building.\n}}\n{{see\n| name=Public Art at Amazon Headquarters | url= | email=\n| address=426 Terry Ave N | lat=47.62262 | long=-122.33673 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| content=While the Amazon buildings are not open to the public, there are several signed outdoor art pieces around the complex and other nearby buildings, and you can hob-nob with \"blue badges\" at local restaurants and coffee shops. Seattle Architecture Foundation also leads guided walking tours.\n}}\n{{do\n| name=Northwest Folklife Festival | alt= | url=https://nwfolklife.org/ | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree= | fax=\n| hours=Memorial Day weekend (end of May) | price=free ($10 donation suggested)\n| wikipedia=Northwest Folklife | image=Seattle Center Pavilion during Folklife.jpg | wikidata=Q375649\n|lastedit=2023-06-17| content=A more low-key and global version of Bumbershoot.\n}}\n{{do\n| name=Bite of Seattle | alt= | url=https://biteofseattle.com/ | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree= | fax=\n| hours=Mid-late July | price=\n| wikipedia=Bite of Seattle | wikidata=Q4918822\n|lastedit=2023-06-17| content=Part of the Seafair festivities.\n}}\n{{do\n| name=Bumbershoot | url=https://bumbershoot.com/ | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree= | fax=\n| hours=Labor Day weekend (early Sep) | price=\n|lastedit=2023-06-17| content=A music and arts festival featuring dozens of local and world-class musical acts.\n}}\n{{do\n| name=Seattle Center WinterFest | alt= | url=https://www.seattlecenter.com/winterfest/ | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree=\n| hours=Late Nov to late Dec or early Jan | price=\n| lastedit=2025-01-21\n| content=Various activities/events during the holidays such as the WinterFest Ice rink (skate rental available for fee), Winter Train & Village, ice sculpting displays.\n}}\n{{do\n| name=Festál Cultural Center | alt= | url=https://seattlecenter.com/events/featured-events/festal | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree= | fax=\n| hours=Year-round except Dec | price=\n| content=Celebrate the world with festivals from about 25 countries represented, one country nearly every 2 weeks.\n}}\n{{do\n| name=Climate Pledge Arena | alt= | url=https://climatepledgearena.com/ | email=\n| address=334 1st Ave N | lat=47.622 | long=-122.354 | directions=\n| phone=+1 206-460-7825 (ticketing) | tollfree= | fax=\n| hours=\n| wikipedia=Climate Pledge Arena | image=Climate pledge arena.jpg | wikidata=Q977529\n| content=The region's largest sports arena is home to the NHL's Seattle Kraken, the WNBA's Seattle Storm, and college men's basketball games of the Seattle Redhawks (Seattle University), as well as a major concert venue. It was built for the 1962 World's Fair. The arena's interior was completely gutted and rebuilt for the Kraken, but its original exterior remains intact, maintaining its historic listing. Tours not available; if you want to see the inside, you have to attend an event.\n}}\n{{do\n| name=Seattle Chamber Music Society | alt= | url=https://www.seattlechambermusic.org/ | email=\n| address=10 Harrison St | lat=47.6222 | long=-122.3565 | directions=\n| phone=+1 206-283-8710 | tollfree= | fax=\n| hours= | price=\n| wikipedia=Seattle Chamber Music Society | wikidata=Q7442066\n| lastedit=2018-03-02\n| content=\n}}\n{{do\n| name=McCaw Hall | alt= | url=https://www.mccawhall.com/ | email=\n| address=321 Mercer St | lat=47.62397 | long=-122.35039 | directions=NE corner of Seattle Center\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q11705574\n| content=Home of the [https://www.seattleopera.org/ Seattle Opera] and the [http://www.pnb.org/ Pacific Northwest Ballet], each considered among the best in the United States.\n}}\n{{do\n| name=Seattle Repertory Theatre | alt= | url=https://www.seattlerep.org/ | email=\n| address=155 Mercer St | lat=47.62459 | long=-122.3533 | directions=\n| phone=+1 206-443-2222 (call), +1 206-565-2996 (text) | tollfree=\n| hours=Box officeː Mon-Fri noon-5PM, by phone noon-6PM | price=\n| wikidata=Q7442163\n| lastedit=2025-02-15\n| content=The largest nonprofit resident theatre in the Pacific Northwest.\n}}\n{{do\n| name=Seattle International Film Festival (SIFF)| alt= | url=https://www.siff.net/ | [email protected]\n| address=[https://www.siff.net/cinema/cinema-venues SIFF Cinemas] and other venues | lat= | long= | directions=\n| phone=+1 206-464-5830 | tollfree= | fax=\n| hours=May | price=\n| wikidata=Q2399132\n| content=One of the largest film festivals in North America. Screenings are at the [[w:SIFF Cinema Uptown]], [https://www.siff.net/cinema/cinema-venues/siff-film-center SIFF Film Center], and at venues in adjacent districts.\n}}\n{{do\n| name=SIFF Cinema Uptown | url=https://www.siff.net/cinema/cinema-venues/siff-cinema-uptown | [email protected]\n| address=511 Queen Anne Ave N | lat=47.623532 | long=-122.357005 | directions=\n| phone=+1 206-464-5830 | tollfree= | fax=\n| hours= | price=[https://www.siff.net/cinema/box-office Variable pricing]\n| wikidata=Q43306738\n| content=Three-screen theater showing art, indie, and foreign films and documentaries.\n}}\n{{do\n| name=Lake Union Crew | alt= | url=https://lakeunioncrew.com/ | email=\n| address=2520 Westlake Ave N | lat=47.64315 | long=-122.34324 | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=\n| lastedit=2025-01-19\n| content=A club boathouse for the local rowing community. If you have no rowing experience, classes are offered.\n}}\n{{do\n| name=Northwest Outdoor Center | alt= | url=https://www.nwoc.com/ | email=\n| address=2100 Westlake Ave N Ste 1 | lat=47.63861 | long=-122.33987 | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=\n| lastedit=2025-01-19\n| content=Kayak and paddleboard rentals.\n}}\n{{do\n| name=The Electric Boat Company | alt= | url=https://www.theelectricboatco.com/ | email=\n| address=2046 Westlake Ave #102 | lat=47.63803 | long=-122.34006 | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=\n| lastedit=2025-01-21\n| content=Electric boat rentals for up to 12 passengers. Offers enclosed and heated vessels for year-round appeal. No prior experience needed.\n}}"++ },++ {++ "title": "Seattle/Capitol Hill-Central District",++ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/Capitol_Hill-Central_District",++ "wikitext": "{{see\n| name=Northwest Nikkei Museum | alt=Japanese Cultural Community Center of Washington | url=https://www.jcccw.org/museum/ | email=\n| address=1414 S Weller St | lat=47.5977 | long=-122.3121 | directions=\n| phone=+1 206-568-7114 | tollfree=\n| hours=Mon-Fri 10AM-5PM | price=Free\n| wikipedia=Japanese Cultural and Community Center of Washington\n| lastedit=2021-07-18\n| content=Small exhibits relating to the history of ''nikkei'' (Japanese immigrants) in the Pacific Northwest.\n}}\n{{see\n| name=Seattle Asian Art Museum | alt=SAAM | url=https://www.seattleartmuseum.org/visit/seattle-asian-art-museum | email=\n| address=1400 E Prospect St | lat=47.6303 | long=-122.314 | directions=in Volunteer Park\n| phone=+1 206-654-3100 | tollfree=\n| hours=Fri-Sun 10AM-5PM | price=$15/adult, $13/seniors, $13/military, $10/student, children free, last Fridays free, all tickets +$3 if not bought in advance\n| wikipedia=Seattle Asian Art Museum | image=Seattle Asian Art Museum.jpg | wikidata=Q836066\n| lastedit=2023-06-25\n| content=An offshoot of downtown's Seattle Art Museum, SAAM displays a portion of the permanent collection balanced with rotating, consistently well-curated special exhibitions. The focus is usually on Chinese or Japanese art, reflecting long-established ties across the Pacific, but does include works from as far as India and the Middle East. The Art Deco building (the Seattle Art Museum's original home) is an attraction in its own right.\n}}\n{{see\n| name=Frye Art Museum | alt= | url=https://fryemuseum.org/ | [email protected]\n| address=704 Terry Ave | lat=47.6071 | long=-122.324 | directions=on First Hill\n| phone=+1 206-622-9250 | tollfree= | fax=+1 206 223-1707\n| hours=Tue-Sun 11AM-5PM, Thu 11AM-7PM | price=Free\n| wikipedia=Frye Art Museum | image=Frye Museum winter.jpg | wikidata=Q5506587\n|lastedit=2023-06-17| content=A small private collection of modern art with rotating contemporary exhibitions. Parking is always available and the place is worth a visit.\n}}\n{{see\n| name=Cal Anderson Park | alt= | url=https://www.seattle.gov/parks/allparks/cal-anderson-park | email=\n| address=1635 11th Ave | lat=47.61703 | long=-122.31916 | directions=near Broadway and E Pine St\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikidata=Q12054012\n| lastedit=2026-10-04\n| content=A very popular park on sunny days. It includes a signature fountain and pond, a basketball court, tennis and softball fields, and a playground. Great for peoplewatching, and you can often see groups of people doing activities varying from hackeysack to drum circles to freeze tag to bike polo to twirling around with colorful scarves.\n}}\n{{see\n| name=''Hot Rat Summer'' Mosaic | alt=Saint Rat | url= | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q135418161\n| lastedit=2026-10-04\n| content=A small tile-and-glass mosaic of a rat in a devotional pose, labeled with its namesake caption. It was illicitly installed on the side of a utility gatehouse in 2024. The city repeatedly tried to remove it, but community members swiftly striped away the paint covering it up amidst a swell of public appreciation for the art, so it was eventually allowed to stay.\n}}\n{{see\n| name=Lakeview Cemetery | alt= | url=https://www.lakeviewcemeteryassociation.com/ | email=\n| address=1554 15th Ave E | lat=47.63389 | long=-122.31528 | directions=at junction with Garfield\n| phone=+1 206-322-1582 | tollfree= | fax=\n| hours=Summer 9AM-8PM; spring 9AM-6PM; winter 9AM-4PM | price=Free\n| image=Seattle - Lakeview Cemetery 12.jpg | wikidata=Q872253\n|lastedit=2023-06-17| content=Incorporated in 1872, Lakeview Cemetery is set on a hillside with views of Lake Union, the Cascades, Lake Washington and the Olympic Mountains. The site holds the final resting places of Seattle's first families, many with diverse backgrounds. Many come to pay tribute at the graves of Bruce Lee and his son Brandon.\n}}\n{{see\n| name=Viretta Park | alt= | url=https://www.seattle.gov/parks/parks/viretta-park | email=\n| address=151 Lake Washington Blvd E | lat= | long= | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q7933704\n| lastedit=2026-10-05\n| content=The park's wooden benches are covered with graffiti messages to ''Nirvana'' frontman Kurt Cobain, as it is next door to the home where he died in 1994. This is the closest thing in Seattle to a memorial to the rock icon; serious fans should consider a road trip to his childhood hometown of [[Aberdeen_(Washington)|Aberdeen]].\n}}\n{{see\n| name=Volunteer Park | alt=at Prospect St | url=https://www.seattle.gov/parks/find/parks/volunteer-park | email=\n| address=1247 15th Ave E | lat=47.63040 | long=-122.31559 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikidata=Q7941040\n| content=Designed by John Charles Olmsted and Fredrick Law Olmsted Jr, this is the largest park in Capitol Hill. It is the site of a botanical conservatory and the Seattle Asian Art Museum (above).\n}}\n{{see\n| name=Volunteer Park Conservatory | alt= | url=https://www.volunteerparkconservatory.org/ | [email protected]\n| address=Highland Dr | lat= | long= | directions=north end of park\n| phone= | tollfree=\n| hours=Tue-Sun 10AM-4PM | price=Adult $6; Youth under 18, students, seniors 65+, military $4; children under 6 Free\n| wikidata=Q7941038\n| lastedit=2026-10-04\n| content=A Victorian-style greenhouse with thousands of living specimens on display. There are five sections: bromeliads, ferns, palms, seasonal, and cacti/succulents.\n}}\n{{see\n| name=Water Tower | url= | email=\n| address= | lat=47.62903 | long=-122.31451 | directions=at Prospect St park entrance\n| phone= | tollfree= | fax=\n| hours=10AM-sunset | price=Free\n| content=The 1906 tower at the highest point of Capitol Hill has an observation deck at the top, with views from the Cascades to the Olympics interspersed with a series of panels explaining the history of Seattle's Olmsted-designed park system. The views are somewhat obstructed by metal grates, but the clever photographer can work around them. No elevator - the only way up is the staircase wound around the water tank, seven stories high.\n}}\n{{see\n| name=Washington Park Arboretum | alt= | url=https://botanicgardens.uw.edu/washington-park-arboretum/ | email=\n| address=2300 Arboretum Dr E | lat=47.63920 | long=-122.29433 | directions=\n| phone= | tollfree= | fax=\n| hours=Daily, dawn-dusk | price=\n| wikidata=Q7972107\n|lastedit=2023-06-17| content=The Arboretum is a 230-acre park additionally serving as a botanical garden and horticultural research center, with thousands of trees and plants from temperate climates represented. An extensive network of walking trails covers the park. The visitor's center, near the northeast corner of the park, is open from 10AM to 4PM daily, and has limited parking available. Guided tours are offered on the first and third Sundays of every month, and free trail maps highlighting the major parts of the collection are available at any time.\n}}\n{{see\n| name=Seattle Japanese Garden | alt= | url=https://www.seattlejapanesegarden.org/ | email=\n| address=1075 Lake Washington Blvd E | lat=47.6292 | long=-122.296 | directions=southwest area of the Arboretum\n| phone=+1 206-684-4725 | tollfree= | fax=\n| hours=Hours vary seasonally: generally Tue-Sun 10AM until sunset. Extended hours from May to September, closed December to February; check the link above for more detailed information | price=$6 adults, $4 children over 5 years and seniors\n| wikidata=Q7442119\n|lastedit=2023-06-17| content=A small, formal Japanese garden within the grounds of the Arboretum.\n}}\n{{see\n| name=Streissguth Gardens | alt= | url=https://streissguthgardens.com/ | email=\n| address=E Blaine St between 10th Ave E and Broadway E | lat=47.6347 | long=-122.321 | directions=park at E Blaine St and 10th Ave E and go down public stairs; gardens on your left\n| phone= | tollfree= | fax=\n| hours= | price=\n| image=Flickr - brewbooks - Mary Ellen at Streissguth Gardens - Seattle (2).jpg | wikidata=Q7623187\n| content=A small, family-maintained garden on the northwest side of Seattle's Capitol Hill, on a steep hillside. Noisy but offering great views over Lake Union, Downtown Seattle, and the Olympic Mountains in the distance.\n}}\n{{see\n| name=Madison Park | alt= | url=https://www.seattle.gov/parks/find/parks/madison-park | email=\n| address=4000 E Madison St | lat=47.63513 | long=-122.27783 | directions=south of Evergreen Point Bridge\n| phone=+1 206-684-4075 | tollfree= | fax=\n| hours=4AM-11:30PM | price=\n| wikidata=Q6728065\n| content=Activities include swimming, bathhouse, restaurants, tennis court (with lights), and great views of the floating bridge and the Cascades. Lifeguards patrol the area during the summer, which creates a safer playground for kids, but it's still a beautiful park to hang out for teens and adults.\n}}\n{{see\n| name=Jimi Hendrix statue | alt=The Electric Lady Studio Guitar | url= | email=\n| address= | lat= | long= | directions=NE corner of E Pine St and Broadway\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q17151376\n| lastedit=2026-10-04\n| content=A life-size bronze sculpture of Hendrix rocks out on the sidewalk. Seattle was the birthplace and childhood home of the influential guitarist.\n}}\n{{see\n| name=Seattle University | alt= | url=https://www.seattleu.edu/ | email=\n| address=901 12th Ave | lat=47.61007 | long=-122.319 | directions=\n| phone=+1 206-296-6000 | tollfree= | fax=\n| hours= | price=\n| wikipedia=Seattle University | image=Seattle U Fountain 03.jpg | wikidata=Q615873\n|lastedit=2023-06-17| content=A private Jesuit university. Scenic campus makes a good walking detour.\n}}\n{{see\n| name=Chapel of St. Ignatius | alt= | url= | email=\n| address= | lat=47.61122 | long=-122.31797 | directions=Spring St and 12th Ave\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q114630227\n| lastedit=2025-04-17\n| content=A hidden gem, this small campus chapel was described by architect Stephen Holl as \"seven bottles of light in a stone box\". It is probably the most notable religious building built in Seattle in the last fifty years.\n}}\n{{do\n| name=Hothouse Spa & Sauna | alt= | url= | email=\n| address=1019 E Pike St | lat=47.61392 | long=-122.31839 | directions=\n| phone=+1 206-568-3240 | tollfree= | fax=\n| hours= | price=\n| content=A small urban women-only spa with an industrial/basement vibe. Great for a relaxing soak in the tub. Expect to be nude and quiet, and bring your own towels.\n}}\n{{do\n| name=Club Z | alt= | url= | email=\n| address=1117 Pike St | lat=47.61361 | long=-122.32806 | directions=\n| phone=+1 206-622-9958 | tollfree= | fax=\n| hours= | price=\n| content=On the other end of the spectrum, Club Z is a gay male bathhouse with no tubs and a lot of glory holes! Don't walk in if you expect to do any dancing or, um, bathing.\n}}\n{{do | name=Club Comedy Seattle | url=https://clubcomedyseattle.com/ | address=340 15th Ave E #201 | lat=47.62164 | long=-122.31248 |lastedit=2023-06-17| content=Boutique style stand-up comedy club.}}\n{{do\n| name=Raygun Lounge | alt= | url=https://www.raygunlounge.com/ | email=\n| address=501 E Pine St | lat=47.6151 | long=-122.3251 | directions=\n| phone=+1 206-812-2521 | tollfree= | fax=\n| hours=Mon-Fri 3PM-midnight, Sat-Sun noon-midnight | price=\n| content=All-ages gaming bar with board games and pinball machines. The atmosphere is more like a dive bar: the beer, cider and wine are good, but the food is mediocre, and the TVs are usually playing B-movies (but sometimes have some good campy sci-fi like ''Doctor Who'').\n}}\n{{do\n| name=I-5 Colonnade Mountain Bike Skills Park | alt=I-5 Colonnade | url=https://www.seattle.gov/parks/allparks/i-5-colonnade | email=\n| address=Lakeview Blvd E and Harvard Ave E | lat=47.635931 | long=-122.322535 | directions=\n| phone= | tollfree= | fax=\n| hours=4AM-11:30PM | price=\n| wikidata=Q5967559| image=I-5 Colonnade, 2014-10-13, no. 8.jpg\n| lastedit=2017-02-23\n| content=The bike park was built underneath a section of 12 elevated lanes of Interstate 5. It consists of several mountain bike skills trails, including novice, intermediate, and advanced sections, a dirt jumping section, pump track, and trials area. Skills classes, organized by the Evergreen Mountain Bike Alliance, are occasionally held at the park.\n}}\n{{do\n| name=Central Cinema | alt= | url=https://central-cinema.com/ | email=\n| address=1411 21st Ave | lat=47.61319 | long=-122.30530 | directions=\n| phone=+1 206-686-MOVI (listings), +1 206-328-3230 (office) | tollfree= | fax=\n| hours= | price=$8\n| wikidata=Q43306569\n| lastedit=2017-01-26\n| content=A unique, smaller movie theater that shows older films as well as plenty of late night movie screenings of hilariously bad films. All seats have table in front of them so you can have excellent food and beer during the show. A couple times a month there are $1.99 movie nights for popular older or cult films.\n}}\n{{do\n| name=Northwest Film Forum | url=https://nwfilmforum.org/ | email=\n| address=1515 12th Ave | lat=47.61441 | long=-122.31708 | directions=\n| phone=+1 206-829-7863 | tollfree= | fax=\n| hours= | price=\n| wikidata=Q43305852\n|lastedit=2023-06-17| content=A Seattle-based non-profit organization for film artists, showcasing American and international cinema as well as quarterly world premiere live performances.\n}}\n{{do\n| name=SIFF Cinema Egyptian | url=https://www.siff.net/year-round-cinema/cinema-venues/siff-cinema-egyptian | [email protected]\n| address=805 E Pine St | lat=47.6151 | long=-122.3217 | directions=\n| phone=+1 206-464-5830 | tollfree= | fax=\n| hours= | price=[https://www.siff.net/cinema/box-office Variable pricing]\n| wikidata=Q131038502\n| content=One-screen theater showing art, indie, and foreign films and documentaries.\n}}\n{{do\n| name=Seattle International Film Festival (SIFF)| alt= | url=https://www.siff.net/ | [email protected]\n| address= | lat= | long= | directions=\n| phone=+1 206-464-5830 | tollfree= | fax=\n| hours=May | price=\n| wikidata=Q2399132\n| content=One of the largest film festivals in North America. Screenings are at the SIFF Cinema Egyptian and other SIFF venues in adjacent districts.\n}}\n{{do\n| name=Capitol Hill Block Party | alt= | url=https://www.capitolhillblockparty.com/ | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree= | fax=\n| hours=Late July | price=\n| wikipedia=Capitol Hill Block Party | wikidata=Q5035886\n|lastedit=2023-06-17| content=This music festival during the height of summer attracts both local and national musicians.\n}}"++ },++ {++ "title": "Seattle/Ballard",++ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/Ballard",++ "wikitext": "{{see\n| name=Hiram M. Chittenden Locks | alt=Ballard Locks | url=https://ballardlocks.org/ | email=\n| address=3015 NW 54th St | lat=47.66546 | long=-122.3971 | directions=at junction with 32nd Ave, served by bus route 44\n| phone=+1 206 783-7059 | tollfree= | fax=\n| hours=7AM-9PM | price=Free\n| wikipedia=Ballard Locks | image=Lake Washington ship canal, Hiram M. Chittenden Locks, 1995.jpg | wikidata=Q5769218\n|lastedit=2023-06-17| content=Linking the freshwater Lake Union and the salt-water Puget Sound across a height difference of 22 feet, the locks allow boats to cross the Lake Washington Ship Canal, relying solely on the force of gravity. A salmon ladder with a viewing gallery allows visitors to witness the salmon migration, which with different species allow for viewing throughout most of the year, but preferably mid to late summer. Adjacent is the Carl S English, Jr Botanical Garden. Guided tours weekdays 1PM and 3PM; weekends 11AM.\n}}\n{{see\n| name=National Nordic Museum | alt= | url=https://nordicmuseum.org/ | email=\n| address=2655 NW Market St | lat=47.6685 | long=-122.39235 | directions=\n| phone=+1 206 789-5707 | tollfree=\n| hours=Tue–Sun 10AM–5PM | price=$20 adults, $16 seniors, $15 college students, $10 children above 5 yrs, under 5 free\n| wikipedia=National Nordic Museum | image=Nordic Heritage Museum Entrance.jpg | wikidata=Q7050698\n| lastedit=2024-07-07\n| content=Documents the history of Scandinavian immigration to Seattle, with rotating art exhibits and a permanent collection of historical artifacts. Julefest includes discounted admission and a Christmas market.\n}}\n{{see\n| name=Edith Macefield \"Up\" House | alt= | url= | email=\n| address=1438 NW 46th St | lat=47.662316 | long=-122.375358 | directions=in the Ballard Blocks\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q5338613\n| lastedit=2025-02-01\n| content=Edith Macefield received worldwide attention in 2006 when she turned down an offer of $1 million to sell her house to make way for a five-story commercial development. Instead, the Ballard Blocks project was built surrounding her 108-year-old farmhouse. While production of the film ''Up'', based on a strikingly similar premise, started two years before, Disney acknowledged the story with a promotional tie-in.\n}}\n{{do\n| name=Ballard Commons Park | alt= | url=https://www.seattle.gov/parks/allparks/ballard-commons-park | email=\n| address=5701 22nd Ave. NW | lat=47.67802 | long=-122.33830 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Ballard Commons Park | wikidata=Q118112792\n| lastedit=2024-09-28\n| content= Ballard Commons Park features a playground, skate bowl, water feature, engaging public art, relaxing seating areas and lawns and ADA accessible walkways. \n}}\n{{do\n| name=Golden Gardens Park | alt= | url=https://www.seattle.gov/parks/allparks/golden-gardens-park | email=\n| address= | lat=47.69175 | long=-122.40394 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Golden Gardens Park | wikidata=Q5579416\n| content=A wonderful saltwater beach park in Ballard, past the locks and Shilshole Marina. Water around here is too cold to swim except for crazy and determined kids during the hottest months, but you can wade a little, walk the beach, make sand castles, claim a fire pit, and watch wind surfers and ships go by. On clear days there is a magnificent view of the Olympics on the other side of Puget Sound. Kids of all ages have endless muddy fun trying to dam up or re-route the fresh water stream flowing across one end of the beach.\n}}\n{{do\n| name=Greenlake Park | alt= | url=https://www.seattle.gov/parks/allparks/green-lake-park | email=\n| address= | lat=47.67802 | long=-122.33830 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Green Lake (Seattle) | wikidata=Q3116094\n| lastedit=2023-06-17\n| content=The focal point of the Green Lake neighborhood. The {{mi|2.8}} loop path around the lake is frequented by cyclists, runners, rollerbladers, and walkers. The lake is swimmer friendly; algae growth and closures were sometimes a problem before 2004, when the City of Seattle performed a comprehensive cleanup, and it is now one of the cleanest public swimming areas in the Seattle area.\n}}\n{{do\n| name=Sacred Rain Healing Center | alt= | url=https://www.sacredrainhealing.com/ | [email protected]\n| address=1100 NW 50th Street | lat=47.66518 | long=-122.37102 | directions=\n| phone=+1 206 7896288 | tollfree=\n| hours=10AM-10PM every day | price=$35\n| lastedit=2024-09-28\n| content=Connect with yourself. Seattle's only indoor/outdoor, all-gender clothing-optional hydrotherapy spa. Therapeutic massage, acupuncture, chiropractic, naturopathic physician, yoga, sacred music, nature-bathing. 18 and older.\n}}\n{{do\n| name=Greenwood Classic Car Show | url=https://www.greenwoodcarshow.com | email=\n| address= | lat=47.67797 | long=-122.35525 | directions=\n| phone= | tollfree= | fax=\n| hours=Jun | price=Free for spectators\n| content=Outdoor car show with all types of cars and food vendors about 1 mile along Greenwood Ave North.\n}}\n{{do\n| name=Ballard Seafoodfest | alt= | url=https://seafoodfest.org/ | email=\n| address= | lat=47.66853 | long=-122.38460 | directions=\n| phone= | tollfree=\n| hours=Typically mid-July | price=Free\n| lastedit=2024-07-07\n| content=A parade, a salmon barbecue, local musicians and a lutefisk-eating contest. Most businesses along NW Market St participate by holding sidewalk sales.\n}}\n{{do\n| name=Greenwood/Phinney Art Walk | url=https://www.phinneycenter.org/events/art-walk/ | email=\n| address=6532 Phinney Ave N | lat=47.67769 | long=-122.35428 | directions=\n| phone= | tollfree= | fax=\n| hours=July | price=Free\n| content=Showcases hundreds of local artists.\n}}\n{{do\n| name=Julefest | alt= | url=https://nordicmuseum.org/events/julefest | email=\n| address=2655 NW Market St | lat=47.66519 | long=-122.37098 | directions=\n| phone= | tollfree=\n| hours=Typically a Sat and Sun in late November or early December | price=\n| lastedit=2024-07-07\n| content=Discounted admission to the Nordic Museum and a large collection of food, drink, and craft vendors, with live music. A Nordic-inspired Christmas market.\n}}\n{{do\n| name=Freakout Festival | alt= | url=https://www.the-freakout.com | email=\n| address= | lat=47.66853 | long=-122.38460 | directions=\n| phone= | tollfree=\n| hours=Typically a Thu-Sun period in early November | price=\n| wikipedia=Freakout Festival | wikidata=Q119348565\n| lastedit=2024-09-28\n| content=An annual 4-day underground music festival held at various venues around the neighborhood each November.\n}}\n{{do\n| name=Conor Byrne Pub | url=https://www.conorbyrnepub.com/ | email=\n| address=5140 Ballard Ave NW | lat=47.66550 | long=-122.38188 | directions=\n| phone=+1 206 784-3640 | tollfree= | fax=\n| hours= | price=\n| lastedit=2024-09-28\n| content=A small live music venue and bar.\n}}\n{{do\n| name=Tractor Tavern | alt= | url=https://tractortavern.com/ | email=\n| address=5213 Ballard Ave NW | lat=47.66569 | long=-122.38282 | directions=\n| phone=+1 206 789-3599 | tollfree= | fax=\n| hours= | price=\n| lastedit=2024-09-28\n| content=A medium-sized live music venue typically only open during shows\n}}\n{{do\n| name=Sunset Tavern | url=https://sunsettavern.com/ | email=\n| address=5433 Ballard Ave NW | lat=47.66826 | long=-122.38613 | directions=\n| phone=+1 206 784-4880 | tollfree= | fax=\n| hours= | price=\n| lastedit=2024-09-28\n| content=A small live music venue and bar.\n}}\n{{do\n| name=Egan's Ballard Jam House | url=https://ballardjamhouse.com/ | email=\n| address=1707 NW Market St | lat=47.66852 | long=-122.37935 | directions=\n| phone=+1 206 789-1621 | tollfree= | fax=\n| hours= | price=\n| lastedit=2023-06-17\n| content=A small intimate music supper club with mostly jazz (vocal and instrumental) and other styles of live music offered. Open mic (vocal jam) every other Wednesday evening.\n}}"++ },++ {++ "title": "Seattle/Fremont",++ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/Fremont",++ "wikitext": "{{see\n| name=Fremont Bridge | alt= | url= | email=\n| address= | lat= | long= | directions=connects Fremont Ave N and 4th Ave N\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q5501558\n| lastedit=2025-02-03\n| content=A bascule bridge (drawbridge) spanning the Fremont Cut since 1917. The bridge opens an average of 35 times a day, which makes it the most frequently opened drawbridge in the United States and one of the busiest bascule bridges in the world.\n}}\n{{see\n| name=Gas Works Park | alt= | url=https://www.seattle.gov/parks/allparks/gas-works-park | email=\n| address=2101 N Northlake Way | lat=47.646 | long=-122.335 | directions=\n| phone=+1 206 684-4075 | tollfree= | fax=\n| hours=4AM-11:30PM daily | price=Free\n| wikipedia=Gas Works Park | image=Aerial Gas Works Park September 2011.jpg | wikidata=Q5526323\n| content=\"Gasworks\" is Wallingford's oddly endearing refuge along the north part of Lake Union — prominently featuring the rusting, hulking remains of the city's old gasworks piping and machinery. More conventionally, a solitary bump of a hill offers the ideal spot for kite flying and views across the lake of sailboats, float planes landing, and downtown Seattle at sunset. Generally quiet but exceptionally crowded during the 4th of July for firework shows.\n}}\n{{see\n| name=Woodland Park | alt= | url=https://www.seattle.gov/parks/allparks/woodland-park | email=\n| address=1000 N 50th St | lat=47.66873 | long=-122.34403 | directions=across Aurora Ave from the zoo\n| phone= | tollfree=\n| hours= | price=\n| wikidata=\n| lastedit=2025-02-03\n| content=One of the city's most active hubs for sports and recreation, including walkway paths, several playfields, tennis courts, lawn bowling, horseshoe pits, a skatepark, and a large, wooded dog off-leash area.\n}}\n{{see\n| name=Woodland Park Zoo | alt= | url=https://www.zoo.org/ | email=\n| address=5500 Phinney Ave N | lat=47.6693 | long=-122.3532 | directions=\n| phone=+1 206 548-2500 | tollfree= | fax=\n| hours=May–September 9:30AM-6PM; October–April 9:30AM-4PM | price=May–September $18.75 per adult, $11.75 per child; October–May $11.75 per adult, $8.75 per child. Discounts for using public transit or on a rainy day!\n| wikipedia=Woodland Park Zoo | wikidata=Q8032871\n|lastedit=2023-06-17| content=The zoo hosts 1,100 animals from 300 different species, each housed in nice, naturalistic exhibits. You can feed a giraffe or an elephant by yourself for $5, or enter the aviary where the birds fly overhead and show off talents with the aid of a zookeeper. For the kids, the zoomazium is an additional place to visit for slides and a century-old carousel. Allow at least 3 hours of your time to fully explore the zoo.\n}}\n{{see\n| name=Waiting for the Interurban | alt= | url= | email=\n| address=N. 34th St. at Fremont Ave., at the north approach to the Fremont Bridge | lat=47.64944 | long=-122.34953 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Waiting for the Interurban | wikidata=Q7960603\n| content=A statue of five people (and a dog) waiting for the trolley that has not existed since the 1930s. The statue is frequently clothed by local residents. There was a time when this work was considered offbeat (imagine that!).\n}}\n{{see\n| name=Fremont Rocket | alt= | url= | email=\n| address= | lat=47.65062 | long=-122.35116 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Fremont Rocket | wikidata=Q17156031\n| content=A 53' Cold War rocket, improved with neon space blasters and a smoke generator in the engine bay, towers over the corner of N. 35th Street and Evanston Avenue.\n}}\n{{see\n| name=Fremont Troll | alt= | url= | email=\n| address= | lat=47.65100 | long=-122.34732 | directions=on 36th Street under the Aurora Bridge\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Fremont Troll | wikidata=Q1354391\n| content=Strikingly large (photos generally don't do it justice), the Troll is a concrete statue under the Aurora Bridge clutching a full size Volkswagen Bug in its grip. The statue is notable enough to even gets its own street name - just off of N. 36th Street on Troll Avenue.\n}}\n{{see\n| name=Statue of Lenin | alt= | url= | email=\n| address=N. 36th St. and Evanston Ave | lat=47.65138 | long=-122.35097 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Statue of Lenin, Seattle | wikidata=Q932203\n| content=This bronze statue peers out over the street below, oblivious to the continuing controversy over its display. It was salvaged and brought over from [[Slovakia]] and was re-erected in 1995 in a Fremont parking lot that at the time hosted a weekly market before being moved a few years later to its current site.\n}}\n{{do\n| name=Tai Chi at Meridian Park | url=https://www.seattle.gov/parks/allparks/meridian-playground | email=\n| address=4649 Sunnyside Ave. N | lat=47.66381 | long=-122.33208 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| content=On most Sunday mornings, groups of people practice tai chi or assorted other martial arts in the southeast quadrant of Meridian Park.\n}}\n{{do\n| name=Fremont Abbey Arts Center | url=https://www.fremontabbey.org/ | email=\n| address=4272 Fremont Ave N | lat=47.65917 | long=-122.34989 | directions=\n| phone=+1 206-414-8325 | tollfree= | fax=\n| hours= | price=\n| content=A performing and demonstrative arts venue.\n}}\n{{do\n| name=Fremont Fair | url=https://www.fremontfair.org/ | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree= | fax=\n| hours=Weekend closest to June 20 | price=\n|lastedit=2023-06-17| content=If you happen to be in Seattle around the Summer Solstice, Fremont hosts one of the city's best street fairs on the weekend closest to the solstice. The highlight is the '''Solstice Parade''' on Saturday afternoon, an arts-only parade held by the Fremont Art's Council with hundreds, perhaps even thousands, of elaborately costumed participants, no signage (strongly discouraging commercial participation), and only human-powered floats. A contingent of nude and painted bicyclists traditionally show up and ride before the parade begins. Delilah's \"Billion Belly March\" contingent annually brings over a hundred belly dancers and drummers to the parade.\n}}\n{{do\n| name=Fremont Oktoberfest | url=https://fremontoktoberfest.com/ | email=\n| address= | lat= | long= | directions=\n| phone=+1 206 633-0422 | tollfree= | fax=\n| hours=Third week of September | price=From $15 if purchased in advance, $20 same day\n|lastedit=2023-06-17| content=An annual microbrew tasting event that features the Texas Chainsaw Pumpkin Carving Contest, live music, three beer gardens, a dog day event, the Brew HA-HA 5K, a comedy show, and the Miss Buxom Contest.\n}}"++ },++ {++ "title": "Seattle/University District",++ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/University_District",++ "wikitext": "{{see\n| name=Suzzallo Library | alt= | url=https://www.lib.washington.edu/suzzallo/ | email=\n| address=at Red Square | lat=47.6557 | long=-122.308 | directions=\n| phone=+1 206 543-0242 | tollfree= | fax=\n| hours= | price=\n| wikipedia=Suzzallo Library | image=UDub Red Square Suzzallo Library.jpg | wikidata=Q7651462\n| content=The Suzzallo is a grand Gothic-style structure. The high-ceiling Graduate Reading room is especially impressive.\n}}\n{{see\n| name=Red Square | alt=formally called the Central Plaza | url= | email=\n| address= | lat=47.65592 | long=-122.30953 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Red Square (University of Washington) | wikidata=Q7305086\n| content=This red brick-paved plaza fills with students moving between classes, hanging out, or some student-led activities.\n}}\n{{see\n| name=The Quad | alt=Liberal Arts Quadrangle | url= | email=\n| address= | lat=47.65742 | long=-122.30697 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikidata=Q47090840\n| lastedit=2026-04-02\n| content=A large courtyard surrounded by the liberal arts buildings, decorated with 29 Japanese cherry trees. [https://www.washington.edu/cherryblossoms/ The Quad turns a wonderful shade of pink for a few weeks when the cherry trees bloom in early spring (usually late March-early April)]. It is the premier cherry blossom picnic spot in the region, filling with hundreds of people during peak bloom.\n}}\n{{see\n| name=Burke Museum | alt=Burke Museum of Natural History and Culture | url=https://www.burkemuseum.org/ | email=\n| address=University of Washington, 17th Ave NE & NE 45th St | lat=47.66097 | long=-122.30977 | directions=\n| phone=+1 206 543-5590 | tollfree=\n| hours=Tue-Sun 10AM-5PM, first Thu of each month 10AM-8PM | price=$24 general, $22 seniors, $16 students and youth, children 3 and under free, free for everyone first Thursday of every month\n| wikipedia=Burke Museum of Natural History and Culture | image=Burke Museum totem poles 01.jpg | wikidata=Q388376\n| lastedit=2026-04-02\n| content=Washington's oldest and largest natural history museum. In addition to exhibits on evolution and dinosaur skeletons, there is an emphasis on native peoples of the Northwest Coast, local ecosystems and windows into the day-to-day work of researchers. Most galleries have non-linear arrangements that reward wandering to whatever catches your eye. Highlights include colorful contemporary and historic crafts from Native artists, a well-preserved ''T. rex'' skull and the only dinosaur fossil found in Washington.\n}}\n{{see\n| name=Henry Art Gallery | alt= | url=https://henryart.org/ | [email protected]\n| address=University of Washington, 15th Ave NE & NE 41st St | lat=47.65650 | long=-122.31165 | directions=\n| phone=+1 206 543-2280 | tollfree= | fax=+1 206 685-3123\n| hours=Thu Fri 11AM-9PM, Sat Sun 11AM-4PM | price=$10 general, $6 seniors, free for UW students and staff, free for everyone on first Thursdays\n| wikipedia=Henry Art Gallery | wikidata=Q5717411\n|lastedit=2023-06-17| content=One of the biggest contemporary art galleries in Washington, The Henry shows well-known contemporary and modern artists of visual media, as well as an annual exhibition of the UW's Master of Fine Arts students.\n}}\n{{do\n| name=University of Washington Waterfront Activities Center | alt= | url=https://www.washington.edu/ima/ | email=\n| address= | lat=47.64778 | long=-122.30005 | directions=behind Husky Stadium at Montlake Blvd. and NE Pacific St\n| phone=+1 206 543-9433 | tollfree=\n| hours=Hours vary, call for details | price=Starting at $21/hour, with substantial discounts for UW students, staff, and alumni\n| lastedit=2023-06-17\n| content=Rents canoes and rowboats, with all the necessary accessories. The ramp is also a good location to launch canoes and kayaks of your own.\n}}\n{{do\n| name=Agua Verde Paddle Club | url=https://aguaverdepaddleclub.com/ | email=\n| address=1303 NE Boat St | lat=47.65161 | long=-122.31437 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n|lastedit=2023-06-17| content=Hourly kayak rentals from Portage Bay providing access to the Arboreteum, Lake Washington and Lake Union.\n}}\n{{do\n| name=University Heights Center for the Community | alt= | url=https://www.uheightscenter.org/ | email=\n| address=5031 University Way NE | lat=47.66559 | long=-122.31332 | directions=five blocks from UW\n| phone=+1 206 527-4278 | tollfree=\n| hours=Mon-Fri 8AM-10PM, Sat 9AM-3PM, Sun 9AM-1PM | price=\n| content=A not-for-profit community-based organization in the University District which offers an array of classes, host activities and events for the University District & Greater Seattle Area. Accepts visitors everyday. Once a Seattle School, now a designated landmark. Stop in the center or just cruise by and visit the Farmer's Market every Saturday year round.\n}}\n{{do\n| name=Figure Drawing | url= | email=\n| address=top floor of the Art building on UW campus | lat=47.65852 | long=-122.30652 | directions=\n| phone= | tollfree= | fax=\n| hours=Sun 10:30AM-1PM | price=$1-4 depending on number of attendees\n| content=Show up early to secure an easel and to find the host room. This nude figure drawing session is open to the public. Bring your own supplies, and cash for the model fee. Model is usually female. All levels welcome.\n}}\n{{do\n| name=U-District Farmer's Market | alt= | url=https://seattlefarmersmarkets.org/udfm | email=\n| address=University Way NE between NE 50th & 52nd | lat=47.6658 | long=-122.313 | directions=\n| phone= | tollfree=\n| hours=Sat 9AM-2PM, year round | price=\n| content=Local produce, cheeses and other products can be found here.\n}}\n{{do\n| name=Ravenna Park | alt= | url=https://www.seattle.gov/parks/allparks/ravenna-park | email=\n| address=5520 Ravenna Ave. NE | lat=47.6714 | long=-122.30571 | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikipedia=Ravenna Park | wikidata=Q7296467\n| content=Fans of comic artist Charles Burns might like to visit this park, the site of some of the creepier scenes in his book ''Black Hole''.\n}}\n{{do\n| name=Husky Stadium | alt=officially Alaska Airlines Field at Husky Stadium | url=https://gohuskies.com/facilities/alaska-airlines-field-at-husky-stadium/4 | email=\n| address=3800 Montlake Blvd NE | lat= | long= | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q1136463\n| lastedit=2025-01-30\n| content=Venue for the Washington Huskies football team\n}}\n{{do | name=Laughs Comedy Club | url=https://laughscomedyclub.com/ | address=5220 Roosevelt Way NE | lat=47.66695 | long=-122.31713 | content=Comedy club featuring both locals and nationally-known stars.}}\n{{do\n| name=Jet City Improv | alt= | url=https://www.jetcityimprov.org/ | email=\n| address=5510 University Way NE | lat=47.66889 | long=-122.31281 | directions=\n| phone=+1 206-352-8291 | tollfree=\n| hours= | price=\n| lastedit=2020-02-29\n| content=Improv comedy theater, with an emphasis on innovative improvisational formats.\n}}\n{{do\n| name=Neptune Theatre | alt= | url=https://www.stgpresents.org/ | email=\n| address=1303 NE 45th St | lat=47.66116 | long=-122.31405 | directions=one block west of University Way NE on 45th\n| phone=+1 206 682-1414 | tollfree=\n| hours= | price=\n| wikidata=Q29829461\n| lastedit=2022-02-01\n| content=Live music venue in a 1921 theater building, operated by the same management as downtown's Paramount and Moore.\n}}\n{{do\n| name=Friends of the Seattle Public Library Book Sale | alt= | url=https://www.friendsofspl.org/ | email=\n| address=Building 30, Magnuson Park | lat= | long= | directions=\n| phone= | tollfree=\n| hours= | price=Many items for $1\n| content=Held twice yearly, in April and September. Over 200,000 books, CDs, and videos from library surplus and private donations occupy an entire airplane hanger.\n}}\n{{do\n| name=U-District Street Fair | url= | email=\n| address= | lat= | long= | directions=along the Ave\n| phone= | tollfree= | fax=\n| hours=May | price=\n| content=This street fair includes some performance artists (mainly musicians), many booths selling their wares, some food stalls and some live music. A rather generic affair over-all.\n}}"++ },++ {++ "title": "Seattle/North",++ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/North",++ "wikitext": "{{see\n| name=Carkeek Park | alt= | url=https://www.seattle.gov/parks/parks/carkeek-park | email=\n| address=950 NW Carkeek Park Rd | lat=47.7127 | long=-122.3783 | directions=enter from NW 110th St or 117th St\n| phone=+1 206-684-0877 | tollfree= | fax=\n| hours=6AM-10PM daily | price=Free\n| wikipedia=Carkeek Park | image=Carkeek Park-1.jpg | wikidata=Q5039739\n|lastedit=2023-06-17| content=The century-old trees and surprisingly steep terrain surrounding a small valley give the impression that you're far from the city. Excellent for a walk in the woods. Walkable beach below the train tracks.\n}}\n{{see\n| name=Maple Leaf Park | alt= | url=https://www.seattle.gov/parks/allparks/maple-leaf-reservoir-park | email=\n| address=1020 NE 82nd St | lat=47.6905 | long=-122.3154 | directions=\n| phone=+1 206-615-0810 | tollfree= | fax=\n| hours= | price=\n|lastedit=2023-06-17| content=This area used to be a reservoir. It is now drained, replaced by a wide open green area locals used for playing, but the water tower is still standing. As it is the highest spot in North Seattle, you can catch a wonderful panorama of downtown Seattle, Mount Rainier, the Cascades, Lake Washington, and partially blocked Olympic Mountains.\n}}\n{{see\n| name=Warren G. Magnuson Park | alt= | url=https://www.seattle.gov/parks/allparks/magnuson-park | email=\n| address=7400 Sand Point Way NE | lat=47.6808 | long=-122.248 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Magnuson Park | image=Magnuson Park-1.jpg | wikidata=Q10323074\n|lastedit=2023-06-17| content=Seattle's second-largest park, formerly the Sand Point Naval Air Station. The wide-open spaces of the park support numerous athletic fields and picnic spots, with windsurfing and a boat launch along the Lake Washington shoreline. The buildings remaining from its military days are home to many community organizations and special events.\n}}\n{{do|name=Granite Curling Club|url=http://curlingseattle.org/|email=|address=1440 N 128th St|lat=47.72287|long=-122.34065|directions=|phone=|tollfree=|fax=|hours=|price=|lastedit=2023-06-18|content=An excellent facility dedicated to curling, the loud ice sport with stones and brooms. They host regular open houses to teach people how to play.}}\n{{do\n| name=Kraken Community Iceplex | alt= | url=https://www.krakencommunityiceplex.com/ | email=\n| address=10601 5th Ave NE | lat= | long= | directions=\n| phone= | tollfree=\n| hours= | price=From $18\n| wikidata=Q131374838\n| lastedit=2026-10-08\n| content=This practice facility for the Seattle Kraken hockey team is open for public skate sessions over lunch hours, weekends and evenings.\n}}\n{{do\n| name=Jackson Park Golf Course | alt= | url=https://www.seattlegolf.com/jacksonpark/ | email=\n| address=1000 NE 135th St | lat=47.7286 | long=-122.3166 | directions=\n| phone=+1 206-363-4747 | tollfree= | fax=\n| hours= | price=Championship course: $35 weekday, $45 weekend; Executive course: $8.50 9-hole, $14.00 18-hole (playing twice)\n| content=A decent 18-hole championship and a 9-hole executive golf course plus a driving range.\n}}\n{{do\n| name=Underwater Sports | alt= | url=https://www.underwatersports.com/ | email=\n| address=10545 Aurora Ave N | lat=47.70621 | long=-122.34511 | directions=\n| phone=+1 206-362-3310 | tollfree= | fax=\n| hours=Mon-Fri 9AM-7PM, Sat-Sun 9AM-6PM | price=\n|lastedit=2023-06-17| content=You can buy diving gear here but the unique thing about this place is that they offer open water scuba diving certification classes, as well as more basic swimming classes. Lessons conducted at their indoor pool and then out in the Puget Sound.\n}}\n{{do\n| name=Lake City Summer Festival and Parade | url=https://lakecitysummerfest.com/ | email=\n| address=along NE 125th Street from 25th Avenue NE to Lake City Way NE | lat=47.7193 | long=-122.2992 | directions=\n| phone= | tollfree= | fax=\n| hours=August | price=\n| content=Lake City has a great street fair in late summer, complete with street vendors, food, music and a parade with local kids and performers on up to the SeaFair Pirates. It has a small-town feel while the streets are lined with all sorts of families and crazies from the neighborhood. Fun for all.\n}}"++ },++ {++ "title": "Seattle/Sodo-Georgetown",++ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/Sodo-Georgetown",++ "wikitext": "{{see\n| name=Coast Guard Museum Northwest | alt= | url=https://www.rexmwess.com/cgpatchs/cogardmuseum.html | email=\n| address=Pier 36, Base Seattle, 1519 Alaskan Way S | lat=47.5906 | long=-122.338 | directions=\n| phone=+1 206 217-6993 | tollfree=\n| hours=Mon Wed Fri 9AM-3PM | price=Free\n| wikipedia=Coast Guard Museum Northwest | image=USCG Museum NW - Coast Guard Academy exhibit.jpg | wikidata=Q5138152\n| lastedit=2015-09-20\n| content=A small museum with thousands of Coast Guard related items.\n}}\n{{see\n| name=Connections Museum Seattle | alt=Museum of Communications | url=https://www.telcomhistory.org/connections-museum-seattle/ | email=\n| address=7000 E Marginal Way S | lat=47.54075 | long=-122.32302 | directions=\n| phone=+1 206 767-3012 | tollfree=\n| hours=Sun 10AM-3PM | price=Cash-only $5/adult, $2/child\n| wikipedia=Connections Museum | wikidata=Q6940805\n| lastedit=2020-01-28\n| content=A museum of antique telephone equipment, although \"warehouse\" might be a better description than museum, with racks upon racks crammed full of equipment. Many exhibits are kept functional, including mechanical and electronic switches, telephone poles, and antique telephones. Tours take at least an hour, but will go on for 2-3hrs you have strong technical interests and lots of questions. The building itself is still owned by CenturyLink.\n}}\n{{see\n| name=Museum of Flight | alt= | url=https://www.museumofflight.org/ | email=\n| address=9404 E Marginal Way S | lat=47.519 | long=-122.3 | directions=At Boeing Field. Metro bus #124. No convenient access from light rail.\n| phone=+1 206 764-5720 | tollfree=\n| hours=Daily 10AM to 5PM except 1st Thu of month 10AM to 9PM | price=$20 adults, $12 youth (5-17), children (4 and under) free, $17 active military and seniors (65+). Lower fares available for groups of more than 10 people. Free on first Thursday of the month from 5PM until 9PM\n| wikipedia=Museum of Flight | image=Museum of Flight, Seattle.jpg | wikidata=Q1402597\n| content=A massive air and space museum, near the original Boeing factory. The museum consists of an indoor area that is further divided into the history, jet age, airport operations, and space travel, and an outdoor section where you can sneak inside retired airplanes. The collection includes 131 aircraft and spacecraft ranging from wood and fabric crates to the SR-71, Space Shuttle Trainer, a retired Air-Force One and a Concorde. It's arguably better than even the Smithsonian's Air and Space Museum in the other [[Washington, D.C.|Washington]]. Tours to the vicinity of the airport inside a van are available, but most spots do not allow cameras for security purposes! The Museum offers a large educational outreach program including weekend family workshops, the Aerospace Camp Experience (ACE) summer day camp, and an aviation high school.\n}}\n{{see\n| name=Hat & Boots Park | alt= | url=https://www.seattle.gov/parks/allparks/oxbow-park | email=\n| address=6430 Corson Ave. S | lat=47.5451 | long=-122.3217 | directions=In Oxbow Park\n| phone=+1 206-684-4075 | tollfree=\n| hours=4AM-11:30PM daily | price=\n| image=Oxbow Park Panorama.jpg\n| lastedit=2024-01-05\n| content=See a {{ft|44}} wide cowboy hat and a {{ft|22}} high boot in this park.\n}}\n{{do\n| name=Lumen Field | alt= | url=https://www.lumenfield.com | email=\n| address=800 Occidental Ave S | lat=47.5952 | long=-122.3316 | directions=\n| phone=+1 206 381-7555 | tollfree= | fax=\n| hours= | price=\n| wikipedia=Lumen Field | image=Qwest Field North.jpg | wikidata=Q612736\n| content=Home to Seattle's beloved Seahawks [[American football]] team since 2002 and Sounders soccer club, plus the women's soccer club Seattle Reign, Lumen Field is an impressive open air sports arena with great views beyond the goal posts. It was also host to 6 matches during the 2026 World Cup. The stadium itself is notorious for being ''deafening'' while the so-called 12th Man cheers for the Seahawks. If you can't make it to the game, you can join a tour to see a glance of the history of the teams that call the spot home. Tours are held Fridays and Saturdays at 12:30PM and 2:30PM for $12, $5 for child ages 4-12, $8 ages 65+.\n}}\n{{do\n| name=T-Mobile Park | alt= | url=https://www.mlb.com/mariners/ballpark | email=\n| address=1250 1st Ave S | lat=47.5914 | long=-122.3325 | directions=\n| phone=+1 206 346-4287 | tollfree=\n| hours= | price=\n| wikipedia=T-Mobile Park | image=SafecoFieldTop.jpg | wikidata=Q1193117\n| content=Since 1999, T-Mobile Park has been home to the Seattle Mariners [[Baseball in the United States|baseball]] team and is one of the finest ballparks in the country. Right field bleacher seats enjoy a panoramic view of Downtown and Elliott Bay. An element of the unique atmosphere is the train whistle that sounds each time a Mariners' home run occurs. The stadium offers anything from traditional ballpark dogs and the famous garlic fries to fine gourmet fare and local beers. The aroma of food is everywhere inside the park. There couldn't be a better way to spend a summer evening with friends and family in Seattle. Take the elevator to 300 level to the Rooftop Boardwalk and see great views of the Puget Sound and downtown skyline and check out the free Mariners Hall of Fame on the 100 level for interesting exhibits and photos of Mariners players and memorabilia. You can tour the stadium for $10, $5 children ages 3-12. Schedules vary by season, but there are always tours at 12:30PM and 2:30PM, except during game days. You can buy the tickets online or at the Mariners pro shop.\n}}\n{{do\n| name=Showbox SoDo | url=http://www.showboxonline.com/ | email=\n| address=1700 1st Ave S | lat=47.58792 | long=-122.33388 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| content=Sister venue to the original Showbox across from Pike Place Market, Showbox SoDo offers a variety of live music acts, check website for upcoming events. There is also food, of the burger and pizza variety, and drinks available from the lounge.\n}}\n{{do\n| name=Georgetown Art Attack | url=https://georgetownseattle.org/art-attack/ | email=\n| address= | lat= | long= | directions=along Airport Way S\n| phone= | tollfree= | fax=\n| hours=Every second Sat of the month, 6-9PM | price=\n| content=Local artisans present a stellar array of visual and performing arts throughout the historic industrial arts corridor. Featured studios include the Georgetown Arts and Cultural Center, Vecta Photo, the Georgetown Atelier, Nautilus Studio, Krab Jab Studios, and Tenaya Sims.\n}}"++ },++ {++ "title": "Seattle/South",++ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/South",++ "wikitext": "{{see\n| name=Northwest African American Museum | alt= | url=https://www.naamnw.org/ | email=\n| address=2300 S Massachusetts St | lat=47.5889 | long=-122.3017 | directions=\n| phone=+1 206 518-6000 | tollfree=\n| hours=Wed 11AM-5PM, Thu 11AM-7PM, Fri-Sun 11AM-5PM | price=$7 adult, $5 children 4-12, students, seniors; free the first Thursday of every month\n| wikipedia=Northwest African American Museum | wikidata=Q7059902\n| content=Short but powerful museum on the history of Blacks in the Northwest.\n}}\n{{see\n| name=Jefferson Park | alt= | url=https://www.seattle.gov/parks/allparks/jefferson-park | email=\n| address=3801 Beacon Ave S | lat=47.5666 | long=-122.307 | directions=on Beacon Hill, 0.5 mi south of Beacon Hill Station\n| phone= | tollfree= | fax=\n| hours=4AM - 11:30PM | price=\n| wikipedia=Jefferson Park (Seattle) | image=Jefferson park trees in snow.jpg | wikidata=Q12060863\n| content=Seattle's sixth largest park at 45.2 acres, this park designed by the Olmsted Brothers offers unparalleled views of the Duwamish River, downtown and the Olympic Mountains. It is the home to the Jefferson Park Golf Course, Jefferson Park driving range, Jefferson Skatepark, tennis courts (lighted), basketball courts, the Jefferson Community Center, Jefferson Lawn Bowling, and Beacon Mountain an ecologically oriented playscape with a water park in the summer.\n}}\n{{see\n| name=Seward Park | alt= | url=https://www.seattle.gov/parks/allparks/seward-park | email=\n| address= | lat=47.5559 | long=-122.2515 | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikipedia=Seward Park (Seattle) | wikidata=Q14713892\n| content=This wooded Olmsted park takes up a large peninsula surrounded by Lake Washington. Attractions include kids activities in the summer, great scenery, and a {{mi|2.5}} paved level path that circles the whole peninsula.\n}}\n{{see\n| name=Kubota Gardens | alt= | url=https://www.seattle.gov/parks/find/parks/kubota-garden | email=\n| address=9817 55th Ave S | lat=47.512 | long=-122.2657 | directions=\n| phone=+1 206 684-4075 | tollfree= | fax=\n| hours=Daily 6AM-10PM | price=Free\n| wikipedia=Kubota Garden | wikidata=Q6441303\n| lastedit=2017-07-13\n| content=In 1927 Fujitaro Kubota bought {{convert|5|acre|m2}} of logged-off swampland in the Rainier Beach neighborhood of Seattle and began his garden. Kubota Garden has since grown into a stunning {{convert|20|acre|m2|adj=on}} landscape that blends Japanese garden concepts with native Northwest plants. Kubota was a horticultural pioneer when he began merging Japanese design techniques with North American materials in his display garden in 1927. The Gardens are a spectacular setting of hills and valleys, interlaced with streams, waterfalls, ponds, bridges, and rock outcroppings with a rich array of plant material.\n}}\n{{do\n| name=The Beacon Cinema | alt= | url=https://thebeacon.film/ | [email protected]\n| address=4405 Rainier Ave S | lat=47.5641 | long=-122.2884 | directions=in Columbia City\n| phone=+1 206 420-7328 | tollfree= | fax=\n| hours=Vary by movie times | price=\n| content=A relatively new movie theater focused on specialized programming.\n}}\n{{do\n| name=Columbia City Theater | alt= | url=https://www.columbiacitytheater.com/ | email=\n| address=4916 Rainier Ave S | lat=47.55727 | long=-122.28451 | directions=\n| phone=+1 206 722-3009 | tollfree=\n| hours=Tue-Sun 6PM-2AM | price=\n| content=Great place to see a band play.\n}}"++ },++ {++ "title": "Seattle/West",++ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/West",++ "wikitext": "{{see\n| name=Alki Point Lighthouse | alt= | url= | email=\n| address=3200 Point Pl SW | lat=47.57638889 | long=-122.42055556 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Alki Point Light | image=Alkilighthouse.jpg | wikidata=Q2641400\n| content=Historic Lighthouse on Alki Point a short walk from Alki Beach sometimes offers public tours but can at be at least partially seen from the road.\n}}\n{{see\n| name=Statue of Liberty | alt= | url= | email=\n| address= | lat=47.57949 | long=-122.41063 | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q56670195\n| lastedit=2025-02-15\n| content=The small replica toward the west end of the beach area is much beloved in the neighborhood. West Seattle has a peculiar affinity to New York City, where a few influential early settlers were from.\n}}\n{{see\n| name=Hamilton Viewpoint Park | alt= | url= | email=\n| address=1531 California Way SW (between Atlantic St & S Ferry Ave) | lat=47.5934 | long=-122.3860 | directions=Take the West Seattle Water Taxi from downtown Seattle\n| phone= | tollfree= | fax=\n| hours=4AM-1130PM | price=Free\n| wikipedia=Hamilton Viewpoint | wikidata=Q5645229\n| content=Hamilton Viewpoint Park rests above the beaches of Alki facing downtown Seattle. Located adjacent to the Seacrest Ferry Dock.\n}}\n{{see\n| name=Lincoln Park | alt= | url=https://www.seattle.gov/parks/allparks/lincoln-park | email=\n| address=8011 Fauntleroy Way SW | lat=47.5307 | long=-122.3978 | directions=From the West Seattle Bridge, continue on Fauntleroy Way until you reach the park on the right.\n| phone=+1 206 684-4075 | tollfree= | fax=\n| hours=4AM-11:30PM daily | price=Free\n| wikipedia=Lincoln Park (Seattle) | wikidata=Q6550922\n| content=Lincoln Park is a big, wild park, next to the Fauntleroy Ferry Dock, serving Vashon Island and Southworth. Lincoln Park has lots of walking paths, picnic areas, beautiful trees, and beach. Unlike Alki Beach, it's quite pebbly and full of driftwood. To reach the park: come across the West Seattle Bridge into West Seattle, bear left at the \"Y\" intersection at Fauntleroy. Stay on Fauntleroy all the way to the park. Follow the signs to the ferries.\n}}\n{{see\n| name=Schmitz Park | alt= | url=https://www.seattle.gov/parks/allparks/schmitz-preserve-park | email=\n| address=5551 SW Admiral Way, 98116 | lat=47.5742 | long=-122.3998 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Schmitz Park (Seattle) | wikidata=Q7431836\n| content=53.1 acre Schmitz Preserve Park has old growth forest, walking paths and an urban stream.\n}}\n{{see\n| name=The Viewpoint at Admiral | url= | email=\n| address=3600 SW Admiral Way | lat=47.5805 | long=-122.3770 | directions=From I-5, take exit 163 to the West Seattle bridge, exit onto Admiral Way. Just before the top of the long climb there's a pullout on the right and a fenced viewing area.\n| phone= | tollfree= | fax=\n| hours= | price=\n| content=Some of the best views of the Seattle skyline are from West Seattle and the Viewpoint at Admiral is the best of the best. You can't miss it as there are usually people there. Bring your camera.\n}}\n{{do\n| name=Alki Beach Park | alt= | url=https://www.seattle.gov/parks/allparks/alki-beach-park | email=\n| address=1702 Alki Ave SW | lat=47.580989 | long=-122.406729 | directions=From the West Seattle Bridge, exit onto Harbor Avenue, and then bear left onto Alki Avenue. The beach runs on the right side\n| phone=+1 206 233-1509 | tollfree= | fax=\n| hours=Apr 15-Oct 1: 6AM-11PM daily, Oct 2-Apr 14: 4AM-11:30PM daily | price=Free\n| wikipedia=Alki Beach Park | image=West Seattle Statue of Liberty 02.jpg | wikidata=Q4727699\n| content=Alki Beach Park is one of the best and biggest sandy beaches in Seattle. There's a rollerblade/bike/walking path along it for miles and the beach itself is always busy and fun. Great views of the city, the ferries and the Olympics. There are several great cafes and coffee shops right across the street. Good place for barbeque as well as many outdoor recreations. It has one of the best night view of Downtown Seattle and the city light.\n}}\n{{do\n| name=West Seattle Golf Course | url=https://www.seattlegolf.com/westseattle/ | email=\n| address=4470 35th Ave SW | lat=47.5623 | long=-122.3714 | directions=\n| phone=+1 206 935-5187 | tollfree= | fax=\n| hours= | price=\n| content=This golf course is one of the finest in the city with great views of Seattle. Fox Den Grille is the restaurant inside that offers breakfast, lunch, dinner, and beverages. Rates of the course are fair depending on the days you go, and the course is well maintained. There are many tournaments going on for golf lovers to participate.\n}}\n{{do\n| name=Seacrest Park | url=https://www.seattle.gov/parks/allparks/seacrest-park | email=\n| address=1660 Harbor Ave SW | lat=47.5887 | long=-122.3800 | directions=Boathouse and Pier is located on Harbor Avenue next to Don Armeni Boat Ramp and is accessible by boat on Elliott Bay.\n| phone= | tollfree= | fax=\n| hours=4AM - 11:30PM | price=\n| content=Seacrest Cove 2 is a local dive site in West Seattle, Washington. The site is within Seacrest Park. Cove 2 is often used as a training site for Open Water, Navigation and other scuba classes. The site has a moderate slope leading down to depths greater than the recreational limit of 130 feet (40 meters). Because of the easily reached deep areas Technical Divers also use Cove 2 for training. The site has a silty bottom, but many submerged logs and pilings and sunken boats (the Honey Bear) that are well decorated with Pacific Northwest Marine Life. There are restrooms in the pier building and a great little Fish and Chips bar within easy walking distance. The site sports a nice view of the Seattle skyline across Elliot Bay. There is an exclusion zone of 150 feet around the walk-on ferry dock. Other sites (Coves 1 and 3!) are just to the north and south respectively. Most parking is curbside on the street.\n}}\n{{do\n| name=Alki Kayak Tours | alt= | url=https://www.kayakalki.com/ | [email protected]\n| address=1660 Harbor Ave SW | lat=47.5894 | long=-122.3806 | directions=\n| phone=+1 206-953-0237 | tollfree=\n| hours= | price=\n| content=seasonal guided sea kayak tours for beginners and experts exploring the natural & human history of Elliot Bay and Puget Sound. They also offer a range of rentals along Alki beach – including stand up paddle boards, quad and inline skates, bicycles and fishing boats.\n}}\n{{do\n| name=[[Blake Island State Park]] | alt= | url= | email=\n| address= | lat=47.5418 | long=-122.4837 | directions=located in Puget Sound in the waters between West Seattle and the Kitsap Peninsula\n| phone= | tollfree=\n| hours= | price=\n| content=West Seattle is a popular launch site to visit this island state park accessible only by boat that was the birthplace of Chief Seattle.\n}}"++ }++ ]++}@@@ -34,7 +34,8 @@ async function boot(opts = {}) {TOURS_DRILL: '1', TOURS_EVENTS_URL: network.url,OV_LIVE_URL: upstreams.url, OV_RENT_URL: upstreams.url,OV_WIKIVOYAGE_URL: upstreams.url, OV_WIKIDATA_URL: upstreams.url, OV_WIKIPEDIA_URL: upstreams.url,-- OV_COMMONS_URL: upstreams.url, OV_OVERPASS_URL: `${upstreams.url}/interpreter`,++ OV_COMMONS_URL: upstreams.url, OV_OVERPASS_URL: `${upstreams.url}/interpreter`, OV_NOMINATIM_URL: upstreams.url,++ OV_WIKIDATA_QUERY_URL: `${upstreams.url}/sparql`,TOURS_MIN_INTERVAL_MS: '0', TOURS_CITY_LIMIT: '3', TOURS_OSM_RADIUS_M: '3000',...(opts.env || {}),};@@@ -16,6 +16,10 @@const http = require('http');const WIKIVOYAGE = {++ Seattle: `'''Seattle''' sits beside Puget Sound, with parks, gardens, and waterfront walks.++==See==++{{see | name=Kubota Garden | lat=47.5122 | long=-122.2654 | content=The Japanese garden has a moon bridge and seasonal colour.}}++`,Tokyo: `'''Tokyo''' is the capital of Japan and one of the world's great cities. It mixes quiet shrines with dense neighbourhoods, and the trains go everywhere.==See==@@@ -86,6 +90,7 @@ The oldest quarter, climbed by tram and on foot.};const ENTITIES = {++ Q5083: { label: 'Seattle', lat: 47.6062, lon: -122.3321, population: 737015, image: 'File:Seattle test.jpg', country: 'Q30', sitelink: 'Seattle' },Q1490: { label: 'Tokyo', lat: 35.6762, lon: 139.6503, population: 13960000, image: 'File:Tokyo test.jpg', country: 'Q17', sitelink: 'Tokyo' },Q1353: { label: 'Delhi', lat: 28.6139, lon: 77.2090, population: 16787941, image: 'File:Delhi test.jpg', country: 'Q668', sitelink: 'Delhi' },Q8686: { label: 'Shanghai', lat: 31.2304, lon: 121.4737, population: 24870895, image: 'File:Shanghai test.jpg', country: 'Q148', sitelink: 'Shanghai' },@@@ -93,8 +98,8 @@ const ENTITIES = {Q90: { label: 'Paris', lat: 48.8566, lon: 2.3522, population: 2140526, image: 'File:Paris test.jpg', country: 'Q142', sitelink: 'Paris' },Q1492: { label: 'Barcelona', lat: 41.3874, lon: 2.1686, population: 1620343, image: 'File:Barcelona test.jpg', country: 'Q29', sitelink: 'Barcelona' },};--const COUNTRIES = { Q17: { label: 'Japan', code: 'JP' }, Q668: { label: 'India', code: 'IN' }, Q148: { label: 'China', code: 'CN' }, Q45: { label: 'Portugal', code: 'PT' }, Q142: { label: 'France', code: 'FR' }, Q29: { label: 'Spain', code: 'ES' } };--const TITLE_QID = { Tokyo: 'Q1490', Delhi: 'Q1353', Shanghai: 'Q8686', Lisbon: 'Q597', Paris: 'Q90', Barcelona: 'Q1492', 'New York City': 'Q60' };++const COUNTRIES = { Q30: { label: 'United States', code: 'US' }, Q17: { label: 'Japan', code: 'JP' }, Q668: { label: 'India', code: 'IN' }, Q148: { label: 'China', code: 'CN' }, Q45: { label: 'Portugal', code: 'PT' }, Q142: { label: 'France', code: 'FR' }, Q29: { label: 'Spain', code: 'ES' } };++const TITLE_QID = { Seattle: 'Q5083', Tokyo: 'Q1490', Delhi: 'Q1353', Shanghai: 'Q8686', Lisbon: 'Q597', Paris: 'Q90', Barcelona: 'Q1492', 'New York City': 'Q60' };const LIVE = {streams: [{ id: 1, username: 'ada', display_name: 'Ada', title: 'IRL walk in Lisbon', category: 'irl', tags: '[]', viewer_count: 12, thumbnail_url: null, started_at: '2026-10-31 10:00:00' }],@@@ -131,6 +136,7 @@ async function startUpstreams() {clips: JSON.parse(JSON.stringify(LIVE.clips)),listings: JSON.parse(JSON.stringify(LISTINGS)),overpass: null,++ nominatim: {},down: {},requests: [],};@@@ -140,6 +146,9 @@ async function startUpstreams() {state.requests.push({ method: req.method, path: url.pathname, query: url.search, headers: req.headers, body: raw });if (state.down[url.pathname] || state.down.all) return json(503, { error: 'source down' });++ if (url.pathname === '/search') return json(200, state.nominatim[String(url.searchParams.get('q') || '').toLowerCase()] || []);++ if (url.pathname === '/sparql') return json(200, { results: { bindings: state.notable || [] } });++if (url.pathname === '/w/api.php') {const action = url.searchParams.get('action');if (action === 'parse') {nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++const assert = require('node:assert/strict');++const { boot, check, done } = require('./helpers/boot');++const { createIndexNow } = require('../server/index-now');++const KEY = 'testkey1';++++(async () => {++ await check('IndexNow batches only canonical city and place pages', async () => {++ const posts = [];++ const index = createIndexNow({ baseUrl: 'https://openvibe.tours', key: KEY,++ fetchImpl: async (_url, options) => { posts.push(JSON.parse(options.body)); return { status: 200 }; } });++ index.add('/city/seattle'); index.add('/city/seattle/free');++ index.add('/place/kubota-garden-plc_0123456789ABCDEFGHJKMNPQRS');++ index.add('/place/not-a-real-id'); index.add('https://elsewhere.example/');++ await index.flush();++ assert.strictEqual(posts.length, 1);++ assert.deepEqual(posts[0].urlList, [++ 'https://openvibe.tours/city/seattle', 'https://openvibe.tours/city/seattle/free',++ 'https://openvibe.tours/place/kubota-garden-plc_0123456789ABCDEFGHJKMNPQRS',++ ]);++ assert.strictEqual(posts[0].key, KEY);++ });++ const t = await boot({ env: { INDEXNOW_KEY: KEY } });++ try {++ await check('IndexNow ownership key is served as a public text file', async () => {++ const response = await t.get(`/${KEY}.txt`);++ assert.strictEqual(response.status, 200);++ assert.strictEqual(response.text, KEY);++ });++ } finally { await t.close(); }++ done();++})().catch((err) => { process.stdout.write(String(err.stack || err)); process.exit(1); });@@@ -25,13 +25,13 @@ const { seed } = require('./helpers/seed');assert.match(r.text, /href="\/routes\/rte_/);assert.match(r.text, /href="\/stays"/);assert.match(r.text, /TouristAttraction|TouristDestination|FAQPage/);-- // Cards ask Wikimedia for the width they draw, not the 1200 px file the city page keeps.-- assert.match(r.text, /srcset="[^"]*\/330px-Tokyo_test\.jpg 330w[^"]*\/640px-Tokyo_test\.jpg 640w"/, 'cards carry 330/640');++ // Commons may reject new widths for a returned thumbnail; cards use the verified URL.++ assert.match(r.text, /\/1280px-Tokyo_test\.jpg/, 'cards use the Commons imageinfo thumbnail');assert.match(r.text, /width="640" height="360"/, 'the card shape is declared');const cities = await t.get('/cities');assert.strictEqual(cities.status, 200);-- assert.match(cities.text, /sizes="58px"/, 'a city row says the 58 px it draws');-- assert.match(cities.text, /\/160px-Tokyo_test\.jpg 160w[^"]*\/330px-Tokyo_test\.jpg 330w/, 'a city row carries 160/330');++ assert.match(cities.text, /width="58" height="44"/, 'a city row declares its compact image size');++ assert.match(cities.text, /\/1280px-Tokyo_test\.jpg/, 'a city row uses the verified Commons thumbnail');// The FAQPage data asks exactly the questions the page shows (structured data names what is on the page).const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => [].concat(JSON.parse(m[1]))).flatMap((x) => (x['@graph'] ? x['@graph'] : [x]));@@@ -67,7 +67,7 @@ const { seed } = require('./helpers/seed');assert.match(r.text, /"@type":"TouristDestination"/);assert.match(r.text, /"@type":"TouristAttraction"/);assert.match(r.text, /"@type":"TouristTrip"/);-- assert.match(r.text, /href="\/place\/plc_/);++ assert.match(r.text, /href="\/place\/[a-z0-9-]+-plc_/);assert.match(r.text, /href="\/routes\/rte_/);assert.match(r.text, /A flat in Alfama/);assert.match(r.text, /IRL walk in Lisbon/);@@@ -39,7 +39,7 @@ const node = (tags) => ({ type: 'node', id: 9001, lat: 29.56, lon: 106.58, tags:await check('the city page lists it by its English name, with its own name in the line under it', async () => {const r = await t.get(`/city/${city.slug}`);assert.strictEqual(r.status, 200);-- assert.match(r.text, /<strong>Hongya Cave<\/strong><span class="tr-muted">洪崖洞 · attraction/);++ assert.match(r.text, /<h3><a href="[^"]+">Hongya Cave<\/a><\/h3><p class="tr-pcard-meta">洪崖洞 · attraction/);});await check('a row leaves out a kind its name already says, and the facts join without a stray separator', async () => {nnew file mode 100644@@@ -0,0 +1,103 @@++'use strict';++const assert = require('assert');++const fixture = require('./fixtures/seattle-districts.json');++const { boot, check, done } = require('./helpers/boot');++const { parseCityPage } = require('../server/sources/wikivoyage');++const { overpassQuery, placeFromElement, createOverpass } = require('../server/sources/osm');++const { isPlace } = require('../server/places/is-place');++++(async () => {++ await check('recorded Seattle district See and Do listings contain many real places', async () => {++ const names = fixture.pages.flatMap((page) => {++ const parsed = parseCityPage(page.wikitext, page.title, { districts: 0 });++ return [...parsed.see, ...parsed.do].filter((x) => x.hasCoords && isPlace(x.name)).map((x) => x.name);++ });++ assert.ok(fixture.districts.length >= 10);++ assert.ok(names.length >= 24);++ assert.ok(names.includes('Space Needle'));++ assert.ok(!names.includes('Seattle CityPASS'));++ });++ await check('Overpass queries only named destinations and protects wikidata-tagged results', async () => {++ const tagged = overpassQuery(47.6, -122.3, 15000, true);++ const ordinary = overpassQuery(47.6, -122.3, 15000);++ for (const query of [tagged, ordinary]) {++ assert.match(query, /\["name"\]/);++ assert.match(query, /museum\|gallery\|viewpoint/);++ assert.match(query, /park\|garden\|nature_reserve/);++ assert.doesNotMatch(query, /tourism"\]/);++ }++ assert.match(tagged, /\["wikidata"\]/);++ assert.ok(ordinary.indexOf('["wikidata"]') < ordinary.lastIndexOf('out center tags 400'));++ const calls = [];++ const fetchImpl = async (_url, opts) => {++ const query = new URLSearchParams(opts.body).get('data');++ calls.push(query);++ const elements = [{ type: 'node', id: 1, lat: 47.6, lon: -122.3, tags: { name: 'Space Needle', tourism: 'attraction', wikidata: 'Q1' } },++ { type: 'node', id: 1, lat: 47.6, lon: -122.3, tags: { name: 'Space Needle', tourism: 'attraction', wikidata: 'Q1' } },++ { type: 'node', id: 2, lat: 47.61, lon: -122.31, tags: { name: 'Seattle CityPASS', tourism: 'attraction' } }];++ return { ok: true, status: 200, text: async () => JSON.stringify({ elements }) };++ };++ const osm = createOverpass({ config: { upstreams: { overpass: 'https://example.test/interpreter', timeoutMs: 1000 },++ openData: { userAgent: 'Tours test', radiusM: 15000 } }, fetchImpl });++ const result = await osm.tourism(47.6, -122.3);++ assert.strictEqual(result.elements.length, 2);++ assert.strictEqual(calls.length, 1);++ assert.match(calls[0], /\["wikidata"\]/);++ assert.ok(!placeFromElement(result.elements[1], 'cty_1', result.fetchedAt));++ assert.ok(!placeFromElement({ type: 'node', id: 3, lat: 47.6, lon: -122.3,++ tags: { name: 'Visitor centre', tourism: 'attraction', shop: 'yes' } }, 'cty_1', result.fetchedAt));++ assert.ok(!isPlace('A scenic place', { instanceLabel: 'travel agency' }));++ assert.ok(!isPlace('Seattle Pass'));++ assert.ok(!isPlace('Seattle Tours'));++ });++ const t = await boot();++ try {++ t.upstreams.state.nominatim.seattle = [{ name: 'Seattle', type: 'city', addresstype: 'city', lat: '47.6062', lon: '-122.3321',++ address: { city: 'Seattle', country: 'United States', country_code: 'us' }, extratags: { wikidata: 'Q5083' } }];++ t.upstreams.state.pages.Seattle += fixture.districts.map((x) => `[[${x}|district]]`).join(' ');++ for (const page of fixture.pages.slice(1)) t.upstreams.state.pages[page.title] = page.wikitext;++ t.upstreams.state.notable = [++ { item: { value: 'http://www.wikidata.org/entity/Q9991' }, label: { value: 'Seattle CityPASS' },++ coordinate: { value: 'Point(-122.3 47.6)' }, sitelinks: { value: '100' }, instanceLabel: { value: 'tourist attraction' } },++ { item: { value: 'http://www.wikidata.org/entity/Q9992' }, label: { value: 'New Seattle Viewpoint' },++ coordinate: { value: 'Point(-122.33 47.62)' }, sitelinks: { value: '50' }, instanceLabel: { value: 'scenic viewpoint' } },++ { item: { value: 'http://www.wikidata.org/entity/Q9993' }, label: { value: 'Seattle Excursions' },++ coordinate: { value: 'Point(-122.34 47.63)' }, sitelinks: { value: '40' }, instanceLabel: { value: 'travel agency' } },++ ];++ await check('on-demand city follows every district, caches one Wikidata query, and lists at least 24 places', async () => {++ const city = await t.ctx.discover.ensureCity('Seattle');++ const places = await t.ctx.store.cityPlaces(city.id, { limit: 200 });++ assert.ok(places.length >= 24, String(places.length));++ assert.ok(places.some((p) => p.name === 'New Seattle Viewpoint'));++ assert.ok(!places.some((p) => p.name === 'Seattle CityPASS'));++ assert.ok(!places.some((p) => p.name === 'Seattle Excursions'));++ assert.strictEqual(t.upstreams.state.requests.filter((r) => r.path === '/sparql').length, 1);++ const queryRequest = t.upstreams.state.requests.find((r) => r.path === '/sparql');++ const query = new URLSearchParams(queryRequest.query.slice(1)).get('query');++ assert.match(query, /wdt:P131\+ wd:Q5083/);++ assert.match(query, /wdt:P31 \?kind/);++ assert.match(queryRequest.headers['user-agent'], /OpenVibeTours/);++ assert.strictEqual(t.upstreams.state.requests.filter((r) => r.path === '/w/api.php' && r.query.includes('action=parse')).length,++ 1 + fixture.districts.length);++ await t.ctx.jobs.places.populateCity(city);++ assert.strictEqual(t.upstreams.state.requests.filter((r) => r.path === '/sparql').length, 1);++ });++ await check('an existing thin Seattle city page refreshes missing district data', async () => {++ const city = await t.ctx.store.cityBySlug('seattle');++ await t.ctx.s.db.exec('DELETE FROM tours_places WHERE city_id=$1', [city.id]);++ await t.ctx.s.db.exec('DELETE FROM tours_wikidata_city_places WHERE city_id=$1', [city.id]);++ await t.ctx.store.upsertCitySource(city.id, {++ wikivoyageTitle: 'Seattle', wikitext: JSON.stringify({ pages: [{ title: 'Seattle',++ wikitext: t.upstreams.state.pages.Seattle, sourceUrl: 'https://en.wikivoyage.org/wiki/Seattle' }] }),++ sourceUrl: 'https://en.wikivoyage.org/wiki/Seattle', fetchedAt: new Date().toISOString(),++ });++ const before = t.upstreams.state.requests.filter((r) => r.path === '/w/api.php' && r.query.includes('action=parse')).length;++ const page = await t.get('/city/seattle');++ assert.strictEqual(page.status, 200);++ assert.ok((await t.ctx.store.cityPlaces(city.id, { limit: 200 })).length >= 24);++ assert.strictEqual(t.upstreams.state.requests.filter((r) => r.path === '/w/api.php' && r.query.includes('action=parse')).length - before,++ fixture.districts.length);++ });++ } finally { await t.close(); }++ done();++})().catch((err) => { process.stdout.write(String(err.stack || err)); process.exit(1); });@@@ -19,11 +19,11 @@ const imageWidth = new Function(`${iw[0]}; return imageWidth;`)();// Cards ask for the width they draw on both URL shapes Wikimedia's imageinfo returns, and on a// stored original; no other host is rewritten.const thumbUrl = 'https://thumb.wikimedia.org/wikipedia/commons/thumb/a/ab/Foo.jpg/1280px-Foo.jpg?utm_source=commons.wikimedia.org';--assert.strictEqual(imageWidth(thumbUrl, 330), 'https://thumb.wikimedia.org/wikipedia/commons/thumb/a/ab/Foo.jpg/330px-Foo.jpg?utm_source=commons.wikimedia.org', 'a thumb URL changes its width');--assert.strictEqual(imageWidth('https://upload.wikimedia.org/wikipedia/commons/5/51/Foo.png?utm_source=x', 640), 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/51/Foo.png/640px-Foo.png?utm_source=x', 'a stored original becomes a thumb');++assert.strictEqual(imageWidth(thumbUrl, 330), null, 'a Commons thumbnail keeps the verified imageinfo URL');++assert.strictEqual(imageWidth('https://upload.wikimedia.org/wikipedia/commons/5/51/Foo.png?utm_source=x', 640), null, 'an unscaled original keeps its URL because Wikimedia may reject an enlarged thumb');assert.strictEqual(imageWidth('https://commons.wikimedia.org/wiki/Special:FilePath/Foo.jpg?width=1000', 330), 'https://commons.wikimedia.org/wiki/Special:FilePath/Foo.jpg?width=330', 'Special:FilePath takes a width');assert.strictEqual(imageWidth('https://openvibe.media/t/x.jpg', 330), null, 'a non-Wikimedia picture is not rewritten');--assert.strictEqual(imageWidth('https://upload.wikimedia.org/wikipedia/commons/a/ab/Foo.jpg', 330), 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Foo.jpg/330px-Foo.jpg', 'a two-letter hash prefix');++assert.strictEqual(imageWidth('https://upload.wikimedia.org/wikipedia/commons/a/ab/Foo.jpg', 330), null, 'an original with a two-letter hash prefix keeps its URL');assert.strictEqual(imageWidth(null, 330), null);assert.strictEqual(safeImage('https://photos.app.goo.gl/A7PbTNW1RbBbZUrTA'), null, 'a share link is not a picture');
@@ -40,8 +40,9 @@ OpenVibe.Tours is the travel room. V1 has six things, all from open data or the
@@ -40,8 +40,9 @@ OpenVibe.Tours is the travel room. V1 has six things, all from open data or the
## Does not own
## Does not own
-- **Live, Rent and AI** — OpenVibe.Live, OpenVibe.Rent and OpenVibe.AI. Tours reads their public APIs and never
- writes to them. It never uses a service token.
+- **Live, Rent, Media and AI** — Tours reads public Live/Rent pages. Licensed place images are copied to Media with
+ the `media.object.upload` service scope; sourced place summaries use AI with `ai.run.create ai.run.read` when
+ `OV_AI_INTERNAL_URL` is configured. Both keep the original source and attribution on the place page.
- **Identity and roles** — OpenVibe.Network. Tours is OAuth client `tours` (PKCE S256) and verifies session tokens
- **Identity and roles** — OpenVibe.Network. Tours is OAuth client `tours` (PKCE S256) and verifies session tokens
against the Network JWKS; a person's canonical subject is `usr_…`.
against the Network JWKS; a person's canonical subject is `usr_…`.
- **OpenStreetMap, Wikivoyage, Wikidata, Commons** — external open data, cached in Tours' own tables with the source
- **OpenStreetMap, Wikivoyage, Wikidata, Commons** — external open data, cached in Tours' own tables with the source
@@ -55,12 +56,13 @@ OpenVibe.Tours is the travel room. V1 has six things, all from open data or the
@@ -55,12 +56,13 @@ OpenVibe.Tours is the travel room. V1 has six things, all from open data or the
| OpenStreetMap via Overpass | places (`tourism=*`) | ODbL 1.0; contact User-Agent, per-host pacing, one bounded `around:` query, no bulk download | OSM object URL, fetched time |
| OpenStreetMap via Overpass | places (`tourism=*`) | ODbL 1.0; contact User-Agent, per-host pacing, one bounded `around:` query, no bulk download | OSM object URL, fetched time |
| Wikivoyage | city summary, See listings, route stops | CC BY-SA 4.0; the page URL on every city and stop | page URL, fetched time |
| Wikivoyage | city summary, See listings, route stops | CC BY-SA 4.0; the page URL on every city and stop | page URL, fetched time |
| Wikidata | coordinates, population, country, English Wikivoyage sitelink | CC0 | Q id, fetched time |
| Wikidata | coordinates, population, country, English Wikivoyage sitelink | CC0 | Q id, fetched time |
-| Wikimedia Commons | city image + licence/credit | each image's own licence (usually CC BY-SA) | credit, licence, licence URL |
+| Wikimedia Commons and Openverse | place and city images with licence/credit | CC BY, CC BY-SA, CC0 or public domain; no NC/ND | source URL, credit, licence, Media thumbnail |
| OpenVibe.Live | live IRL streams, VODs, clips | public API; read-only | item URL, fetched time |
| OpenVibe.Live | live IRL streams, VODs, clips | public API; read-only | item URL, fetched time |
| OpenVibe.Rent | stays | public listings; read-only | listing URL, fetched time |
| OpenVibe.Rent | stays | public listings; read-only | listing URL, fetched time |
-OpenVibe.AI is **not** used: its public API has chat, generate, summarize, classify, extract, embed, image and
-research, but no speech synthesis. The route schema has `audio_status`/`audio_url` ready for the day it does.
+OpenVibe.AI may summarize the cited place description for “Why go”; the page labels this as an AI summary and links
+the source. If the AI call is unavailable, the sourced description remains. AI has no speech synthesis route; route
+audio remains off.
## Jobs
## Jobs
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+-- phase: expand
+-- Source checked images and cached city name resolutions.
+ALTER TABLE tours_places ADD COLUMN image_license_url text;
+ALTER TABLE tours_places ADD COLUMN image_source_url text;
+ALTER TABLE tours_places ADD COLUMN gallery jsonb NOT NULL DEFAULT '[]'::jsonb;
+ALTER TABLE tours_places ADD COLUMN rank_score integer NOT NULL DEFAULT 0;
+ALTER TABLE tours_places ADD COLUMN guide_features text;
+ALTER TABLE tours_places ADD COLUMN guide_visit text;
+ALTER TABLE tours_places ADD COLUMN guide_season text;
+ALTER TABLE tours_places DROP CONSTRAINT tours_places_source;
+ALTER TABLE tours_places ADD CONSTRAINT tours_places_source CHECK (source IN ('openstreetmap', 'wikivoyage', 'wikidata', 'wikipedia'));
+ALTER TABLE tours_cities ADD COLUMN image_source_url text;
+ALTER TABLE tours_cities ADD COLUMN osm_attempted_at timestamptz;
+ALTER TABLE tours_live ADD COLUMN place_id text COLLATE "C" REFERENCES tours_places(id) ON DELETE SET NULL;
+CREATE INDEX tours_live_place ON tours_live (place_id, fetched_at DESC) WHERE place_id IS NOT NULL;
+CREATE TABLE tours_city_lookup (
+ query text COLLATE "C" PRIMARY KEY,
+ city_id text COLLATE "C" REFERENCES tours_cities(id) ON DELETE CASCADE,
+ expires_at timestamptz NOT NULL
+);
+CREATE TABLE tours_photo_assets (
+ source_url text COLLATE "C" PRIMARY KEY,
+ media_id text COLLATE "C" NOT NULL,
+ media_url text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE TABLE tours_pageview_cache (
+ wikipedia_title text COLLATE "C" PRIMARY KEY,
+ views integer NOT NULL,
+ fetched_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE TABLE tours_place_summaries (
+ place_id text COLLATE "C" PRIMARY KEY REFERENCES tours_places(id) ON DELETE CASCADE,
+ source_fetched_at timestamptz NOT NULL,
+ summary text NOT NULL,
+ run_id text COLLATE "C" NOT NULL,
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+-- phase: expand
+ALTER TABLE tours_places ADD COLUMN guide_why text;
+ALTER TABLE tours_places DROP CONSTRAINT tours_places_osm_type;
+ALTER TABLE tours_places ADD CONSTRAINT tours_places_osm_type CHECK (osm_type IN ('node', 'way', 'relation', 'wikivoyage', 'wikidata'));
+CREATE TABLE tours_wikidata_city_places (
+ city_id text COLLATE "C" PRIMARY KEY REFERENCES tours_cities(id) ON DELETE CASCADE,
+ fetched_at timestamptz NOT NULL DEFAULT now(),
+ payload jsonb NOT NULL
+);
@@ -66,13 +66,19 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -66,13 +66,19 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
/* City / place / route lists */
/* City / place / route lists */
.tr-city-list, .tr-place-list, .tr-route-list, .tr-live-list, .tr-stay-list, .tr-trip-list, .tr-trip-places { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
.tr-city-list, .tr-place-list, .tr-route-list, .tr-live-list, .tr-stay-list, .tr-trip-list, .tr-trip-places { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
-.tr-city-row a, .tr-place-row a, .tr-route-list a, .tr-live-list a, .tr-stay-list a, .tr-trip-list a { display: flex; gap: 12px; align-items: center; padding: 10px 12px; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 12px; background: var(--bg-secondary, #101828); color: inherit; text-decoration: none; }
-.tr-city-row a:hover, .tr-place-row a:hover, .tr-route-list a:hover, .tr-live-list a:hover, .tr-stay-list a:hover, .tr-trip-list a:hover { border-color: var(--room-accent, #F98942); }
+.tr-city-row a, .tr-place-row > a, .tr-route-list a, .tr-live-list a, .tr-stay-list a, .tr-trip-list a { display: flex; gap: 12px; align-items: center; padding: 10px 12px; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 12px; background: var(--bg-secondary, #101828); color: inherit; text-decoration: none; }
+.tr-city-row a:hover, .tr-place-row > a:hover, .tr-route-list a:hover, .tr-live-list a:hover, .tr-stay-list a:hover, .tr-trip-list a:hover { border-color: var(--room-accent, #F98942); }
+.tr-place-list, .tr-place-row { min-width: 0; }
+.tr-place-row figure { margin: 0; min-width: 0; }
+.tr-place-row figure img { display: block; width: 100%; max-height: 230px; object-fit: cover; border-radius: 12px 12px 0 0; }
+.tr-place-row figcaption { font-size: .78rem; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; padding: 6px 0 8px; }
.tr-city-photo { width: 58px; height: 44px; border-radius: 8px; overflow: hidden; flex: none; display: grid; place-items: center; background: var(--bg-primary, #0a0f1c); color: var(--room-accent, #F98942); }
.tr-city-photo { width: 58px; height: 44px; border-radius: 8px; overflow: hidden; flex: none; display: grid; place-items: center; background: var(--bg-primary, #0a0f1c); color: var(--room-accent, #F98942); }
.tr-city-photo img { width: 100%; height: 100%; object-fit: cover; }
.tr-city-photo img { width: 100%; height: 100%; object-fit: cover; }
.tr-city-text, .tr-place-row span, .tr-route-list a, .tr-live-list a, .tr-stay-list a { display: grid; gap: 2px; }
.tr-city-text, .tr-place-row span, .tr-route-list a, .tr-live-list a, .tr-stay-list a { display: grid; gap: 2px; }
.tr-city-text strong, .tr-place-row strong, .tr-route-list strong, .tr-live-list strong, .tr-stay-list strong { font-size: 1rem; }
.tr-city-text strong, .tr-place-row strong, .tr-route-list strong, .tr-live-list strong, .tr-stay-list strong { font-size: 1rem; }
.tr-city-img, .tr-place-img { margin: 16px 0 0; }
.tr-city-img, .tr-place-img { margin: 16px 0 0; }
+.tr-gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 14px; }
+.tr-gallery .tr-place-img:first-child { grid-column: 1 / -1; }
.tr-city-img img, .tr-place-img img { width: 100%; max-height: 380px; object-fit: cover; border-radius: 16px; }
.tr-city-img img, .tr-place-img img { width: 100%; max-height: 380px; object-fit: cover; border-radius: 16px; }
.tr-city-img figcaption, .tr-place-img figcaption { font-size: .78rem; color: var(--text-muted, #7386a3); margin-top: 6px; }
.tr-city-img figcaption, .tr-place-img figcaption { font-size: .78rem; color: var(--text-muted, #7386a3); margin-top: 6px; }
.tr-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 10px; margin: 18px 0 0; }
.tr-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 10px; margin: 18px 0 0; }
@@ -155,3 +161,95 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -155,3 +161,95 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td[data-label]:not(:first-child)::before { content: attr(data-label); display: block; margin-bottom: 2px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td[data-label]:not(:first-child)::before { content: attr(data-label); display: block; margin-bottom: 2px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:empty { display: none; }
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:empty { display: none; }
}
}
+
+/* ── City and place pages (Opus design pass, 2026-11-08): a guide people browse with their thumbs ── */
+.tr-city-hero { padding-top: clamp(14px, 3vw, 28px); }
+.tr-city-cover { margin: 0 0 18px; }
+.tr-city-cover img { display: block; width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 18px; background: var(--bg-secondary, #111a2e); }
+.tr-city-cover figcaption, .tr-hero-shot figcaption, .tr-shot figcaption { margin-top: 6px; font-size: .74rem; line-height: 1.45; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }
+.tr-city-intro h1 { margin: 0; }
+.tr-city-intro .tr-lede { max-width: 72ch; }
+.tr-seg { display: inline-flex; gap: 4px; margin: 18px 0 2px; padding: 4px; border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 999px; }
+.tr-seg a { padding: 8px 16px; border-radius: 999px; font-weight: 700; font-size: .92rem; text-decoration: none; color: var(--text-secondary, #96a7c2); min-height: 40px; display: inline-flex; align-items: center; }
+.tr-seg a[aria-current="page"] { background: var(--room-accent, #F98942); color: var(--room-on, #0a0f1c); }
+.tr-count { display: inline-block; margin-left: 6px; padding: 1px 9px; border-radius: 999px; font-size: .8rem; font-weight: 700; vertical-align: 3px; color: var(--text-secondary, #96a7c2); background: var(--bg-secondary, rgba(255,255,255,.06)); }
+.tr-pill { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: .72rem; font-weight: 800; letter-spacing: .02em; text-transform: none; color: #0b3d1f; background: #7ee2a8; vertical-align: 1px; }
+.tr-pgrid { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 156px), 1fr)); }
+.tr-pcard { position: relative; display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 14px; overflow: hidden; background: var(--bg-card, #0f1729); transition: border-color .15s ease, transform .15s ease; }
+.tr-pcard:hover { border-color: var(--room-accent, #F98942); }
+.tr-pcard:active { transform: scale(.995); }
+.tr-pcard-media { aspect-ratio: 4 / 3; background: var(--bg-secondary, #111a2e); }
+.tr-pcard-media img { display: block; width: 100%; height: 100%; object-fit: cover; }
+.tr-pcard-pin { display: grid; place-content: center; justify-items: center; gap: 6px; color: var(--room-strong, #F98942); background: color-mix(in srgb, var(--room-accent, #F98942) 9%, var(--bg-card, #0f1729)); font-size: .78rem; font-weight: 700; text-transform: capitalize; }
+.tr-pcard-body { padding: 10px 12px 8px; display: grid; gap: 4px; align-content: start; }
+.tr-pcard h3 { margin: 0; font-size: .98rem; line-height: 1.3; font-weight: 750; overflow-wrap: anywhere; }
+.tr-pcard h3 a { color: var(--text-primary, #e6edf7); text-decoration: none; }
+.tr-pcard h3 a::after { content: ""; position: absolute; inset: 0; }
+.tr-pcard h3 a:focus-visible { outline: none; }
+.tr-pcard:focus-within { outline: 3px solid var(--room-accent, #F98942); outline-offset: 2px; }
+.tr-pcard-meta { margin: 0; font-size: .8rem; line-height: 1.4; color: var(--text-muted, #7386a3); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
+.tr-pcard-body .tr-pill { justify-self: start; }
+.tr-pcard-credit { position: relative; z-index: 1; margin: auto 0 0; padding: 0 12px 10px; font-size: .68rem; line-height: 1.4; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }
+.tr-pgrid-small { grid-template-columns: repeat(auto-fill, minmax(min(100%, 150px), 1fr)); }
+
+.tr-place-top { display: grid; gap: 18px; padding-top: clamp(10px, 2vw, 22px); }
+.tr-place-head h1 { margin: 0; }
+.tr-place-head .tr-kicker .tr-pill { margin-left: 6px; }
+.tr-place-actions { margin-top: 16px; }
+.tr-place-media { min-width: 0; }
+.tr-hero-shot { margin: 0; }
+.tr-hero-shot img { display: block; width: 100%; height: auto; aspect-ratio: 16 / 10; object-fit: cover; border-radius: 16px; background: var(--bg-secondary, #111a2e); }
+.tr-rail-wrap { margin-top: 12px; }
+.tr-rail-count { margin: 0 0 6px; font-size: .8rem; font-weight: 700; color: var(--text-secondary, #96a7c2); }
+.tr-rail { list-style: none; margin: 0; padding: 0 0 6px; display: flex; gap: 10px; overflow-x: auto; scroll-snap-type: x mandatory; overscroll-behavior-x: contain; }
+.tr-rail li { flex: 0 0 min(72%, 260px); scroll-snap-align: start; min-width: 0; }
+.tr-shot { margin: 0; }
+.tr-shot img { display: block; width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; border-radius: 12px; background: var(--bg-secondary, #111a2e); }
+.tr-quick { list-style: none; margin: 22px 0 0; padding: 0; display: grid; gap: 8px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 156px), 1fr)); }
+.tr-quick li { min-width: 0; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 12px; background: var(--bg-card, #0f1729); }
+.tr-quick li > strong, .tr-quick li > a { display: grid; gap: 2px; padding: 10px 12px; }
+.tr-quick li > strong + span { display: block; padding: 0 12px 10px; margin-top: -8px; }
+.tr-quick strong { font-size: .92rem; line-height: 1.3; color: var(--text-primary, #e6edf7); overflow-wrap: anywhere; }
+.tr-quick span { font-size: .76rem; line-height: 1.35; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }
+.tr-quick a { text-decoration: none; }
+.tr-quick a strong { color: var(--room-strong, var(--accent-light, #60a5fa)); }
+.tr-quick li:has(a):hover { border-color: var(--room-accent, #F98942); }
+.tr-guide { max-width: 760px; }
+.tr-guide p { line-height: 1.65; }
+.tr-band { padding: clamp(16px, 3vw, 24px); border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, rgba(255,255,255,.08)); }
+.tr-band h2 { margin-bottom: 4px; }
+.tr-remind { margin-top: 12px; }
+.tr-sources { margin-top: clamp(32px, 5vw, 48px); padding-top: 14px; border-top: 1px solid var(--border, rgba(255,255,255,.1)); font-size: .84rem; color: var(--text-muted, #7386a3); }
+.tr-sources h2 { font-size: .8rem; letter-spacing: .08em; text-transform: uppercase; margin: 0 0 6px; color: var(--text-secondary, #96a7c2); }
+.tr-sources p { margin: 4px 0; line-height: 1.5; overflow-wrap: anywhere; }
+@media (min-width: 720px) {
+ .tr-city-cover img { aspect-ratio: 21 / 9; }
+ .tr-pgrid { gap: 16px; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); }
+ .tr-place-top { grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); align-items: start; gap: 32px; }
+ .tr-place-head { position: sticky; top: 76px; }
+ .tr-rail li { flex-basis: 220px; }
+}
+.tr-pcard-text .tr-pcard-body { padding-top: 12px; }
+.tr-pcard-text .tr-pcard-pin { color: var(--room-strong, #F98942); }
+.tr-fold { margin-top: 12px; }
+.tr-fold > summary { cursor: pointer; display: inline-flex; align-items: center; min-height: 44px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.14)); font-weight: 700; list-style: none; }
+.tr-fold > summary::-webkit-details-marker { display: none; }
+.tr-fold > summary::after { content: ""; width: 7px; height: 7px; margin-left: 10px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: translateY(-2px) rotate(45deg); }
+.tr-fold[open] > summary { margin-bottom: 12px; }
+.tr-fold[open] > summary::after { transform: translateY(2px) rotate(-135deg); }
+.tr-together { margin-top: 18px; }
+.tr-together-h { margin: 0 0 8px; font-size: .78rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }
+.tr-together .tr-actions { margin: 0; }
+.tr-together .tr-fine { margin: 8px 0 0; }
+@media (min-width: 720px) { .tr-place-top-solo { grid-template-columns: minmax(0, 1fr); } .tr-place-top-solo .tr-place-head { position: static; max-width: 760px; } }
+.tr-pcard-body > .tr-pcard-pin { display: block; background: none; padding: 0; font-size: inherit; }
+.tr-pgrid { align-items: start; }
+.tr-place-top .tr-quick { margin-top: 4px; }
+@media (min-width: 720px) {
+ .tr-place-top { grid-template-areas: "head media" "quick media"; grid-template-rows: auto 1fr; }
+ .tr-place-top > .tr-place-head { grid-area: head; position: static; }
+ .tr-place-top > .tr-place-media { grid-area: media; }
+ .tr-place-top > .tr-quick { grid-area: quick; align-self: start; }
+ .tr-place-top-solo { grid-template-areas: "head" "quick"; grid-template-rows: auto auto; }
+}
+.tr-pcard-media { overflow: hidden; }
@@ -34,6 +34,8 @@ const { assetVersion, send } = require('./render/layout');
@@ -34,6 +34,8 @@ const { assetVersion, send } = require('./render/layout');
const { raw } = require('./render/html');
const { raw } = require('./render/html');
const { createStore: dataStore } = require('./store');
const { createStore: dataStore } = require('./store');
const { createJobs } = require('./jobs');
const { createJobs } = require('./jobs');
+const { createDiscover } = require('./places/discover');
+const { createNarrative } = require('./places/narrative');
const { createSearchIndex } = require('./search-index');
const { createSearchIndex } = require('./search-index');
const accountDataLib = require('./identity/account-data');
const accountDataLib = require('./identity/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
@@ -57,6 +59,8 @@ async function createApp(opts = {}) {
@@ -57,6 +59,8 @@ async function createApp(opts = {}) {
const sharing = opts.sharing || createSharing({ db: s.db, config, fetchImpl, log });
const sharing = opts.sharing || createSharing({ db: s.db, config, fetchImpl, log });
const store = dataStore(s, { config, sharing });
const store = dataStore(s, { config, sharing });
const jobs = createJobs({ config, s, store, fetchImpl, log });
const jobs = createJobs({ config, s, store, fetchImpl, log });
+ const discover = createDiscover({ config, s, store, jobs, fetchImpl, log });
+ const narrative = createNarrative({ config, s, fetchImpl, log });
// The places, cities and walks in OpenVibe.Search (started by server/index.js, never under TOURS_DRILL).
// The places, cities and walks in OpenVibe.Search (started by server/index.js, never under TOURS_DRILL).
const searchIndex = createSearchIndex({ config, s, log });
const searchIndex = createSearchIndex({ config, s, log });
@@ -65,7 +69,7 @@ async function createApp(opts = {}) {
@@ -65,7 +69,7 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Tours cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Tours cannot answer account events'); });
- const ctx = { config, s, keys, sso, principal, store, jobs, searchIndex, accountData, accountSend, sharing, log };
+ const ctx = { config, s, keys, sso, principal, store, jobs, discover, narrative, searchIndex, accountData, accountSend, sharing, log };
const app = express();
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
@@ -27,6 +27,7 @@ function load(env = process.env) {
@@ -27,6 +27,7 @@ function load(env = process.env) {
nodeEnv,
nodeEnv,
isProduction,
isProduction,
baseUrl,
baseUrl,
+ indexNow: { key: String(env.INDEXNOW_KEY || '').trim() },
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
drill: bool(env.TOURS_DRILL),
drill: bool(env.TOURS_DRILL),
limits: {
limits: {
@@ -38,6 +39,8 @@ function load(env = process.env) {
@@ -38,6 +39,8 @@ function load(env = process.env) {
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:tours:' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:tours:' },
networkUrl,
networkUrl,
+ media: { url: trim(env.OV_MEDIA_URL || 'https://openvibe.media'), scope: 'media.object.upload' },
+ ai: { url: trim(env.OV_AI_INTERNAL_URL || ''), scope: 'ai.run.create ai.run.read' },
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
audience: env.TOURS_AUDIENCE || 'openvibe.tours',
audience: env.TOURS_AUDIENCE || 'openvibe.tours',
@@ -68,9 +71,12 @@ function load(env = process.env) {
@@ -68,9 +71,12 @@ function load(env = process.env) {
rentPublic: trim(env.OV_RENT_PUBLIC_URL || 'https://openvibe.rent'),
rentPublic: trim(env.OV_RENT_PUBLIC_URL || 'https://openvibe.rent'),
wikivoyage: trim(env.OV_WIKIVOYAGE_URL || 'https://en.wikivoyage.org'),
wikivoyage: trim(env.OV_WIKIVOYAGE_URL || 'https://en.wikivoyage.org'),
wikidata: trim(env.OV_WIKIDATA_URL || 'https://www.wikidata.org'),
wikidata: trim(env.OV_WIKIDATA_URL || 'https://www.wikidata.org'),
+ wikidataQuery: trim(env.OV_WIKIDATA_QUERY_URL || 'https://query.wikidata.org/sparql'),
wikipedia: trim(env.OV_WIKIPEDIA_URL || 'https://en.wikipedia.org'),
wikipedia: trim(env.OV_WIKIPEDIA_URL || 'https://en.wikipedia.org'),
commons: trim(env.OV_COMMONS_URL || 'https://commons.wikimedia.org'),
commons: trim(env.OV_COMMONS_URL || 'https://commons.wikimedia.org'),
+ openverse: trim(env.OV_OPENVERSE_URL || 'https://api.openverse.org'),
overpass: trim(env.OV_OVERPASS_URL || 'https://overpass-api.de/api/interpreter'),
overpass: trim(env.OV_OVERPASS_URL || 'https://overpass-api.de/api/interpreter'),
+ nominatim: trim(env.OV_NOMINATIM_URL || 'https://nominatim.openstreetmap.org'),
timeoutMs: Math.max(1000, int(env.TOURS_UPSTREAM_TIMEOUT_MS, 60_000)),
timeoutMs: Math.max(1000, int(env.TOURS_UPSTREAM_TIMEOUT_MS, 60_000)),
},
},
@@ -80,11 +86,11 @@ function load(env = process.env) {
@@ -80,11 +86,11 @@ function load(env = process.env) {
email: String(env.TOURS_OSM_EMAIL || '').trim(),
email: String(env.TOURS_OSM_EMAIL || '').trim(),
minIntervalMs: Math.max(0, int(env.TOURS_MIN_INTERVAL_MS, 1100)),
minIntervalMs: Math.max(0, int(env.TOURS_MIN_INTERVAL_MS, 1100)),
retryAfterMs: Math.max(0, int(env.TOURS_RETRY_AFTER_MS, 120_000)),
retryAfterMs: Math.max(0, int(env.TOURS_RETRY_AFTER_MS, 120_000)),
- radiusM: Math.min(10_000, Math.max(300, int(env.TOURS_OSM_RADIUS_M, 3500))),
+ radiusM: Math.min(25_000, Math.max(300, int(env.TOURS_OSM_RADIUS_M, 15_000))),
maxPerCity: Math.min(200, Math.max(1, int(env.TOURS_OSM_MAX_PER_CITY, 80))),
maxPerCity: Math.min(200, Math.max(1, int(env.TOURS_OSM_MAX_PER_CITY, 80))),
osmCitiesPerRun: Math.min(100, Math.max(0, int(env.TOURS_OSM_CITIES_PER_RUN, 10))),
osmCitiesPerRun: Math.min(100, Math.max(0, int(env.TOURS_OSM_CITIES_PER_RUN, 10))),
cityLimit: Math.min(200, Math.max(1, int(env.TOURS_CITY_LIMIT, 100))),
cityLimit: Math.min(200, Math.max(1, int(env.TOURS_CITY_LIMIT, 100))),
- districtPages: Math.min(4, Math.max(0, int(env.TOURS_WIKIVOYAGE_DISTRICTS, 2))),
+ districtPages: Math.min(40, Math.max(0, int(env.TOURS_WIKIVOYAGE_DISTRICTS, 40))),
},
},
jobs: {
jobs: {
@@ -31,7 +31,7 @@ const { sameOrigin } = require('./principal');
@@ -31,7 +31,7 @@ const { sameOrigin } = require('./principal');
const { document: openapi } = require('../openapi');
const { document: openapi } = require('../openapi');
function createApi(ctx) {
function createApi(ctx) {
- const { config, s, principal, store } = ctx;
+ const { config, s, principal, store, discover } = ctx;
const r = asyncRouter();
const r = asyncRouter();
r.use(express.json({ limit: '64kb' }));
r.use(express.json({ limit: '64kb' }));
@@ -84,13 +84,27 @@ function createApi(ctx) {
@@ -84,13 +84,27 @@ function createApi(ctx) {
if (!Number.isFinite(limit) || limit < 1 || limit > 200) return invalid(res, 'limit must be from 1 to 200', 'limit');
if (!Number.isFinite(limit) || limit < 1 || limit > 200) return invalid(res, 'limit must be from 1 to 200', 'limit');
if (!Number.isFinite(offset) || offset < 0) return invalid(res, 'offset must be 0 or more', 'offset');
if (!Number.isFinite(offset) || offset < 0) return invalid(res, 'offset must be 0 or more', 'offset');
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 80) : null;
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 80) : null;
- const cities = await store.listCities({ limit, offset, q });
+ let cities = await store.listCities({ limit, offset, q });
+ if (q && !cities.length && offset === 0) {
+ try {
+ await discover.ensureCity(q);
+ cities = await store.listCities({ limit, offset, q });
+ } catch (err) { ctx.log.warn(`[Tours city API lookup] ${err.message}`); }
+ }
okPublic(res);
okPublic(res);
return res.json({ count: cities.length, cities });
return res.json({ count: cities.length, cities });
});
});
r.get('/cities/:slug', async (req, res) => {
r.get('/cities/:slug', async (req, res) => {
- const city = await store.cityBySlug(String(req.params.slug || ''));
+ let city = await store.cityBySlug(String(req.params.slug || ''));
+ if (!city) {
+ try { city = await discover.ensureCity(String(req.params.slug || '').replace(/-/g, ' ')); }
+ catch (err) { ctx.log.warn(`[Tours city API lookup] ${err.message}`); }
+ }
+ else if (city.place_count < 24) {
+ try { city = await discover.ensureCity(city.name); }
+ catch (err) { ctx.log.warn(`[Tours city API refresh] ${err.message}`); }
+ }
if (!city) return problem(res, 404, 'tours.city_not_found', 'No such city.');
if (!city) return problem(res, 404, 'tours.city_not_found', 'No such city.');
const [places, routes, live, stays] = await Promise.all([
const [places, routes, live, stays] = await Promise.all([
store.cityPlaces(city.id, { limit: 100 }), store.cityRoutes(city.id), store.cityLive(city.id), store.cityStays(city.id),
store.cityPlaces(city.id, { limit: 100 }), store.cityRoutes(city.id), store.cityLive(city.id), store.cityStays(city.id),
@@ -99,8 +113,19 @@ function createApi(ctx) {
@@ -99,8 +113,19 @@ function createApi(ctx) {
return res.json({ city, places, routes, live, stays });
return res.json({ city, places, routes, live, stays });
});
});
+ r.get('/places', async (req, res) => {
+ const q = typeof req.query.q === 'string' ? req.query.q.trim().slice(0, 120) : '';
+ if (!q) return invalid(res, 'q is required', 'q');
+ let place = null;
+ try { place = await discover.ensurePlace(q); }
+ catch (err) { ctx.log.warn(`[Tours place API lookup] ${err.message}`); }
+ if (!place) return problem(res, 404, 'tours.place_not_found', 'No such place.');
+ okPublic(res);
+ return res.json({ place });
+ });
+
r.get('/places/:id', async (req, res) => {
r.get('/places/:id', async (req, res) => {
- const place = await store.placeById(String(req.params.id || ''));
+ const place = await discover.enrichPlace(await store.placeById(String(req.params.id || '')));
if (!place) return problem(res, 404, 'tours.place_not_found', 'No such place.');
if (!place) return problem(res, 404, 'tours.place_not_found', 'No such place.');
const routes = await store.routesForPlace(place.id);
const routes = await store.routesForPlace(place.id);
okPublic(res);
okPublic(res);
@@ -10,6 +10,8 @@ const path = require('path');
@@ -10,6 +10,8 @@ const path = require('path');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
+const { placePath } = require('../store');
+const { createIndexNow } = require('../index-now');
const SITE_NAME = 'OpenVibe.Tours';
const SITE_NAME = 'OpenVibe.Tours';
const DESCRIPTION = 'OpenVibe.Tours is go anywhere: fly, stay, walk, watch. Cities, places and self-guided routes from OpenStreetMap, Wikivoyage and Wikidata; IRL tours live on OpenVibe.Live; stays from OpenVibe.Rent; flights through the official airline-neutral search; and trips a person shares with the people going with them.';
const DESCRIPTION = 'OpenVibe.Tours is go anywhere: fly, stay, walk, watch. Cities, places and self-guided routes from OpenStreetMap, Wikivoyage and Wikidata; IRL tours live on OpenVibe.Live; stays from OpenVibe.Rent; flights through the official airline-neutral search; and trips a person shares with the people going with them.';
@@ -51,6 +53,9 @@ function createDiscoveryRoutes(ctx) {
@@ -51,6 +53,9 @@ function createDiscoveryRoutes(ctx) {
const TEXT = cache.htmlHeaders({ maxAge: 3600 });
const TEXT = cache.htmlHeaders({ maxAge: 3600 });
const pages = publicPages();
const pages = publicPages();
+ const indexNow = createIndexNow({ baseUrl: config.baseUrl, key: config.indexNow?.key });
+ if (indexNow.enabled) r.use(indexNow.keyFile);
+
r.get('/robots.txt', (_req, res) => {
r.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(
res.type('text/plain').set('Cache-Control', TEXT).send(
'# openvibe.tours: public cities, places and routes are for search engines; sign-in, the API and trips are not.\n'
'# openvibe.tours: public cities, places and routes are for search engines; sign-in, the API and trips are not.\n'
@@ -104,7 +109,7 @@ function createDiscoveryRoutes(ctx) {
@@ -104,7 +109,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) },
{ title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) },
{ title: 'Cities', pages: cities.map((c) => ({ url: `/city/${c.slug}`, title: c.name, text: [c.country, c.summary].filter(Boolean).join(' · ') })) },
{ title: 'Cities', pages: cities.map((c) => ({ url: `/city/${c.slug}`, title: c.name, text: [c.country, c.summary].filter(Boolean).join(' · ') })) },
{ title: 'Routes', pages: routes.map((r0) => ({ url: `/routes/${r0.id}`, title: 'Self-guided route', text: 'Generated from a Wikivoyage See list.' })) },
{ title: 'Routes', pages: routes.map((r0) => ({ url: `/routes/${r0.id}`, title: 'Self-guided route', text: 'Generated from a Wikivoyage See list.' })) },
- { title: 'Places', pages: places.map((p) => ({ url: `/place/${p.id}`, title: 'Place', text: 'From OpenStreetMap, ODbL.' })) },
+ { title: 'Places', pages: places.map((p) => ({ url: placePath(p), title: p.name, text: 'From OpenStreetMap or Wikivoyage.' })) },
],
],
}));
}));
});
});
@@ -117,8 +122,9 @@ function createDiscoveryRoutes(ctx) {
@@ -117,8 +122,9 @@ function createDiscoveryRoutes(ctx) {
const urls = [
const urls = [
...pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority })),
...pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority })),
...cities.map((e) => ({ loc: abs(`/city/${e.slug}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'weekly', priority: 0.8 })),
...cities.map((e) => ({ loc: abs(`/city/${e.slug}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'weekly', priority: 0.8 })),
+ ...cities.map((e) => ({ loc: abs(`/city/${e.slug}/free`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'weekly', priority: 0.6 })),
...routes.map((e) => ({ loc: abs(`/routes/${e.id}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'weekly', priority: 0.6 })),
...routes.map((e) => ({ loc: abs(`/routes/${e.id}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'weekly', priority: 0.6 })),
- ...places.map((e) => ({ loc: abs(`/place/${e.id}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'monthly', priority: 0.4 })),
+ ...places.map((e) => ({ loc: abs(placePath(e)), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'monthly', priority: 0.4 })),
];
];
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
});
});
@@ -14,6 +14,7 @@ const { raw } = require('../render/html');
@@ -14,6 +14,7 @@ const { raw } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const views = require('../views');
const views = require('../views');
const { createFlights } = require('../flights');
const { createFlights } = require('../flights');
+const { placePath } = require('../store');
const PUBLIC_CACHE = 'public, max-age=60';
const PUBLIC_CACHE = 'public, max-age=60';
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
@@ -21,12 +22,15 @@ const ovServe = require('openvibe-shared/serve');
@@ -21,12 +22,15 @@ const ovServe = require('openvibe-shared/serve');
const SHARE_HEAD = `<link rel="stylesheet" href="${ovServe.url('ov-share.css')}"><script src="${ovServe.url('ov-share.js')}" defer></script>`;
const SHARE_HEAD = `<link rel="stylesheet" href="${ovServe.url('ov-share.css')}"><script src="${ovServe.url('ov-share.js')}" defer></script>`;
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
- const { config, store } = ctx;
+ const { config, store, discover, jobs } = ctx;
const r = asyncRouter();
const r = asyncRouter();
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
const base = () => String(config.baseUrl).replace(/\/+$/, '');
const base = () => String(config.baseUrl).replace(/\/+$/, '');
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
+ const imageLd = (p) => views.safeImage(p.image_url) && p.image_credit && p.image_license && p.image_source_url
+ ? { '@type': 'ImageObject', contentUrl: p.image_url, url: p.image_source_url,
+ creditText: p.image_credit, license: p.image_license_url || p.image_license } : undefined;
const flights = createFlights({ config });
const flights = createFlights({ config });
const notFromHere = (req, res) => page(req, res, {
const notFromHere = (req, res) => page(req, res, {
@@ -67,35 +71,53 @@ function createPageRoutes(ctx) {
@@ -67,35 +71,53 @@ function createPageRoutes(ctx) {
// ── Cities ───────────────────────────────────────────────
// ── Cities ───────────────────────────────────────────────
r.get('/cities', async (req, res) => {
r.get('/cities', async (req, res) => {
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 80) : '';
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 80) : '';
- const cities = await store.listCities({ limit: 100, q: q || null });
+ let cities = await store.listCities({ limit: 100, q: q || null });
+ if (q && !cities.length) {
+ try {
+ const city = await discover.ensureCity(q);
+ if (city) return res.redirect(303, `/city/${encodeURIComponent(city.slug)}`);
+ } catch (err) { ctx.log.warn(`[Tours city lookup] ${err.message}`); }
+ }
return page(req, res, {
return page(req, res, {
index: true, cache: q ? null : PUBLIC_CACHE, title: 'Cities',
index: true, cache: q ? null : PUBLIC_CACHE, title: 'Cities',
- description: 'The top 100 cities from Wikidata and Wikivoyage, with places from OpenStreetMap, self-guided routes, live tours and stays.',
+ description: 'Find things to do in any city, with places from OpenStreetMap and Wikivoyage.',
jsonLd: [seo.jsonLd.webPage({ name: 'Cities', url: `${base()}/cities`, description: 'The top 100 cities from open data.', siteUrl: base() })],
jsonLd: [seo.jsonLd.webPage({ name: 'Cities', url: `${base()}/cities`, description: 'The top 100 cities from open data.', siteUrl: base() })],
body: raw(views.citiesPage({ cities, q })),
body: raw(views.citiesPage({ cities, q })),
});
});
});
});
r.get('/city/:slug', async (req, res) => {
r.get('/city/:slug', async (req, res) => {
- const city = await store.cityBySlug(String(req.params.slug || ''));
+ let city = await store.cityBySlug(String(req.params.slug || ''));
+ if (!city) {
+ try { city = await discover.ensureCity(String(req.params.slug || '').replace(/-/g, ' ')); }
+ catch (err) { ctx.log.warn(`[Tours city lookup] ${err.message}`); }
+ }
+ else if (city.place_count < 24) {
+ try { city = await discover.ensureCity(city.name); }
+ catch (err) { ctx.log.warn(`[Tours city refresh] ${err.message}`); }
+ }
if (!city) return page(req, res, { index: false, title: 'Not found', body: raw(views.notFound()) }, 404);
if (!city) return page(req, res, { index: false, title: 'Not found', body: raw(views.notFound()) }, 404);
+ if (city.slug !== req.params.slug) return res.redirect(301, `/city/${encodeURIComponent(city.slug)}`);
+ if (!city.osm_fetched_at && (!city.osm_attempted_at || Date.now() - new Date(city.osm_attempted_at).getTime() > 15 * 60_000)) {
+ try { await jobs.places.populateCity(city); } catch (err) { ctx.log.warn(`[Tours city places] ${err.message}`); }
+ }
const [places, routes, live, stays] = await Promise.all([
const [places, routes, live, stays] = await Promise.all([
store.cityPlaces(city.id, { limit: 60 }), store.cityRoutes(city.id), store.cityLive(city.id), store.cityStays(city.id),
store.cityPlaces(city.id, { limit: 60 }), store.cityRoutes(city.id), store.cityLive(city.id), store.cityStays(city.id),
]);
]);
const attractions = places.slice(0, 10).map((p) => ({
const attractions = places.slice(0, 10).map((p) => ({
- '@type': 'TouristAttraction', name: p.name, url: `${base()}/place/${p.id}`,
+ '@type': 'TouristAttraction', name: p.name, url: `${base()}${placePath(p)}`,
geo: { '@type': 'GeoCoordinates', latitude: p.lat, longitude: p.lon },
geo: { '@type': 'GeoCoordinates', latitude: p.lat, longitude: p.lon },
- ...(p.description ? { description: p.description } : {}), ...(views.safeImage(p.image_url) ? { image: p.image_url } : {}),
+ ...(p.description ? { description: p.description } : {}), ...(imageLd(p) ? { image: imageLd(p) } : {}),
}));
}));
const tripLd = routes.slice(0, 3).map((rt) => ({
const tripLd = routes.slice(0, 3).map((rt) => ({
'@type': 'TouristTrip', name: rt.title, url: `${base()}/routes/${rt.id}`, description: rt.summary || undefined,
'@type': 'TouristTrip', name: rt.title, url: `${base()}/routes/${rt.id}`, description: rt.summary || undefined,
itinerary: { '@type': 'ItemList', numberOfItems: rt.stop_count },
itinerary: { '@type': 'ItemList', numberOfItems: rt.stop_count },
}));
}));
return page(req, res, {
return page(req, res, {
- index: true, cache: PUBLIC_CACHE, title: city.name,
+ index: true, cache: PUBLIC_CACHE, title: `Things to do in ${city.name}`,
description: city.summary ? String(city.summary).slice(0, 180) : `${city.name}: places, self-guided routes, live tours and stays.`,
description: city.summary ? String(city.summary).slice(0, 180) : `${city.name}: places, self-guided routes, live tours and stays.`,
jsonLd: [
jsonLd: [
- { '@context': 'https://schema.org', '@type': 'TouristDestination', name: city.name, url: `${base()}/city/${city.slug}`, description: city.summary || undefined, geo: { '@type': 'GeoCoordinates', latitude: city.lat, longitude: city.lon }, touristType: 'Independent', ...(city.image_url ? { image: city.image_url } : {}) },
+ { '@context': 'https://schema.org', '@type': 'TouristDestination', name: city.name, url: `${base()}/city/${city.slug}`, description: city.summary || undefined, geo: { '@type': 'GeoCoordinates', latitude: city.lat, longitude: city.lon }, touristType: 'Independent', ...(imageLd(city) ? { image: imageLd(city) } : {}) },
...(attractions.length ? [{ '@context': 'https://schema.org', '@type': 'ItemList', name: `Places in ${city.name}`, numberOfItems: attractions.length, itemListElement: attractions.map((a, i) => ({ '@type': 'ListItem', position: i + 1, item: a })) }] : []),
...(attractions.length ? [{ '@context': 'https://schema.org', '@type': 'ItemList', name: `Places in ${city.name}`, numberOfItems: attractions.length, itemListElement: attractions.map((a, i) => ({ '@type': 'ListItem', position: i + 1, item: a })) }] : []),
...tripLd,
...tripLd,
seo.jsonLd.breadcrumbs([{ name: 'Cities', url: `${base()}/cities` }, { name: city.name, url: `${base()}/city/${city.slug}` }]),
seo.jsonLd.breadcrumbs([{ name: 'Cities', url: `${base()}/cities` }, { name: city.name, url: `${base()}/city/${city.slug}` }]),
@@ -104,31 +126,72 @@ function createPageRoutes(ctx) {
@@ -104,31 +126,72 @@ function createPageRoutes(ctx) {
});
});
});
});
+ r.get('/city/:slug/free', async (req, res) => {
+ let city = await store.cityBySlug(String(req.params.slug || ''));
+ if (city && city.place_count < 24) {
+ try { city = await discover.ensureCity(city.name); }
+ catch (err) { ctx.log.warn(`[Tours city refresh] ${err.message}`); }
+ }
+ if (!city) return page(req, res, { index: false, title: 'Not found', body: raw(views.notFound()) }, 404);
+ const places = (await store.cityPlaces(city.id, { limit: 200 })).filter((p) => p.fee === 'no');
+ return page(req, res, {
+ index: true, cache: PUBLIC_CACHE, title: `Free things to do in ${city.name}`,
+ description: `Places in ${city.name} that OpenStreetMap marks as free to visit.`,
+ jsonLd: [seo.jsonLd.itemList(`Free things to do in ${city.name}`, places.map((p) => ({ name: p.name, url: `${base()}${placePath(p)}` })), { url: `${base()}/city/${city.slug}/free` })],
+ body: raw(views.cityPage({ city, places, free: true })),
+ });
+ });
+
+ r.get('/places', async (req, res) => {
+ const q = typeof req.query.q === 'string' ? req.query.q.trim().slice(0, 120) : '';
+ if (!q) return res.redirect(303, '/cities');
+ try {
+ const place = await discover.ensurePlace(q);
+ if (place) return res.redirect(303, placePath(place));
+ } catch (err) { ctx.log.warn(`[Tours place lookup] ${err.message}`); }
+ return page(req, res, { index: false, title: 'Place not found', body: raw(views.notFound()) }, 404);
+ });
+
+ r.get('/remind-place/:id', async (req, res) => {
+ const place = await store.placeById(String(req.params.id || ''));
+ const date = String(req.query.date || '');
+ if (!place || !/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(`${date}T18:00:00Z`))
+ || Date.parse(`${date}T18:00:00Z`) <= Date.now()) return page(req, res, { index: false, title: 'Choose a future date', body: raw(views.notFound()) }, 400);
+ const params = new URLSearchParams({
+ title: views.placeName(place), at: `${date}T18:00:00Z`, url: `${base()}${placePath(place)}`, from: 'openvibe.tours',
+ });
+ return res.redirect(303, `https://openvibe.today/remind?${params}`);
+ });
+
// ── Places ───────────────────────────────────────────────
// ── Places ───────────────────────────────────────────────
r.get('/place/:id', async (req, res) => {
r.get('/place/:id', async (req, res) => {
- const place = await store.placeById(String(req.params.id || ''));
+ const placeId = String(req.params.id || '').match(/plc_[0-9A-HJKMNP-TV-Z]{26}$/)?.[0];
+ const place = placeId ? await discover.enrichPlace(await store.placeById(placeId)) : null;
if (!place) return page(req, res, { index: false, title: 'Not found', body: raw(views.notFound()) }, 404);
if (!place) return page(req, res, { index: false, title: 'Not found', body: raw(views.notFound()) }, 404);
- const [routeList, trips] = await Promise.all([
+ const [routeList, trips, nearby, summary, live] = await Promise.all([
store.routesForPlace(place.id),
store.routesForPlace(place.id),
signedIn(req) ? store.tripsForSubject(req.viewer.subject) : Promise.resolve([]),
signedIn(req) ? store.tripsForSubject(req.viewer.subject) : Promise.resolve([]),
+ store.cityPlaces(place.city_id, { limit: 20 }),
+ ctx.narrative.forPlace(place),
+ store.placeLive(place.id),
]);
]);
return page(req, res, {
return page(req, res, {
- index: true, cache: PUBLIC_CACHE, title: views.localName(place) ? `${views.placeName(place)} (${place.name})` : place.name,
+ index: true, cache: PUBLIC_CACHE, path: placePath(place), title: views.localName(place) ? `${views.placeName(place)} (${place.name})` : place.name,
description: place.description || `${views.placeName(place)} in ${place.city_name}: coordinates, address, opening hours, source and map.`,
description: place.description || `${views.placeName(place)} in ${place.city_name}: coordinates, address, opening hours, source and map.`,
jsonLd: [
jsonLd: [
{
{
'@context': 'https://schema.org', '@type': 'TouristAttraction', name: views.placeName(place),
'@context': 'https://schema.org', '@type': 'TouristAttraction', name: views.placeName(place),
...(views.localName(place) ? { alternateName: place.name } : {}),
...(views.localName(place) ? { alternateName: place.name } : {}),
- url: `${base()}/place/${place.id}`, description: place.description || undefined,
+ url: `${base()}${placePath(place)}`, description: place.description || undefined,
geo: { '@type': 'GeoCoordinates', latitude: place.lat, longitude: place.lon },
geo: { '@type': 'GeoCoordinates', latitude: place.lat, longitude: place.lon },
...(place.address ? { address: { '@type': 'PostalAddress', streetAddress: place.address, addressLocality: place.city_name } } : {}),
...(place.address ? { address: { '@type': 'PostalAddress', streetAddress: place.address, addressLocality: place.city_name } } : {}),
- ...(views.safeImage(place.image_url) ? { image: place.image_url } : {}),
+ ...(imageLd(place) ? { image: imageLd(place) } : {}),
...(place.fee === 'no' ? { isAccessibleForFree: true } : place.fee === 'yes' ? { isAccessibleForFree: false } : {}),
...(place.fee === 'no' ? { isAccessibleForFree: true } : place.fee === 'yes' ? { isAccessibleForFree: false } : {}),
sameAs: [place.osm_url],
sameAs: [place.osm_url],
},
},
- seo.jsonLd.breadcrumbs([{ name: 'Cities', url: `${base()}/cities` }, { name: place.city_name, url: `${base()}/city/${place.city_slug}` }, { name: views.placeName(place), url: `${base()}/place/${place.id}` }]),
+ seo.jsonLd.breadcrumbs([{ name: 'Cities', url: `${base()}/cities` }, { name: place.city_name, url: `${base()}/city/${place.city_slug}` }, { name: views.placeName(place), url: `${base()}${placePath(place)}` }]),
],
],
- body: raw(views.placePage({ place, routeList, trips })),
+ body: raw(views.placePage({ place, routeList, trips, summary, live, nearby: nearby.filter((p) => p.id !== place.id).slice(0, 6) })),
});
});
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+'use strict';
+
+const { createIndexNow: sharedIndexNow } = require('openvibe-shared/indexnow');
+
+function createIndexNow({ baseUrl, key, fetchImpl, log } = {}) {
+ const client = sharedIndexNow({ host: baseUrl, key, fetch: fetchImpl, log });
+ const origin = String(baseUrl || 'https://openvibe.tours').replace(/\/+$/, '');
+ function add(path) {
+ if (!/^\/(?:city\/[a-z0-9-]+(?:\/free)?|place\/[a-z0-9-]+-plc_[0-9A-HJKMNP-TV-Z]{26})$/.test(path)) return;
+ client.pingSoon(`${origin}${path}`);
+ }
+ return { ...client, add };
+}
+
+module.exports = { createIndexNow };
@@ -13,6 +13,7 @@ const fs = require('fs');
@@ -13,6 +13,7 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const { createWikivoyage } = require('../sources/wikivoyage-client');
const { createWikivoyage } = require('../sources/wikivoyage-client');
const { createWikidata } = require('../sources/wikidata');
const { createWikidata } = require('../sources/wikidata');
+const { createPhotoCache } = require('../places/photo-cache');
const { parseCityPage, pageUrl } = require('../sources/wikivoyage');
const { parseCityPage, pageUrl } = require('../sources/wikivoyage');
const { slugify } = require('../store');
const { slugify } = require('../store');
@@ -32,11 +33,12 @@ function fileTitle(name) {
@@ -32,11 +33,12 @@ function fileTitle(name) {
// A run under older rules is not fresh (jobs/index.js): credits-1 also cleans picture credits (and {{unesco}}, rail badges); inline-templates-2 kept
// A run under older rules is not fresh (jobs/index.js): credits-1 also cleans picture credits (and {{unesco}}, rail badges); inline-templates-2 kept
// {{convert}}, the unit templates ({{km2}}), {{lang}} and the like in the summaries, so the first boot with it rewrites them.
// {{convert}}, the unit templates ({{km2}}), {{lang}} and the like in the summaries, so the first boot with it rewrites them.
-const CITIES_VERSION = 'credits-1';
+const CITIES_VERSION = 'district-see-do-2';
function createCitiesJob({ config, s, store, fetchImpl, log = console }) {
function createCitiesJob({ config, s, store, fetchImpl, log = console }) {
const wikivoyage = createWikivoyage({ config, fetchImpl, pace: s.pace });
const wikivoyage = createWikivoyage({ config, fetchImpl, pace: s.pace });
const wikidata = createWikidata({ config, fetchImpl, pace: s.pace, log });
const wikidata = createWikidata({ config, fetchImpl, pace: s.pace, log });
+ const photoCache = createPhotoCache({ config, s, fetchImpl, log });
async function runOnce({ limit = config.openData.cityLimit } = {}) {
async function runOnce({ limit = config.openData.cityLimit } = {}) {
const catalog = loadCatalog(limit);
const catalog = loadCatalog(limit);
@@ -56,7 +58,7 @@ function createCitiesJob({ config, s, store, fetchImpl, log = console }) {
@@ -56,7 +58,7 @@ function createCitiesJob({ config, s, store, fetchImpl, log = console }) {
const entity = entities.get(resolved.qid);
const entity = entities.get(resolved.qid);
if (!entity || entity.lat == null || entity.lon == null) { result.skipped.push(entry.name); continue; }
if (!entity || entity.lat == null || entity.lon == null) { result.skipped.push(entry.name); continue; }
const country = countries.get(entity.countryQid) || { label: entry.country, code: null };
const country = countries.get(entity.countryQid) || { label: entry.country, code: null };
- const image = images.get(fileTitle(entity.image));
+ const image = await photoCache.cache(images.get(fileTitle(entity.image)));
const title = entry.wikivoyage || entity.wikivoyageTitle || entry.name;
const title = entry.wikivoyage || entity.wikivoyageTitle || entry.name;
const name = entity.label || entry.name;
const name = entity.label || entry.name;
const city = {
const city = {
@@ -74,6 +76,7 @@ function createCitiesJob({ config, s, store, fetchImpl, log = console }) {
@@ -74,6 +76,7 @@ function createCitiesJob({ config, s, store, fetchImpl, log = console }) {
imageCredit: image ? image.credit : null,
imageCredit: image ? image.credit : null,
imageLicense: image ? image.license : null,
imageLicense: image ? image.license : null,
imageLicenseUrl: image ? image.licenseUrl : null,
imageLicenseUrl: image ? image.licenseUrl : null,
+ imageSourceUrl: image ? image.sourceUrl : null,
source: 'wikidata',
source: 'wikidata',
sourceUrl: `https://www.wikidata.org/wiki/${entity.qid}`,
sourceUrl: `https://www.wikidata.org/wiki/${entity.qid}`,
fetchedAt: entity.fetchedAt || s.now(),
fetchedAt: entity.fetchedAt || s.now(),
@@ -96,14 +99,14 @@ function createCitiesJob({ config, s, store, fetchImpl, log = console }) {
@@ -96,14 +99,14 @@ function createCitiesJob({ config, s, store, fetchImpl, log = console }) {
if (main) {
if (main) {
const parsed = parseCityPage(main.wikitext, main.title, { districts: config.openData.districtPages });
const parsed = parseCityPage(main.wikitext, main.title, { districts: config.openData.districtPages });
const pages = [{ title: main.title, wikitext: main.wikitext, sourceUrl: main.sourceUrl, fetchedAt: main.fetchedAt }];
const pages = [{ title: main.title, wikitext: main.wikitext, sourceUrl: main.sourceUrl, fetchedAt: main.fetchedAt }];
- let see = parsed.see.map((x) => ({ ...x, pageUrl: main.sourceUrl }));
- if (see.filter((x) => x.hasCoords).length < 4) {
+ let see = [...parsed.see, ...parsed.do].map((x) => ({ ...x, pageUrl: main.sourceUrl }));
+ if (parsed.districts.length) {
for (const district of parsed.districts.slice(0, config.openData.districtPages)) {
for (const district of parsed.districts.slice(0, config.openData.districtPages)) {
try {
try {
const sub = await wikivoyage.page(district);
const sub = await wikivoyage.page(district);
if (!sub) continue;
if (!sub) continue;
const subParsed = parseCityPage(sub.wikitext, sub.title, { districts: 0 });
const subParsed = parseCityPage(sub.wikitext, sub.title, { districts: 0 });
- see = see.concat(subParsed.see.map((x) => ({ ...x, pageUrl: sub.sourceUrl })));
+ see = see.concat([...subParsed.see, ...subParsed.do].map((x) => ({ ...x, pageUrl: sub.sourceUrl })));
pages.push({ title: sub.title, wikitext: sub.wikitext, sourceUrl: sub.sourceUrl, fetchedAt: sub.fetchedAt });
pages.push({ title: sub.title, wikitext: sub.wikitext, sourceUrl: sub.sourceUrl, fetchedAt: sub.fetchedAt });
result.districts += 1;
result.districts += 1;
} catch (err) {
} catch (err) {
@@ -118,7 +118,7 @@ function createJobs({ config, s, store, fetchImpl = globalThis.fetch, log = cons
@@ -118,7 +118,7 @@ function createJobs({ config, s, store, fetchImpl = globalThis.fetch, log = cons
timers.length = 0;
timers.length = 0;
}
}
- return { runOnce, runAll, start, stop, isFresh, jobs };
+ return { runOnce, runAll, start, stop, isFresh, jobs, places: creators.places };
}
}
module.exports = { createJobs };
module.exports = { createJobs };
@@ -19,6 +19,11 @@ function createLiveJob({ config, s, store, fetchImpl, log = console }) {
@@ -19,6 +19,11 @@ function createLiveJob({ config, s, store, fetchImpl, log = console }) {
async function runOnce() {
async function runOnce() {
const cities = await store.listCities({ limit: 200 });
const cities = await store.listCities({ limit: 200 });
const match = cityMatcher(cities);
const match = cityMatcher(cities);
+ async function location(item) {
+ const place = item.placeId ? await store.placeById(item.placeId) : null;
+ const city = place ? { id: place.city_id, name: place.city_name } : match(`${item.title} ${item.tags.join(' ')}`);
+ return { ...item, placeId: place?.id || null, cityId: city?.id || null, cityName: city?.name || null };
+ }
const result = { streams: 0, vods: 0, clips: 0, withCity: 0, errors: [] };
const result = { streams: 0, vods: 0, clips: 0, withCity: 0, errors: [] };
let streams = [];
let streams = [];
let recent = [];
let recent = [];
@@ -31,28 +36,28 @@ function createLiveJob({ config, s, store, fetchImpl, log = console }) {
@@ -31,28 +36,28 @@ function createLiveJob({ config, s, store, fetchImpl, log = console }) {
if (!result.errors.some((e) => e.startsWith('streams:'))) {
if (!result.errors.some((e) => e.startsWith('streams:'))) {
await store.clearLiveStreams();
await store.clearLiveStreams();
for (const item of streams) {
for (const item of streams) {
- const city = match(`${item.title} ${item.tags.join(' ')}`);
- await store.upsertLive({ ...item, cityId: city ? city.id : null, cityName: city ? city.name : null });
+ const located = await location(item);
+ await store.upsertLive(located);
result.streams += 1;
result.streams += 1;
- if (city) result.withCity += 1;
+ if (located.cityId) result.withCity += 1;
}
}
}
}
const recordingsOk = !result.errors.some((e) => e.startsWith('recent:')) && !result.errors.some((e) => e.startsWith('clips:'));
const recordingsOk = !result.errors.some((e) => e.startsWith('recent:')) && !result.errors.some((e) => e.startsWith('clips:'));
if (recordingsOk) await store.clearLiveRecordings();
if (recordingsOk) await store.clearLiveRecordings();
if (!result.errors.some((e) => e.startsWith('recent:'))) {
if (!result.errors.some((e) => e.startsWith('recent:'))) {
for (const item of recent) {
for (const item of recent) {
- const city = match(`${item.title} ${item.tags.join(' ')}`);
- await store.upsertLive({ ...item, cityId: city ? city.id : null, cityName: city ? city.name : null });
+ const located = await location(item);
+ await store.upsertLive(located);
result.vods += 1;
result.vods += 1;
- if (city) result.withCity += 1;
+ if (located.cityId) result.withCity += 1;
}
}
}
}
if (!result.errors.some((e) => e.startsWith('clips:'))) {
if (!result.errors.some((e) => e.startsWith('clips:'))) {
for (const item of clips) {
for (const item of clips) {
- const city = match(`${item.title} `);
- await store.upsertLive({ ...item, cityId: city ? city.id : null, cityName: city ? city.name : null });
+ const located = await location(item);
+ await store.upsertLive(located);
result.clips += 1;
result.clips += 1;
- if (city) result.withCity += 1;
+ if (located.cityId) result.withCity += 1;
}
}
}
}
await store.trimLiveVods(200);
await store.trimLiveVods(200);
@@ -3,20 +3,32 @@
@@ -3,20 +3,32 @@
/**
/**
* The places job, in two passes:
* The places job, in two passes:
*
*
- * 1. Wikivoyage: every city's stored See listings with coordinates become a cited place (CC BY-SA) unless an
+ * 1. Wikivoyage: every city's stored See and Do listings with coordinates become a cited place (CC BY-SA) unless an
* OpenStreetMap place of the same name or within 120 m is already stored. This makes every city populated
* OpenStreetMap place of the same name or within 120 m is already stored. This makes every city populated
* even when Overpass is queueing.
* even when Overpass is queueing.
- * 2. OpenStreetMap: one bounded `tourism=*` Overpass query for the cities whose OSM data is oldest, limited by
+ * 2. OpenStreetMap: one bounded, named-destination Overpass query for the cities whose OSM data is oldest, limited by
* TOURS_OSM_CITIES_PER_RUN so one run never hammers the public instance. ODbL; every place keeps its
* TOURS_OSM_CITIES_PER_RUN so one run never hammers the public instance. ODbL; every place keeps its
* openstreetmap.org URL and the fetch time. A failure leaves the previous rows and the Wikivoyage fallback.
* openstreetmap.org URL and the fetch time. A failure leaves the previous rows and the Wikivoyage fallback.
*/
*/
const { createOverpass } = require('../sources/osm');
const { createOverpass } = require('../sources/osm');
+const { createWikidata } = require('../sources/wikidata');
+const { createPhotoCache } = require('../places/photo-cache');
+const { createOpenverse } = require('../sources/openverse');
+const { fetchJson } = require('../sources/http');
const { parseCityPage } = require('../sources/wikivoyage');
const { parseCityPage } = require('../sources/wikivoyage');
const { stableId } = require('../store');
const { stableId } = require('../store');
+const { isPlace } = require('../places/is-place');
// A run under older rules is not fresh (jobs/index.js): name-en-1 stored OpenStreetMap's English names; wv-text-1
// A run under older rules is not fresh (jobs/index.js): name-en-1 stored OpenStreetMap's English names; wv-text-1
// rewrites the stored Wikivoyage places' text with the current parser.
// rewrites the stored Wikivoyage places' text with the current parser.
-const PLACES_VERSION = 'wv-text-1';
+const PLACES_VERSION = 'notable-places-2';
+
+function commonsFile(value) {
+ const raw = String(value || '').trim();
+ if (/^(?:File|Image):/i.test(raw)) return `File:${raw.replace(/^(?:File|Image):/i, '')}`;
+ if (/^https:\/\/commons\.wikimedia\.org\/wiki\/File:/i.test(raw)) return `File:${decodeURIComponent(raw.split('/wiki/File:')[1]).replace(/_/g, ' ')}`;
+ return null;
+}
const R = 6_371_000;
const R = 6_371_000;
function distanceM(a, b) {
function distanceM(a, b) {
@@ -27,8 +39,91 @@ function distanceM(a, b) {
@@ -27,8 +39,91 @@ function distanceM(a, b) {
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
}
}
+function rankPlace(place, entity, views = 0) {
+ return (place.source === 'wikivoyage' ? 300 : 0)
+ + Math.min(1000, Number(entity?.sitelinks || 0)) * 20
+ + Math.round(Math.log10(Math.max(0, views) + 1) * 1000)
+ + (place.imageUrl ? 500 : 0) + (place.description ? 200 : 0);
+}
+
+function whyAfterLead(text) {
+ const sentences = String(text || '').match(/[^.!?]+[.!?]+(?:\s|$)/g)?.map((s) => s.trim()) || [];
+ return sentences.slice(1, 4).join(' ').slice(0, 600) || null;
+}
+
function createPlacesJob({ config, s, store, fetchImpl, log = console }) {
function createPlacesJob({ config, s, store, fetchImpl, log = console }) {
const osm = createOverpass({ config, fetchImpl, pace: s.pace });
const osm = createOverpass({ config, fetchImpl, pace: s.pace });
+ const wikidata = createWikidata({ config, fetchImpl, pace: s.pace, log });
+ const photoCache = createPhotoCache({ config, s, fetchImpl, log });
+ const openverse = createOpenverse({ config, fetchImpl, pace: s.pace });
+
+ async function pageviewsFor(entities) {
+ const out = new Map();
+ if (config.drill) return out;
+ const ranked = [...entities.values()].filter((e) => e.wikipediaTitle)
+ .sort((a, b) => b.sitelinks - a.sitelinks).slice(0, 5);
+ const today = new Date();
+ const month = `${new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - 1, 1)).toISOString().slice(0, 7).replace('-', '')}0100`;
+ for (const entity of ranked) {
+ const title = entity.wikipediaTitle;
+ const cached = await s.db.maybe(`SELECT views FROM tours_pageview_cache WHERE wikipedia_title=$1 AND fetched_at > now()-interval '7 days'`, [title]);
+ if (cached) { out.set(entity.qid, Number(cached.views)); continue; }
+ try {
+ const path = encodeURIComponent(title.replace(/ /g, '_'));
+ const { json } = await fetchJson(`https://wikimedia.org/api/rest_v1/metrics/pageviews/per-article/en.wikipedia/all-access/user/${path}/monthly/${month}/${month}`,
+ { fetchImpl, pace: s.pace, userAgent: config.openData.userAgent, timeoutMs: 8000, retries: 0, label: 'Wikimedia pageviews' });
+ const views = Math.max(0, Number(json?.items?.[0]?.views) || 0);
+ out.set(entity.qid, views);
+ await s.db.exec(`INSERT INTO tours_pageview_cache(wikipedia_title, views) VALUES ($1,$2)
+ ON CONFLICT(wikipedia_title) DO UPDATE SET views=EXCLUDED.views, fetched_at=now()`, [title, views]);
+ } catch { /* Sitelinks still provide a stable notability signal. */ }
+ }
+ return out;
+ }
+
+ async function photosFor(places) {
+ const qids = places.map((p) => p.wikidataId).filter(Boolean);
+ const entities = qids.length ? await wikidata.entities(qids) : new Map();
+ const pageviews = await pageviewsFor(entities);
+ const files = places.map((p) => {
+ const p18 = entities.get(p.wikidataId)?.image;
+ return commonsFile(p.imageFile) || (p18 ? commonsFile(p18) || `File:${p18}` : null);
+ });
+ const galleries = new Map();
+ for (let i = 0, searched = 0; i < places.length && searched < 5; i++) {
+ const category = /^Category:/i.test(String(places[i].imageFile || ''))
+ ? places[i].imageFile : entities.get(places[i].wikidataId)?.commonsCategory;
+ if (!category) continue;
+ searched += 1;
+ try {
+ const found = (await wikidata.categoryFiles(category)).slice(0, 3);
+ galleries.set(i, found);
+ if (!files[i]) files[i] = found[0] || null;
+ } catch { /* no verified photo */ }
+ }
+ const images = await wikidata.imageInfo([...files.filter(Boolean), ...[...galleries.values()].flat()]);
+ let fallbacks = 0;
+ return Promise.all(places.map(async (p, i) => {
+ let image = images.get(files[i]) || (galleries.get(i) || []).map((file) => images.get(file)).find(Boolean);
+ if (!image && !config.drill && fallbacks < 3) {
+ fallbacks += 1;
+ try { image = await openverse.image(p.name); } catch { /* no verified photo */ }
+ }
+ const cached = image ? await photoCache.cache(image) : null;
+ const gallery = [];
+ for (const file of galleries.get(i) || []) {
+ const candidate = images.get(file);
+ if (!candidate || candidate.sourceUrl === cached?.sourceUrl) continue;
+ const photo = await photoCache.cache(candidate);
+ if (photo) gallery.push({ url: photo.url, credit: photo.credit, license: photo.license,
+ licenseUrl: photo.licenseUrl, sourceUrl: photo.sourceUrl });
+ }
+ const pictured = cached ? { ...p, imageUrl: cached.url, imageCredit: cached.credit,
+ imageLicense: cached.license, imageLicenseUrl: cached.licenseUrl, imageSourceUrl: cached.sourceUrl, gallery } : p;
+ return { ...pictured, rankScore: Math.max(Number(p.rankScore) || 0,
+ rankPlace(pictured, entities.get(p.wikidataId), pageviews.get(p.wikidataId) || 0)) };
+ }));
+ }
async function seedWikivoyage(city) {
async function seedWikivoyage(city) {
const source = await store.citySource(city.id);
const source = await store.citySource(city.id);
@@ -40,15 +135,17 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {
@@ -40,15 +135,17 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {
else if (parsed && Array.isArray(parsed.pages)) {
else if (parsed && Array.isArray(parsed.pages)) {
for (const page of parsed.pages) {
for (const page of parsed.pages) {
const p = parseCityPage(page.wikitext || '', page.title || '', { districts: 0 });
const p = parseCityPage(page.wikitext || '', page.title || '', { districts: 0 });
- listings = listings.concat(p.see.map((x) => ({ ...x, pageUrl: page.sourceUrl || source.source_url })));
+ listings = listings.concat([...p.see, ...p.do].map((x) => ({ ...x, pageUrl: page.sourceUrl || source.source_url })));
}
}
}
}
const existing = await store.cityPlaces(city.id, { limit: 200 });
const existing = await store.cityPlaces(city.id, { limit: 200 });
let n = 0;
let n = 0;
+ const prepared = await photosFor(listings.filter((x) => x.wikidata).map((x) => ({ wikidataId: x.wikidata })));
+ const photos = new Map(listings.filter((x) => x.wikidata).map((x, i) => [x.wikidata, prepared[i]]));
for (const listing of listings) {
for (const listing of listings) {
if (!listing.hasCoords || !Number.isFinite(Number(listing.lat)) || !Number.isFinite(Number(listing.lon))) continue;
if (!listing.hasCoords || !Number.isFinite(Number(listing.lat)) || !Number.isFinite(Number(listing.lon))) continue;
const name = String(listing.name || '').trim();
const name = String(listing.name || '').trim();
- if (!name) continue;
+ if (!name || !isPlace(name)) continue;
const key = name.toLowerCase();
const key = name.toLowerCase();
const osmId = stableId('wv', `${city.id}:${key}:${listing.lat}:${listing.lon}`);
const osmId = stableId('wv', `${city.id}:${key}:${listing.lat}:${listing.lon}`);
// A Wikivoyage place already stored is written again, so its text follows the parser (a description stored
// A Wikivoyage place already stored is written again, so its text follows the parser (a description stored
@@ -65,11 +162,14 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {
@@ -65,11 +162,14 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {
lat: Number(listing.lat),
lat: Number(listing.lat),
lon: Number(listing.lon),
lon: Number(listing.lon),
description: listing.note || null,
description: listing.note || null,
+ guideWhy: whyAfterLead(listing.note),
wikidataId: listing.wikidata || null,
wikidataId: listing.wikidata || null,
source: 'wikivoyage',
source: 'wikivoyage',
sourceUrl: listing.pageUrl || source.source_url,
sourceUrl: listing.pageUrl || source.source_url,
fetchedAt: source.fetched_at,
fetchedAt: source.fetched_at,
+ ...photos.get(listing.wikidata),
};
};
+ place.rankScore = (place.rankScore || 0) + 300;
await store.upsertPlace(place);
await store.upsertPlace(place);
existing.push({ name, lat: place.lat, lon: place.lon, id: null });
existing.push({ name, lat: place.lat, lon: place.lon, id: null });
n += 1;
n += 1;
@@ -77,6 +177,58 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {
@@ -77,6 +177,58 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {
return n;
return n;
}
}
+ async function seedWikidata(city) {
+ if (!city.wikidata_id) return 0;
+ const cached = await s.db.maybe(`SELECT payload FROM tours_wikidata_city_places
+ WHERE city_id=$1 AND fetched_at > now()-interval '7 days'`, [city.id]);
+ let found = cached?.payload;
+ if (typeof found === 'string') found = JSON.parse(found);
+ if (!Array.isArray(found)) {
+ found = await wikidata.notablePlaces(city.wikidata_id);
+ await s.db.exec(`INSERT INTO tours_wikidata_city_places(city_id,payload) VALUES ($1,$2::jsonb)
+ ON CONFLICT(city_id) DO UPDATE SET payload=EXCLUDED.payload,fetched_at=now()`, [city.id, JSON.stringify(found)]);
+ }
+ const existing = await store.cityPlaces(city.id, { limit: 200 });
+ const candidates = found.filter((x) => !existing.some((p) => p.wikidata_id === x.qid ||
+ p.name.toLowerCase() === x.name.toLowerCase() || distanceM(p, x) <= 120)).map((x) => ({
+ cityId: city.id, osmType: 'wikidata', osmId: x.qid, wikidataId: x.qid,
+ name: x.name, kind: 'attraction', lat: x.lat, lon: x.lon,
+ rankScore: Math.min(1000, x.sitelinks) * 20,
+ source: 'wikidata', sourceUrl: x.sourceUrl, fetchedAt: x.fetchedAt,
+ }));
+ const photographed = await photosFor(candidates);
+ for (const place of photographed) await store.upsertPlace(place);
+ return photographed.length;
+ }
+
+ async function populateCity(city) {
+ const seeded = await seedWikivoyage(city);
+ let notable = 0;
+ try { notable = await seedWikidata(city); }
+ catch (err) { log.warn(`[Tours places] Wikidata ${city.slug}: ${err.message}`); }
+ if (city.osm_fetched_at && Date.now() - new Date(city.osm_fetched_at).getTime() < 7 * 86_400_000) return { seeded, notable, osm: 0 };
+ await store.markCityOsmAttempted(city.id);
+ const { elements, fetchedAt } = await osm.tourism(city.lat, city.lon, config.openData.radiusM);
+ const existing = await store.cityPlaces(city.id, { limit: 200 });
+ const seen = new Set();
+ const candidates = elements.map((el) => osm.placeFromElement(el, city.id, fetchedAt)).filter(Boolean);
+ const photographed = await photosFor(candidates);
+ let count = 0;
+ let ordinaryCount = 0;
+ for (const place of photographed.sort((a, b) => (b.rankScore || 0) - (a.rankScore || 0))) {
+ if (!place.wikidataId && ordinaryCount >= config.openData.maxPerCity) continue;
+ const key = `${place.osmType}:${place.osmId}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ if (existing.some((p) => p.source === 'wikivoyage' && (p.name.toLowerCase() === place.name.toLowerCase() || distanceM(p, place) <= 120))) continue;
+ await store.upsertPlace(place);
+ count += 1;
+ if (!place.wikidataId) ordinaryCount += 1;
+ }
+ await store.markCityOsmFetched(city.id);
+ return { seeded, notable, osm: count };
+ }
+
async function runOnce({ limit = config.openData.cityLimit, osmLimit = config.openData.osmCitiesPerRun } = {}) {
async function runOnce({ limit = config.openData.cityLimit, osmLimit = config.openData.osmCitiesPerRun } = {}) {
const result = { cities: 0, wikivoyagePlaces: 0, places: 0, osmCities: 0, empty: [], errors: [], version: PLACES_VERSION };
const result = { cities: 0, wikivoyagePlaces: 0, places: 0, osmCities: 0, empty: [], errors: [], version: PLACES_VERSION };
@@ -97,22 +249,8 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {
@@ -97,22 +249,8 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {
const osmCities = await store.citiesForOsm(want);
const osmCities = await store.citiesForOsm(want);
for (const [i, city] of osmCities.entries()) {
for (const [i, city] of osmCities.entries()) {
try {
try {
- const { elements, fetchedAt } = await osm.tourism(city.lat, city.lon, config.openData.radiusM);
- const seen = new Set();
- const existing = await store.cityPlaces(city.id, { limit: 200 });
- let n = 0;
- for (const el of elements) {
- if (n >= config.openData.maxPerCity) break;
- const place = osm.placeFromElement(el, city.id, fetchedAt);
- if (!place) continue;
- const key = `${place.osmType}:${place.osmId}`;
- if (seen.has(key)) continue;
- seen.add(key);
- if (existing.some((p) => p.source === 'wikivoyage' && (p.name.toLowerCase() === place.name.toLowerCase() || distanceM(p, place) <= 120))) continue;
- await store.upsertPlace(place);
- n += 1;
- }
- await store.markCityOsmFetched(city.id);
+ const filled = await populateCity(city);
+ const n = filled.osm;
result.places += n;
result.places += n;
result.osmCities += 1;
result.osmCities += 1;
if (!n) result.empty.push(city.slug);
if (!n) result.empty.push(city.slug);
@@ -125,7 +263,7 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {
@@ -125,7 +263,7 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {
return result;
return result;
}
}
- return { runOnce, seedWikivoyage, distanceM, version: PLACES_VERSION };
+ return { runOnce, seedWikivoyage, populateCity, photosFor, distanceM, version: PLACES_VERSION };
}
}
-module.exports = { createPlacesJob, distanceM };
+module.exports = { createPlacesJob, distanceM, rankPlace };
@@ -99,7 +99,7 @@ const document = {
@@ -99,7 +99,7 @@ const document = {
'/api/v1/ping': { get: { summary: 'Liveness', responses: json({ type: 'object', properties: { ok: { type: 'boolean' }, service: { type: 'string' } } }) } },
'/api/v1/ping': { get: { summary: 'Liveness', responses: json({ type: 'object', properties: { ok: { type: 'boolean' }, service: { type: 'string' } } }) } },
'/api/v1/cities': {
'/api/v1/cities': {
get: {
get: {
- summary: 'List the top cities', tags: ['Cities'],
+ summary: 'List cities; a new city name is resolved and cached on demand', tags: ['Cities'],
parameters: [query('limit', { type: 'integer', minimum: 1, maximum: 200, default: 100 }), query('offset', { type: 'integer', minimum: 0, default: 0 }), query('q', { type: 'string', maxLength: 80 })],
parameters: [query('limit', { type: 'integer', minimum: 1, maximum: 200, default: 100 }), query('offset', { type: 'integer', minimum: 0, default: 0 }), query('q', { type: 'string', maxLength: 80 })],
responses: json({ type: 'object', properties: { count: { type: 'integer' }, cities: { type: 'array', items: { $ref: '#/components/schemas/City' } } } }),
responses: json({ type: 'object', properties: { count: { type: 'integer' }, cities: { type: 'array', items: { $ref: '#/components/schemas/City' } } } }),
},
},
@@ -110,6 +110,11 @@ const document = {
@@ -110,6 +110,11 @@ const document = {
responses: { ...json({ type: 'object', properties: { city: { $ref: '#/components/schemas/City' }, places: { type: 'array', items: { $ref: '#/components/schemas/Place' } }, routes: { type: 'array', items: { $ref: '#/components/schemas/Route' } }, live: { type: 'array', items: { $ref: '#/components/schemas/Live' } }, stays: { type: 'array', items: { $ref: '#/components/schemas/Stay' } } } }), '404': { description: 'No such city' } },
responses: { ...json({ type: 'object', properties: { city: { $ref: '#/components/schemas/City' }, places: { type: 'array', items: { $ref: '#/components/schemas/Place' } }, routes: { type: 'array', items: { $ref: '#/components/schemas/Route' } }, live: { type: 'array', items: { $ref: '#/components/schemas/Live' } }, stays: { type: 'array', items: { $ref: '#/components/schemas/Stay' } } } }), '404': { description: 'No such city' } },
},
},
},
},
+ '/api/v1/places': {
+ get: { summary: 'Resolve a place name and return its sourced place page data', tags: ['Places'],
+ parameters: [query('q', { type: 'string', minLength: 3, maxLength: 120 })],
+ responses: { ...json({ type: 'object', properties: { place: { $ref: '#/components/schemas/Place' } } }), '404': { description: 'No such place' } } },
+ },
'/api/v1/places/{id}': {
'/api/v1/places/{id}': {
get: { summary: 'One place', tags: ['Places'], parameters: [pathParam('id', 'plc_01JABCDEFGHJKMNPQRSTVWXYZ0')], responses: { ...json({ type: 'object', properties: { place: { $ref: '#/components/schemas/Place' }, routes: { type: 'array', items: { $ref: '#/components/schemas/Route' } } } }), '404': { description: 'No such place' } } },
get: { summary: 'One place', tags: ['Places'], parameters: [pathParam('id', 'plc_01JABCDEFGHJKMNPQRSTVWXYZ0')], responses: { ...json({ type: 'object', properties: { place: { $ref: '#/components/schemas/Place' }, routes: { type: 'array', items: { $ref: '#/components/schemas/Route' } } } }), '404': { description: 'No such place' } } },
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,167 @@
@@ -0,0 +1,167 @@
+'use strict';
+
+const { fetchJson, createPacer } = require('../sources/http');
+const { createWikidata } = require('../sources/wikidata');
+const { createWikivoyage } = require('../sources/wikivoyage-client');
+const { parseCityPage } = require('../sources/wikivoyage');
+const { placeFromElement } = require('../sources/osm');
+const { slugify } = require('../store');
+const { createPhotoCache } = require('./photo-cache');
+
+function createDiscover({ config, s, store, jobs, fetchImpl = globalThis.fetch, log = console }) {
+ const nominatimPace = createPacer(Math.max(1100, config.openData.minIntervalMs));
+ const wikidata = createWikidata({ config, fetchImpl, pace: s.pace, log });
+ const wikivoyage = createWikivoyage({ config, fetchImpl, pace: s.pace });
+ const photoCache = createPhotoCache({ config, s, fetchImpl, log });
+ const pending = new Map();
+ const keyOf = (q) => String(q || '').trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 120);
+
+ async function search(q, limit = 5) {
+ const url = `${config.upstreams.nominatim}/search?${new URLSearchParams({ q, format: 'jsonv2', addressdetails: '1', extratags: '1', namedetails: '1', limit: String(limit) })}`;
+ const { json } = await fetchJson(url, {
+ fetchImpl, pace: nominatimPace, userAgent: config.openData.userAgent,
+ timeoutMs: Math.min(12_000, config.upstreams.timeoutMs), label: 'Nominatim', retries: 0,
+ });
+ return Array.isArray(json) ? json : [];
+ }
+
+ async function ensureCity(name, { populate = true } = {}) {
+ const key = keyOf(name);
+ if (key.length < 2 || key.length > 80) return null;
+ const existing = await store.cityBySlug(slugify(key));
+ if (existing) {
+ if (!populate) return existing;
+ if (pending.has(`city:${key}`)) return pending.get(`city:${key}`);
+ const task = (async () => {
+ const source = await store.citySource(existing.id);
+ let stored = null;
+ try { stored = JSON.parse(source?.wikitext || 'null'); } catch { /* old source format */ }
+ let pages = Array.isArray(stored?.pages) ? [...stored.pages] : [];
+ if (!pages.length) {
+ const main = await wikivoyage.page(existing.wikivoyage_title || existing.name);
+ if (main) pages = [main];
+ }
+ const districts = pages.length ? parseCityPage(pages[0].wikitext, pages[0].title,
+ { districts: config.openData.districtPages }).districts : [];
+ const known = new Set(pages.map((p) => p.title));
+ const missing = districts.filter((title) => !known.has(title));
+ for (const title of missing) {
+ try {
+ const sub = await wikivoyage.page(title);
+ if (sub) pages.push(sub);
+ } catch (err) { log.warn(`[Tours discover] Wikivoyage ${title}: ${err.message}`); }
+ }
+ if (pages.length && (!source || missing.length)) await store.upsertCitySource(existing.id, {
+ wikivoyageTitle: pages[0].title, wikitext: JSON.stringify({ pages }),
+ sourceUrl: pages[0].sourceUrl, fetchedAt: pages[0].fetchedAt,
+ });
+ const osmFresh = existing.osm_fetched_at && Date.now() - new Date(existing.osm_fetched_at).getTime() < 7 * 86_400_000;
+ if (missing.length || !osmFresh) {
+ try { await jobs.places.populateCity(existing); }
+ catch (err) { log.warn(`[Tours discover] places ${existing.name}: ${err.message}`); }
+ }
+ return store.cityBySlug(existing.slug);
+ })().finally(() => pending.delete(`city:${key}`));
+ pending.set(`city:${key}`, task);
+ return task;
+ }
+ const cached = await s.db.maybe('SELECT city_id FROM tours_city_lookup WHERE query=$1 AND expires_at > now()', [key]);
+ if (cached) return cached.city_id ? store.cityById(cached.city_id) : null;
+ if (pending.has(`city:${key}`)) return pending.get(`city:${key}`);
+ const task = (async () => {
+ const hits = await search(name);
+ const hit = hits.find((h) => ['city', 'town', 'municipality', 'village'].includes(h.addresstype || h.type)) || hits[0];
+ const cityName = hit && (hit.address?.city || hit.address?.town || hit.address?.municipality || hit.address?.village || hit.name);
+ const qid = hit?.extratags?.wikidata || null;
+ const resolved = qid ? { qid } : (await wikidata.resolveQids([cityName || name])).get(cityName || name);
+ if (!hit || !resolved || !/^Q[1-9][0-9]{0,11}$/.test(resolved.qid)) {
+ await s.db.exec(`INSERT INTO tours_city_lookup(query, city_id, expires_at) VALUES ($1,NULL,now()+interval '1 day')
+ ON CONFLICT(query) DO UPDATE SET city_id=NULL, expires_at=EXCLUDED.expires_at`, [key]);
+ return null;
+ }
+ const entity = (await wikidata.entities([resolved.qid])).get(resolved.qid);
+ const nameOut = entity?.label || cityName || String(name).trim();
+ const file = entity?.image ? (/^File:/i.test(entity.image) ? entity.image : `File:${entity.image}`) : null;
+ const image = await photoCache.cache(file ? (await wikidata.imageInfo([file])).get(file) : null);
+ const now = new Date().toISOString();
+ const city = {
+ name: nameOut, slug: slugify(nameOut), country: hit.address?.country || 'Unknown',
+ countryCode: hit.address?.country_code?.toUpperCase() || null, region: hit.address?.state || null,
+ lat: Number(hit.lat), lon: Number(hit.lon), population: entity?.population || null,
+ wikidataId: resolved.qid, wikipediaTitle: nameOut, wikivoyageTitle: entity?.wikivoyageTitle || nameOut,
+ imageUrl: image?.url || null, imageCredit: image?.credit || null, imageLicense: image?.license || null,
+ imageLicenseUrl: image?.licenseUrl || null, imageSourceUrl: image?.sourceUrl || null,
+ source: 'wikidata', sourceUrl: `https://www.wikidata.org/wiki/${resolved.qid}`, fetchedAt: now,
+ };
+ let page = null;
+ try { page = await wikivoyage.page(city.wikivoyageTitle); } catch (err) { log.warn(`[Tours discover] Wikivoyage ${nameOut}: ${err.message}`); }
+ if (page) {
+ city.summary = parseCityPage(page.wikitext, page.title, { districts: 0 }).summary;
+ city.wikivoyageTitle = page.title;
+ }
+ const cityId = await store.upsertCity(city);
+ if (page) {
+ const pages = [page];
+ const districts = parseCityPage(page.wikitext, page.title, { districts: config.openData.districtPages }).districts;
+ for (const district of districts) {
+ try {
+ const sub = await wikivoyage.page(district);
+ if (sub) pages.push(sub);
+ } catch (err) { log.warn(`[Tours discover] Wikivoyage ${district}: ${err.message}`); }
+ }
+ await store.upsertCitySource(cityId, {
+ wikivoyageTitle: page.title, wikitext: JSON.stringify({ pages }),
+ sourceUrl: page.sourceUrl, fetchedAt: page.fetchedAt,
+ });
+ }
+ await s.db.exec(`INSERT INTO tours_city_lookup(query, city_id, expires_at) VALUES ($1,$2,now()+interval '30 days')
+ ON CONFLICT(query) DO UPDATE SET city_id=EXCLUDED.city_id, expires_at=EXCLUDED.expires_at`, [key, cityId]);
+ const stored = await store.cityById(cityId);
+ if (populate) {
+ try { await jobs.places.populateCity(stored); } catch (err) { log.warn(`[Tours discover] places ${nameOut}: ${err.message}`); }
+ }
+ return store.cityById(cityId);
+ })().finally(() => pending.delete(`city:${key}`));
+ pending.set(`city:${key}`, task);
+ return task;
+ }
+
+ async function ensurePlace(name) {
+ const key = keyOf(name);
+ if (key.length < 3 || key.length > 120) return null;
+ if (pending.has(`place:${key}`)) return pending.get(`place:${key}`);
+ const task = (async () => {
+ const hits = await search(name, 3);
+ const hit = hits.find((h) => h.osm_type && h.osm_id && h.name && Number.isFinite(Number(h.lat)) && Number.isFinite(Number(h.lon)));
+ if (!hit) return null;
+ const cityName = hit.address?.city || hit.address?.town || hit.address?.municipality || hit.address?.village;
+ if (!cityName) return null;
+ const city = await ensureCity(cityName, { populate: false });
+ if (!city) return null;
+ const tags = { name: hit.name, ...hit.extratags };
+ if (!tags.tourism && !tags.leisure) tags[['park', 'garden'].includes(hit.type) ? 'leisure' : 'tourism'] = hit.type || 'attraction';
+ const place = placeFromElement({ type: hit.osm_type, id: hit.osm_id, lat: hit.lat, lon: hit.lon, tags }, city.id, new Date().toISOString());
+ if (!place) return null;
+ const [photographed] = await jobs.places.photosFor([place]);
+ const id = await store.upsertPlace(photographed);
+ return enrichPlace(await store.placeById(id));
+ })().finally(() => pending.delete(`place:${key}`));
+ pending.set(`place:${key}`, task);
+ return task;
+ }
+
+ async function enrichPlace(place) {
+ if (!place || place.description || (!place.wikipedia && !place.wikidata_id)) return place;
+ try {
+ const fromTag = String(place.wikipedia || '').match(/^en:(.{2,120})$/)?.[1] || null;
+ const entity = !fromTag && place.wikidata_id ? (await wikidata.entities([place.wikidata_id])).get(place.wikidata_id) : null;
+ const title = fromTag || entity?.wikipediaTitle;
+ const guide = title ? await wikidata.article(title) : null;
+ return guide ? store.updatePlaceGuide(place.id, guide) : place;
+ } catch (err) { log.warn(`[Tours discover] Wikipedia ${place.id}: ${err.message}`); return place; }
+ }
+
+ return { ensureCity, ensurePlace, enrichPlace, search };
+}
+
+module.exports = { createDiscover };
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+'use strict';
+
+// A ticket product, seller or shop is never a destination, even if a source tags it as an attraction.
+function isPlace(name, { shop, office, instanceLabel = '' } = {}) {
+ if (shop || office) return false;
+ const text = `${name || ''} ${instanceLabel}`;
+ return !/\b(?:city\s*pass|citypass|pass(?:es)?|ticket(?:s|ing)?|tours|excursions?|tour\s*(?:operator|company|agency)|travel\s*agency|shop|store|retail(?:er)?)\b/i.test(text);
+}
+
+module.exports = { isPlace };
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+'use strict';
+
+const crypto = require('crypto');
+
+function createNarrative({ config, s, fetchImpl = globalThis.fetch, log = console }) {
+ let ai = null;
+ if (config.ai.url && config.oauth.clientSecret && !config.drill) {
+ const { serviceAuth } = require('openvibe-contracts');
+ const { createAiClient } = require('openvibe-publishing/ai');
+ const tokenClient = serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret, audience: 'openvibe.ai', scope: config.ai.scope, fetchImpl,
+ });
+ ai = createAiClient({ baseUrl: config.ai.url, tokenClient, fetch: fetchImpl, waitMs: 4000, timeoutMs: 6000, pollMs: 500 });
+ }
+
+ async function forPlace(place) {
+ if (!place?.description || !place.source_url) return null;
+ const old = await s.db.maybe('SELECT summary, run_id FROM tours_place_summaries WHERE place_id=$1 AND source_fetched_at=$2::timestamptz', [place.id, place.fetched_at]);
+ if (old) return { text: old.summary, runId: old.run_id, sourceUrl: place.source_url };
+ if (!ai?.enabled) return null;
+ try {
+ const key = crypto.createHash('sha256').update(`${place.id}:${place.fetched_at}:${place.description}`).digest('hex');
+ const r = await ai.run('ai.summarize', {
+ text: `${place.name}: ${place.description}`, max_words: 70, style: 'a short, inviting visitor guide using only the source facts',
+ }, { target: { service: 'tours', type: 'place', id: place.id }, idempotencyKey: `tours-place-${key}` });
+ const summary = String(r.output?.text || '').trim().slice(0, 800);
+ if (!summary || r.synthetic) return null;
+ await s.db.exec(`INSERT INTO tours_place_summaries(place_id, source_fetched_at, summary, run_id)
+ VALUES ($1,$2::timestamptz,$3,$4) ON CONFLICT(place_id) DO UPDATE SET
+ source_fetched_at=EXCLUDED.source_fetched_at, summary=EXCLUDED.summary, run_id=EXCLUDED.run_id, updated_at=now()`,
+ [place.id, place.fetched_at, summary, r.runId]);
+ return { text: summary, runId: r.runId, sourceUrl: place.source_url };
+ } catch (err) { log.warn(`[Tours narrative] ${place.id}: ${err.message}`); return null; }
+ }
+ return { forPlace };
+}
+
+module.exports = { createNarrative };
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+'use strict';
+
+// A checked Commons image is copied once into Media as a public object. The file's Commons page,
+// author and licence stay on the place row; Media serves only the smaller image bytes.
+function createPhotoCache({ config, s, fetchImpl = globalThis.fetch, log = console }) {
+ let objects;
+ function client() {
+ if (!objects) {
+ const { createServiceTokenClient } = require('openvibe-sdk/auth');
+ const { createObjectsClient } = require('openvibe-sdk/media');
+ const tokenClient = createServiceTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret, audience: 'openvibe.media', scope: config.media.scope,
+ });
+ objects = createObjectsClient({ app: 'tours', baseUrl: config.media.url, tokenClient });
+ }
+ return objects;
+ }
+ async function cache(image) {
+ if (!image?.sourceUrl || !image?.url) return image;
+ if (config.drill) return image.provider === 'openverse' ? null : image;
+ if (!config.oauth.clientSecret) return null;
+ const old = await s.db.maybe('SELECT media_url FROM tours_photo_assets WHERE source_url=$1', [image.sourceUrl]);
+ if (old) return { ...image, url: old.media_url };
+ try {
+ const src = new URL(image.url);
+ const commons = /^(?:thumb|upload)\.wikimedia\.org$/.test(src.hostname);
+ const openverse = src.hostname === 'api.openverse.org' && /^\/v1\/images\/[0-9a-f-]{36}\/thumb\/$/i.test(src.pathname);
+ if (src.protocol !== 'https:' || (!commons && !openverse)) return null;
+ if (commons) src.pathname = src.pathname.replace(/\/\d+px-([^/]+)$/, '/640px-$1');
+ const res = await fetchImpl(src.toString(), { headers: { 'User-Agent': config.openData.userAgent }, signal: AbortSignal.timeout(12_000) });
+ const type = String(res.headers.get('content-type') || '').split(';')[0].toLowerCase();
+ const length = Number(res.headers.get('content-length') || 0);
+ if (!res.ok || !/^image\/(?:jpeg|png|webp)$/.test(type) || length > 4_000_000) return null;
+ const bytes = Buffer.from(await res.arrayBuffer());
+ if (bytes.length > 4_000_000) return null;
+ const file = image.sourceUrl.split('/').pop().slice(0, 100) || 'place-photo.jpg';
+ const obj = await client().upload(bytes, { kind: 'file', visibility: 'public', mimeType: type, filename: file });
+ const url = obj.public_url || obj.url;
+ if (!url || !/^https:\/\/openvibe\.media\//.test(url)) return null;
+ await s.db.exec(`INSERT INTO tours_photo_assets(source_url, media_id, media_url) VALUES ($1,$2,$3)
+ ON CONFLICT(source_url) DO NOTHING`, [image.sourceUrl, obj.id, url]);
+ return { ...image, url };
+ } catch (err) {
+ log.warn(`[Tours photos] Media copy unavailable: ${err.message}`);
+ return null;
+ }
+ }
+ return { cache };
+}
+
+module.exports = { createPhotoCache };
@@ -89,7 +89,7 @@ function createSearchIndex({ config, s, log = console, outbox = null }) {
@@ -89,7 +89,7 @@ function createSearchIndex({ config, s, log = console, outbox = null }) {
owner: 'tours', db, outbox: out, baseUrl: config.baseUrl, now: s.now, log,
owner: 'tours', db, outbox: out, baseUrl: config.baseUrl, now: s.now, log,
types: {
types: {
place: {
place: {
- page: (row) => `/place/${row.id}`,
+ page: (row) => require('./store').placePath(row),
document: describePlace,
document: describePlace,
rows: page(`SELECT p.id, p.name, p.name_en, p.kind, p.description, p.address, p.fee, p.wheelchair, p.created_at, p.updated_at,
rows: page(`SELECT p.id, p.name, p.name_en, p.kind, p.description, p.address, p.fee, p.wheelchair, p.created_at, p.updated_at,
c.name AS city_name, c.slug AS city_slug, c.country
c.name AS city_name, c.slug AS city_slug, c.country
@@ -30,6 +30,7 @@ function categoryOf(s) {
@@ -30,6 +30,7 @@ function categoryOf(s) {
}
}
function isIrl(s) {
function isIrl(s) {
+ if (placeIdOf(s)) return true;
const tags = parseTags(s.tags).map((t) => t.toLowerCase());
const tags = parseTags(s.tags).map((t) => t.toLowerCase());
if (tags.includes('irl') || tags.includes('travel')) return true;
if (tags.includes('irl') || tags.includes('travel')) return true;
if (IRL_WORDS.test(String(s.title || ''))) return true;
if (IRL_WORDS.test(String(s.title || ''))) return true;
@@ -39,6 +40,11 @@ function isIrl(s) {
@@ -39,6 +40,11 @@ function isIrl(s) {
return IRL_CATEGORIES.has(String((s && s.category) || '').toLowerCase());
return IRL_CATEGORIES.has(String((s && s.category) || '').toLowerCase());
}
}
+function placeIdOf(s) {
+ const url = String(s.place_url || '');
+ return url.match(/^https:\/\/openvibe\.tours\/place\/[a-z0-9-]+-(plc_[0-9A-HJKMNP-TV-Z]{26})$/)?.[1] || null;
+}
+
function streamOf(s, { fetchedAt, liveUrl, videoBase }) {
function streamOf(s, { fetchedAt, liveUrl, videoBase }) {
const username = String(s.username || '').slice(0, 64);
const username = String(s.username || '').slice(0, 64);
if (!username) return null;
if (!username) return null;
@@ -58,6 +64,7 @@ function streamOf(s, { fetchedAt, liveUrl, videoBase }) {
@@ -58,6 +64,7 @@ function streamOf(s, { fetchedAt, liveUrl, videoBase }) {
fetchedAt,
fetchedAt,
tags: parseTags(s.tags),
tags: parseTags(s.tags),
videoBase,
videoBase,
+ placeId: placeIdOf(s),
};
};
}
}
@@ -93,6 +100,7 @@ function clipOf(c, { fetchedAt, videoBase }) {
@@ -93,6 +100,7 @@ function clipOf(c, { fetchedAt, videoBase }) {
endedAt: utc(c.created_at),
endedAt: utc(c.created_at),
fetchedAt,
fetchedAt,
tags: [],
tags: [],
+ placeId: placeIdOf(c),
};
};
}
}
@@ -144,4 +152,4 @@ function createLive({ config, fetchImpl, pace }) {
@@ -144,4 +152,4 @@ function createLive({ config, fetchImpl, pace }) {
return { streams, recent, clips, isIrl, parseTags, categoryOf };
return { streams, recent, clips, isIrl, parseTags, categoryOf };
}
}
-module.exports = { createLive, isIrl, streamOf, vodOf, clipOf, parseTags, utc, IRL_CATEGORIES };
+module.exports = { createLive, isIrl, streamOf, vodOf, clipOf, placeIdOf, parseTags, utc, IRL_CATEGORIES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+'use strict';
+
+const { fetchJson } = require('./http');
+const ALLOWED = new Set(['by', 'by-sa', 'cc0', 'pdm']);
+
+function asImage(row, query) {
+ if (!row || row.mature || !ALLOWED.has(String(row.license || '').toLowerCase())) return null;
+ const title = String(row.title || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
+ const phrase = String(query || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
+ if (!phrase || !title.includes(phrase) || !row.creator || !row.foreign_landing_url || !row.id) return null;
+ const source = String(row.foreign_landing_url);
+ const thumb = `https://api.openverse.org/v1/images/${encodeURIComponent(row.id)}/thumb/`;
+ if (!/^https:\/\//.test(source) || !/^[0-9a-f-]{36}$/i.test(row.id)) return null;
+ const license = String(row.license).toLowerCase();
+ return {
+ url: thumb, credit: String(row.creator).slice(0, 250),
+ license: license === 'pdm' ? 'PDM' : license === 'cc0' ? 'CC0' : `CC ${license.toUpperCase()} ${String(row.license_version || '').trim()}`.trim(),
+ licenseUrl: row.license_url || null, sourceUrl: source, provider: 'openverse',
+ };
+}
+
+function createOpenverse({ config, fetchImpl, pace }) {
+ async function image(name) {
+ const q = String(name || '').trim().slice(0, 100);
+ if (q.length < 3) return null;
+ const url = `${config.upstreams.openverse}/v1/images/?${new URLSearchParams({ q, license: 'by,by-sa,cc0,pdm', mature: 'false', page_size: '5' })}`;
+ const { json } = await fetchJson(url, { fetchImpl, pace, timeoutMs: Math.min(config.upstreams.timeoutMs, 10_000),
+ userAgent: config.openData.userAgent, label: 'Openverse', retries: 0 });
+ return ((json && json.results) || []).map((r) => asImage(r, q)).find(Boolean) || null;
+ }
+ return { image };
+}
+
+module.exports = { createOpenverse, asImage };
@@ -1,17 +1,23 @@
@@ -1,17 +1,23 @@
'use strict';
'use strict';
/**
/**
- * OpenStreetMap through Overpass: one bounded `around:` query per city, tourism=* only, `out center tags`.
+ * OpenStreetMap through Overpass: one bounded `around:` query per city, named destinations only.
* ODbL; every place keeps its openstreetmap.org URL and the query's fetch time. No bulk download.
* ODbL; every place keeps its openstreetmap.org URL and the query's fetch time. No bulk download.
*/
*/
const { fetchText } = require('./http');
const { fetchText } = require('./http');
+const { isPlace } = require('../places/is-place');
// What a traveller means by "place". Lodging is Rent's; a hotel is not an attraction.
// What a traveller means by "place". Lodging is Rent's; a hotel is not an attraction.
-const INTERESTING = new Set(['attraction', 'museum', 'viewpoint', 'artwork', 'gallery', 'zoo', 'theme_park', 'aquarium', 'information', 'picnic_site', 'botanical_garden']);
+const INTERESTING = new Set(['attraction', 'museum', 'viewpoint', 'gallery', 'zoo', 'theme_park', 'aquarium', 'park', 'garden', 'nature_reserve']);
-function overpassQuery(lat, lon, radiusM) {
- const r = Math.max(100, Math.min(10_000, Math.round(Number(radiusM) || 3500)));
- return `[out:json][timeout:25];nwr["tourism"](around:${r},${Number(lat)},${Number(lon)});out center tags 160;`;
+function overpassQuery(lat, lon, radiusM, tagged = false) {
+ const r = Math.max(100, Math.min(25_000, Math.round(Number(radiusM) || 15_000)));
+ const group = (wikidata) => {
+ const wd = wikidata ? '["wikidata"]' : '';
+ return `(nwr["tourism"~"^(attraction|museum|gallery|viewpoint|zoo|aquarium|theme_park)$"]["name"]${wd}(around:${r},${Number(lat)},${Number(lon)});nwr["leisure"~"^(park|garden|nature_reserve)$"]["name"]${wd}(around:${r},${Number(lat)},${Number(lon)}););`;
+ };
+ // Overpass emits the tagged group in full before the capped general group, in one paced request.
+ return `[out:json][timeout:25];${group(true)}out center tags;${tagged ? '' : `${group(false)}out center tags 400;`}`;
}
}
function commonsImage(tag) {
function commonsImage(tag) {
@@ -30,8 +36,8 @@ function addressOf(tags) {
@@ -30,8 +36,8 @@ function addressOf(tags) {
function placeFromElement(el, cityId, fetchedAt) {
function placeFromElement(el, cityId, fetchedAt) {
const tags = el.tags || {};
const tags = el.tags || {};
- const kind = String(tags.tourism || '').toLowerCase();
- if (!INTERESTING.has(kind) || !tags.name) return null;
+ const kind = String(tags.tourism || tags.leisure || '').toLowerCase();
+ if (!INTERESTING.has(kind) || !tags.name || !isPlace(tags.name, tags)) return null;
const lat = Number.isFinite(Number(el.lat)) ? Number(el.lat) : Number(el.center && el.center.lat);
const lat = Number.isFinite(Number(el.lat)) ? Number(el.lat) : Number(el.center && el.center.lat);
const lon = Number.isFinite(Number(el.lon)) ? Number(el.lon) : Number(el.center && el.center.lon);
const lon = Number.isFinite(Number(el.lon)) ? Number(el.lon) : Number(el.center && el.center.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
@@ -53,8 +59,10 @@ function placeFromElement(el, cityId, fetchedAt) {
@@ -53,8 +59,10 @@ function placeFromElement(el, cityId, fetchedAt) {
fee: ['yes', 'no'].includes(tags.fee) ? tags.fee : null,
fee: ['yes', 'no'].includes(tags.fee) ? tags.fee : null,
wikidataId: /^Q[1-9][0-9]{0,11}$/.test(tags.wikidata || '') ? tags.wikidata : null,
wikidataId: /^Q[1-9][0-9]{0,11}$/.test(tags.wikidata || '') ? tags.wikidata : null,
wikipedia: tags.wikipedia || null,
wikipedia: tags.wikipedia || null,
- imageUrl: commonsImage(tags.image) || commonsImage(tags.wikimedia_commons),
- imageCredit: 'OpenStreetMap contributor',
+ // OSM tags do not carry an image licence or author. The places job resolves these through Commons.
+ imageFile: tags.image || tags.wikimedia_commons || null,
+ imageUrl: null,
+ imageCredit: null,
imageLicense: null,
imageLicense: null,
description: tags.description ? String(tags.description).slice(0, 800) : null,
description: tags.description ? String(tags.description).slice(0, 800) : null,
source: 'openstreetmap',
source: 'openstreetmap',
@@ -72,8 +80,15 @@ function createOverpass({ config, fetchImpl, pace }) {
@@ -72,8 +80,15 @@ function createOverpass({ config, fetchImpl, pace }) {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body,
});
});
let json;
let json;
- try { json = JSON.parse(out.text); } catch { throw new Error('Overpass answered something that is not JSON'); }
- return { elements: (json && json.elements) || [], fetchedAt: out.fetchedAt };
+ try { json = JSON.parse(out.text); }
+ catch { throw new Error('Overpass answered something that is not JSON'); }
+ const seen = new Set();
+ return { elements: (json?.elements || []).filter((el) => {
+ const key = `${el.type}:${el.id}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ }), fetchedAt: out.fetchedAt };
}
}
return { tourism, placeFromElement, overpassQuery, INTERESTING };
return { tourism, placeFromElement, overpassQuery, INTERESTING };
}
}
@@ -10,6 +10,14 @@
@@ -10,6 +10,14 @@
* imageInfo(files) a Commons file's 1200px URL and its licence/credit (extmetadata)
* imageInfo(files) a Commons file's 1200px URL and its licence/credit (extmetadata)
*/
*/
const { fetchJson, SourceError } = require('./http');
const { fetchJson, SourceError } = require('./http');
+const { isPlace } = require('../places/is-place');
+
+// Commercial pages use only licences that permit reuse and derivatives. A Commons record without
+// both an author and a source is no better than an unlicensed hotlink.
+function reusableImage({ license, credit, url, sourceUrl }) {
+ return Boolean(url && sourceUrl && credit && license && /^(?:CC0|CC BY(?:-SA)?(?:\s|$)|Public Domain|PDM)/i.test(license)
+ && !/\b(?:NC|ND)\b/i.test(license));
+}
const chunk = (list, n) => { const out = []; for (let i = 0; i < list.length; i += n) out.push(list.slice(i, i + n)); return out; };
const chunk = (list, n) => { const out = []; for (let i = 0; i < list.length; i += n) out.push(list.slice(i, i + n)); return out; };
@@ -28,9 +36,11 @@ function parseEntity(entity) {
@@ -28,9 +36,11 @@ function parseEntity(entity) {
const coord = firstClaim(entity, 'P625');
const coord = firstClaim(entity, 'P625');
const pop = firstClaim(entity, 'P1082');
const pop = firstClaim(entity, 'P1082');
const image = firstClaim(entity, 'P18');
const image = firstClaim(entity, 'P18');
+ const commonsCategory = firstClaim(entity, 'P373');
const country = firstClaim(entity, 'P17');
const country = firstClaim(entity, 'P17');
const label = (entity.labels && entity.labels.en && entity.labels.en.value) || null;
const label = (entity.labels && entity.labels.en && entity.labels.en.value) || null;
const sitelink = entity.sitelinks && entity.sitelinks.enwikivoyage && entity.sitelinks.enwikivoyage.title;
const sitelink = entity.sitelinks && entity.sitelinks.enwikivoyage && entity.sitelinks.enwikivoyage.title;
+ const wikipediaTitle = entity.sitelinks && entity.sitelinks.enwiki && entity.sitelinks.enwiki.title;
return {
return {
qid: entity.id,
qid: entity.id,
label,
label,
@@ -38,14 +48,61 @@ function parseEntity(entity) {
@@ -38,14 +48,61 @@ function parseEntity(entity) {
lon: coord && Number.isFinite(Number(coord.longitude)) ? Number(coord.longitude) : null,
lon: coord && Number.isFinite(Number(coord.longitude)) ? Number(coord.longitude) : null,
population: pop && Number.isFinite(Number(String(pop.amount || '').replace(/^\+/, ''))) ? Number(String(pop.amount).replace(/^\+/, '')) : null,
population: pop && Number.isFinite(Number(String(pop.amount || '').replace(/^\+/, ''))) ? Number(String(pop.amount).replace(/^\+/, '')) : null,
image: image ? String(image) : null,
image: image ? String(image) : null,
+ commonsCategory: typeof commonsCategory === 'string' ? `Category:${commonsCategory}` : null,
countryQid: country && typeof country === 'object' && country.id ? country.id : null,
countryQid: country && typeof country === 'object' && country.id ? country.id : null,
wikivoyageTitle: sitelink || null,
wikivoyageTitle: sitelink || null,
+ wikipediaTitle: wikipediaTitle || null,
+ sitelinks: Object.keys(entity.sitelinks || {}).length,
};
};
}
}
+function extractGuide(extract, title) {
+ const text = String(extract || '').replace(/\r/g, '').trim();
+ const lead = text.split(/\n\s*==/)[0].trim();
+ const sentences = lead.match(/[^.!?]+[.!?]+(?:\s|$)/g)?.map((s) => s.trim()) || [];
+ const intro = sentences[0] || '';
+ if (!intro || intro.length < 20) return null;
+ const why = sentences.slice(1, 4).join(' ').slice(0, 600) || null;
+ const section = (name) => text.match(new RegExp(`== ${name} ==\\n([\\s\\S]*?)(?=\\n== |$)`, 'i'))?.[1] || '';
+ const features = section('Features and programs').match(/Major features[^.]*\./i)?.[0] || null;
+ const visit = text.match(/[^.\n]*(?:public tour|guided tour|free tour)[^.\n]*\./i)?.[0]?.trim() || null;
+ // An unrelated plant's biology is not advice about when to visit this destination.
+ const named = String(title || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const seasonal = /\b(?:spring|summer|fall|autumn|winter|bloom(?:ing|s)?|foliage|season(?:al|s)?|January|February|March|April|May|June|July|August|September|October|November|December)\b/i;
+ const season = text.split(/(?<=[.!?])\s+|\n+/).map((s) => s.trim())
+ .find((s) => seasonal.test(s) && new RegExp(`\\b${named}\\b`, 'i').test(s)) || null;
+ return { intro: intro.slice(0, 600), why, features: features?.slice(0, 400) || null,
+ visit: visit?.slice(0, 400) || null, season: season?.slice(0, 400) || null,
+ url: `https://en.wikipedia.org/wiki/${encodeURIComponent(String(title).replace(/ /g, '_'))}` };
+}
+
function createWikidata({ config, fetchImpl, pace, log = console }) {
function createWikidata({ config, fetchImpl, pace, log = console }) {
const opts = { fetchImpl, pace, timeoutMs: config.upstreams.timeoutMs, userAgent: config.openData.userAgent, label: 'Wikidata' };
const opts = { fetchImpl, pace, timeoutMs: config.upstreams.timeoutMs, userAgent: config.openData.userAgent, label: 'Wikidata' };
+ async function notablePlaces(cityQid) {
+ if (!/^Q[1-9][0-9]{0,11}$/.test(String(cityQid || ''))) return [];
+ // WDQS times out joining P131+ to P279* on a large city. Its P31 English labels are bounded after the
+ // administrative-chain match; two rows for one item are collapsed below before the top 60 are kept.
+ const query = `SELECT DISTINCT ?item ?label ?coordinate ?links ?instanceLabel WHERE {
+ ?item wdt:P131+ wd:${cityQid}; wdt:P625 ?coordinate; wdt:P31 ?kind; wikibase:sitelinks ?links; rdfs:label ?label.
+ ?kind rdfs:label ?instanceLabel.
+ FILTER(LANG(?label) = "en" && LANG(?instanceLabel) = "en" &&
+ REGEX(?instanceLabel, "museum|park|garden|landmark|viewpoint|zoo|aquarium|observation tower|monument|tourist attraction|art gallery|historic site", "i"))
+ } ORDER BY DESC(?links) LIMIT 120`;
+ const url = `${config.upstreams.wikidataQuery}?${new URLSearchParams({ query, format: 'json' })}`;
+ const { json, fetchedAt } = await fetchJson(url, { ...opts, label: 'Wikidata Query Service', retries: 0 });
+ const seen = new Set();
+ return (json?.results?.bindings || []).map((b) => {
+ const qid = String(b.item?.value || '').match(/\/Q[1-9][0-9]{0,11}$/)?.[0]?.slice(1);
+ const coord = String(b.coordinate?.value || '').match(/^Point\((-?[\d.]+) (-?[\d.]+)\)$/);
+ const name = b.label?.value || '';
+ if (!qid || seen.has(qid) || !coord || !isPlace(name, { instanceLabel: b.instanceLabel?.value })) return null;
+ seen.add(qid);
+ return { qid, name, lon: Number(coord[1]), lat: Number(coord[2]), sitelinks: Number(b.links?.value || b.sitelinks?.value || 0),
+ sourceUrl: `https://www.wikidata.org/wiki/${qid}`, fetchedAt };
+ }).filter(Boolean).slice(0, 60);
+ }
+
/** Wikipedia titles → Q ids. Returns a Map keyed by the requested title (redirects resolved). */
/** Wikipedia titles → Q ids. Returns a Map keyed by the requested title (redirects resolved). */
async function resolveQids(titles) {
async function resolveQids(titles) {
const wanted = [...new Set((titles || []).map((t) => String(t || '').trim()).filter(Boolean))];
const wanted = [...new Set((titles || []).map((t) => String(t || '').trim()).filter(Boolean))];
@@ -70,6 +127,17 @@ function createWikidata({ config, fetchImpl, pace, log = console }) {
@@ -70,6 +127,17 @@ function createWikidata({ config, fetchImpl, pace, log = console }) {
return out;
return out;
}
}
+ async function article(title) {
+ const name = String(title || '').trim();
+ if (!name || name.length > 120) return null;
+ const url = `${config.upstreams.wikipedia}/w/api.php?${new URLSearchParams({
+ action: 'query', format: 'json', formatversion: '2', prop: 'extracts', explaintext: '1', titles: name,
+ })}`;
+ const { json } = await fetchJson(url, { ...opts, label: 'Wikipedia' });
+ const page = json?.query?.pages?.[0];
+ return page && !page.missing ? extractGuide(page.extract, page.title || name) : null;
+ }
+
/** Q ids → parsed entities (all props the city job needs). */
/** Q ids → parsed entities (all props the city job needs). */
async function entities(qids) {
async function entities(qids) {
const wanted = [...new Set((qids || []).filter((q) => /^Q[1-9][0-9]{0,11}$/.test(String(q))))];
const wanted = [...new Set((qids || []).filter((q) => /^Q[1-9][0-9]{0,11}$/.test(String(q))))];
@@ -77,7 +145,7 @@ function createWikidata({ config, fetchImpl, pace, log = console }) {
@@ -77,7 +145,7 @@ function createWikidata({ config, fetchImpl, pace, log = console }) {
for (const group of chunk(wanted, 40)) {
for (const group of chunk(wanted, 40)) {
const url = `${config.upstreams.wikidata}/w/api.php?${new URLSearchParams({
const url = `${config.upstreams.wikidata}/w/api.php?${new URLSearchParams({
action: 'wbgetentities', format: 'json', ids: group.join('|'), props: 'claims|labels|sitelinks',
action: 'wbgetentities', format: 'json', ids: group.join('|'), props: 'claims|labels|sitelinks',
- languages: 'en', sitefilter: 'enwikivoyage',
+ languages: 'en',
})}`;
})}`;
const { json, fetchedAt } = await fetchJson(url, { ...opts, label: 'Wikidata' });
const { json, fetchedAt } = await fetchJson(url, { ...opts, label: 'Wikidata' });
const entitiesObj = (json && json.entities) || {};
const entitiesObj = (json && json.entities) || {};
@@ -137,18 +205,31 @@ function createWikidata({ config, fetchImpl, pace, log = console }) {
@@ -137,18 +205,31 @@ function createWikidata({ config, fetchImpl, pace, log = console }) {
const ENT = { '&': '&', '"': '"', ''': "'", '<': '<', '>': '>', ' ': ' ' };
const ENT = { '&': '&', '"': '"', ''': "'", '<': '<', '>': '>', ' ': ' ' };
const strip = (s) => (s ? s.replace(/<[^>]*>/g, ' ').replace(/&(amp|quot|#39|lt|gt|nbsp);/g, (e) => ENT[e]).replace(/\s+/g, ' ')
const strip = (s) => (s ? s.replace(/<[^>]*>/g, ' ').replace(/&(amp|quot|#39|lt|gt|nbsp);/g, (e) => ENT[e]).replace(/\s+/g, ' ')
.replace(/\s+([.,;:!?])/g, '$1').replace(/[\s.]+$/, '').trim() || null : null);
.replace(/\s+([.,;:!?])/g, '$1').replace(/[\s.]+$/, '').trim() || null : null);
- out.set(String(page.title), {
+ const image = {
url: info.thumburl || info.url || null,
url: info.thumburl || info.url || null,
- credit: strip(val('Artist')) || strip(val('Credit')) || 'Wikimedia Commons',
+ credit: strip(val('Artist')) || strip(val('Credit')),
license: strip(val('LicenseShortName')),
license: strip(val('LicenseShortName')),
licenseUrl: val('LicenseUrl'),
licenseUrl: val('LicenseUrl'),
- });
+ sourceUrl: `https://commons.wikimedia.org/wiki/${encodeURIComponent(String(page.title).replace(/ /g, '_'))}`,
+ };
+ if (reusableImage(image)) out.set(String(page.title), image);
}
}
}
}
return out;
return out;
}
}
- return { resolveQids, entities, countryInfo, imageInfo, parseEntity };
+ async function categoryFiles(category) {
+ const name = String(category || '').trim();
+ if (!/^Category:[^|#?]{3,120}$/i.test(name)) return [];
+ const url = `${config.upstreams.commons}/w/api.php?${new URLSearchParams({
+ action: 'query', format: 'json', formatversion: '2', list: 'categorymembers',
+ cmtitle: name, cmtype: 'file', cmlimit: '5',
+ })}`;
+ const { json } = await fetchJson(url, { ...opts, label: 'Commons category' });
+ return ((json && json.query && json.query.categorymembers) || []).map((r) => r.title).filter((t) => /^File:/i.test(t));
+ }
+
+ return { resolveQids, entities, countryInfo, imageInfo, categoryFiles, article, notablePlaces, parseEntity };
}
}
-module.exports = { createWikidata, parseEntity, claimValues, firstClaim, chunk, SourceError };
+module.exports = { createWikidata, parseEntity, extractGuide, claimValues, firstClaim, chunk, SourceError, reusableImage };
@@ -170,7 +170,7 @@ function coordFrom(params) {
@@ -170,7 +170,7 @@ function coordFrom(params) {
}
}
/** Every {{see}} (and old {{listing|type=see}}) on a page, with coordinates when the listing has them. */
/** Every {{see}} (and old {{listing|type=see}}) on a page, with coordinates when the listing has them. */
-function seeListings(wikitext) {
+function seeListings(wikitext, types = ['see']) {
const text = String(wikitext || '');
const text = String(wikitext || '');
const out = [];
const out = [];
const titles = new Set();
const titles = new Set();
@@ -180,8 +180,8 @@ function seeListings(wikitext) {
@@ -180,8 +180,8 @@ function seeListings(wikitext) {
if (end < 0) break;
if (end < 0) break;
const body = text.slice(i + 2, end - 2);
const body = text.slice(i + 2, end - 2);
const head = (body.split('|')[0] || '').trim().toLowerCase().replace(/^subst:/, '');
const head = (body.split('|')[0] || '').trim().toLowerCase().replace(/^subst:/, '');
- const isSee = head === 'see';
- const isListing = head === 'listing' && /(?:^|\|)\s*type\s*=\s*see\b/i.test(body);
+ const isSee = types.includes(head);
+ const isListing = head === 'listing' && types.some((type) => new RegExp(`(?:^|\\|)\\s*type\\s*=\\s*${type}\\b`, 'i').test(body));
if (!isSee && !isListing) { i = end - 1; continue; }
if (!isSee && !isListing) { i = end - 1; continue; }
const p = paramsOf(body);
const p = paramsOf(body);
const name = stripWiki(p.name || p.title || '', { max: 200 });
const name = stripWiki(p.name || p.title || '', { max: 200 });
@@ -233,6 +233,7 @@ function parseCityPage(wikitext, title, { districts = 2 } = {}) {
@@ -233,6 +233,7 @@ function parseCityPage(wikitext, title, { districts = 2 } = {}) {
return {
return {
summary: leadSummary(wikitext),
summary: leadSummary(wikitext),
see: seeListings(wikitext),
see: seeListings(wikitext),
+ do: seeListings(wikitext, ['do']),
districts: districtLinks(wikitext, title, districts),
districts: districtLinks(wikitext, title, districts),
};
};
}
}
@@ -28,6 +28,7 @@ function slugify(value) {
@@ -28,6 +28,7 @@ function slugify(value) {
return String(value == null ? '' : value).normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
return String(value == null ? '' : value).normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 90);
.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 90);
}
}
+const placePath = (p) => `/place/${slugify(p.name_en || p.name) || 'place'}-${p.id}`;
const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
const day = (v) => (v == null ? null : (v instanceof Date ? v.toISOString().slice(0, 10) : String(v).slice(0, 10)));
const day = (v) => (v == null ? null : (v instanceof Date ? v.toISOString().slice(0, 10) : String(v).slice(0, 10)));
@@ -45,7 +46,10 @@ function wireCity(row) {
@@ -45,7 +46,10 @@ function wireCity(row) {
wikidata_id: row.wikidata_id, wikipedia_title: row.wikipedia_title || null, wikivoyage_title: row.wikivoyage_title || null,
wikidata_id: row.wikidata_id, wikipedia_title: row.wikipedia_title || null, wikivoyage_title: row.wikivoyage_title || null,
summary: row.summary || null, image_url: row.image_url || null, image_credit: row.image_credit || null,
summary: row.summary || null, image_url: row.image_url || null, image_credit: row.image_credit || null,
image_license: row.image_license || null, image_license_url: row.image_license_url || null,
image_license: row.image_license || null, image_license_url: row.image_license_url || null,
+ image_source_url: row.image_source_url || null,
source: row.source, source_url: row.source_url, fetched_at: iso(row.fetched_at),
source: row.source, source_url: row.source_url, fetched_at: iso(row.fetched_at),
+ osm_fetched_at: iso(row.osm_fetched_at),
+ osm_attempted_at: iso(row.osm_attempted_at),
created_at: iso(row.created_at), updated_at: iso(row.updated_at),
created_at: iso(row.created_at), updated_at: iso(row.updated_at),
...(row.place_count != null ? { place_count: Number(row.place_count) } : {}),
...(row.place_count != null ? { place_count: Number(row.place_count) } : {}),
...(row.route_count != null ? { route_count: Number(row.route_count) } : {}),
...(row.route_count != null ? { route_count: Number(row.route_count) } : {}),
@@ -60,9 +64,12 @@ function wirePlace(row) {
@@ -60,9 +64,12 @@ function wirePlace(row) {
address: row.address || null, website: row.website || null, opening_hours: row.opening_hours || null,
address: row.address || null, website: row.website || null, opening_hours: row.opening_hours || null,
wheelchair: row.wheelchair || null, fee: row.fee || null, wikidata_id: row.wikidata_id || null,
wheelchair: row.wheelchair || null, fee: row.fee || null, wikidata_id: row.wikidata_id || null,
wikipedia: row.wikipedia || null, image_url: row.image_url || null, image_credit: row.image_credit || null,
wikipedia: row.wikipedia || null, image_url: row.image_url || null, image_credit: row.image_credit || null,
- image_license: row.image_license || null, description: row.description || null,
+ image_license: row.image_license || null, image_license_url: row.image_license_url || null,
+ image_source_url: row.image_source_url || null, gallery: typeof row.gallery === 'string' ? JSON.parse(row.gallery) : (row.gallery || []), description: row.description || null,
+ guide_why: row.guide_why || null, guide_features: row.guide_features || null, guide_visit: row.guide_visit || null,
+ guide_season: row.guide_season && String(row.guide_season).toLowerCase().includes(String(row.name).toLowerCase()) ? row.guide_season : null,
source: row.source, source_url: row.source_url, fetched_at: iso(row.fetched_at),
source: row.source, source_url: row.source_url, fetched_at: iso(row.fetched_at),
- osm_url: row.osm_type === 'wikivoyage' ? null : `https://www.openstreetmap.org/${row.osm_type}/${row.osm_id}`,
+ osm_url: ['wikivoyage', 'wikidata'].includes(row.osm_type) ? null : `https://www.openstreetmap.org/${row.osm_type}/${row.osm_id}`,
map_url: `https://www.openstreetmap.org/?mlat=${row.lat}&mlon=${row.lon}#map=16/${row.lat}/${row.lon}`,
map_url: `https://www.openstreetmap.org/?mlat=${row.lat}&mlon=${row.lon}#map=16/${row.lat}/${row.lon}`,
};
};
}
}
@@ -119,6 +126,8 @@ function wireTrip(row) {
@@ -119,6 +126,8 @@ function wireTrip(row) {
function createStore(s, { config = {}, sharing = null } = {}) {
function createStore(s, { config = {}, sharing = null } = {}) {
const db = s.db;
const db = s.db;
+ const indexNow = require('./index-now').createIndexNow({ baseUrl: config.baseUrl,
+ key: config.isProduction && !config.drill ? config.indexNow?.key : '' });
const q = (t) => t || db;
const q = (t) => t || db;
/** The viewer as openvibe-sdk/share wants it. Tours' pages and API both name the person by subject. */
/** The viewer as openvibe-sdk/share wants it. Tours' pages and API both name the person by subject. */
@@ -165,13 +174,18 @@ function createStore(s, { config = {}, sharing = null } = {}) {
@@ -165,13 +174,18 @@ function createStore(s, { config = {}, sharing = null } = {}) {
return wireCity(row);
return wireCity(row);
}
}
+ async function cityById(id) {
+ return wireCity(await db.maybe('SELECT * FROM tours_cities WHERE id=$1', [String(id || '')]));
+ }
+
async function cityPlaces(cityId, { limit = 60 } = {}) {
async function cityPlaces(cityId, { limit = 60 } = {}) {
const rows = await db.many(`SELECT p.*, c.slug AS city_slug, c.name AS city_name
const rows = await db.many(`SELECT p.*, c.slug AS city_slug, c.name AS city_name
FROM tours_places p JOIN tours_cities c ON c.id = p.city_id
FROM tours_places p JOIN tours_cities c ON c.id = p.city_id
WHERE p.city_id = $1
WHERE p.city_id = $1
-- The places a visitor can use first: a picture, a description, a name they can read; then by kind and name
-- The places a visitor can use first: a picture, a description, a name they can read; then by kind and name
-- (by kind alone, Chongqing's list opened on an artwork with a Chinese-only name, 2026-11-02).
-- (by kind alone, Chongqing's list opened on an artwork with a Chinese-only name, 2026-11-02).
- ORDER BY (p.image_url IS NOT NULL) DESC, (p.description IS NOT NULL) DESC,
+ ORDER BY (p.image_url IS NOT NULL AND p.image_credit IS NOT NULL AND p.image_license IS NOT NULL AND p.image_source_url IS NOT NULL) DESC,
+ p.rank_score DESC, (p.description IS NOT NULL) DESC,
(p.name_en IS NOT NULL OR p.name ~ '[A-Za-z]') DESC, p.kind, p.name LIMIT $2`, [String(cityId), Math.min(200, Math.max(1, int(limit, 60)))]);
(p.name_en IS NOT NULL OR p.name ~ '[A-Za-z]') DESC, p.kind, p.name LIMIT $2`, [String(cityId), Math.min(200, Math.max(1, int(limit, 60)))]);
return rows.map(wirePlace);
return rows.map(wirePlace);
}
}
@@ -193,11 +207,20 @@ function createStore(s, { config = {}, sharing = null } = {}) {
@@ -193,11 +207,20 @@ function createStore(s, { config = {}, sharing = null } = {}) {
await db.exec('UPDATE tours_cities SET osm_fetched_at = now() WHERE id = $1', [String(cityId)]);
await db.exec('UPDATE tours_cities SET osm_fetched_at = now() WHERE id = $1', [String(cityId)]);
}
}
+ async function markCityOsmAttempted(cityId) {
+ await db.exec('UPDATE tours_cities SET osm_attempted_at = now() WHERE id = $1', [String(cityId)]);
+ }
+
async function cityLive(cityId) {
async function cityLive(cityId) {
const rows = await db.many(`SELECT * FROM tours_live WHERE city_id = $1 ORDER BY kind, fetched_at DESC LIMIT 12`, [String(cityId)]);
const rows = await db.many(`SELECT * FROM tours_live WHERE city_id = $1 ORDER BY kind, fetched_at DESC LIMIT 12`, [String(cityId)]);
return rows.map(wireLive);
return rows.map(wireLive);
}
}
+ async function placeLive(placeId) {
+ const rows = await db.many('SELECT * FROM tours_live WHERE place_id = $1 ORDER BY kind, fetched_at DESC LIMIT 12', [String(placeId)]);
+ return rows.map(wireLive);
+ }
+
async function cityStays(cityId) {
async function cityStays(cityId) {
const rows = await db.many(`SELECT * FROM tours_stays WHERE city_id = $1 ORDER BY price LIMIT 12`, [String(cityId)]);
const rows = await db.many(`SELECT * FROM tours_stays WHERE city_id = $1 ORDER BY price LIMIT 12`, [String(cityId)]);
return rows.map(wireStay);
return rows.map(wireStay);
@@ -210,6 +233,15 @@ function createStore(s, { config = {}, sharing = null } = {}) {
@@ -210,6 +233,15 @@ function createStore(s, { config = {}, sharing = null } = {}) {
return wirePlace(row);
return wirePlace(row);
}
}
+ async function updatePlaceGuide(id, guide) {
+ await db.exec(`UPDATE tours_places SET description=$2, guide_features=$3, guide_visit=$4, guide_season=$5,
+ source='wikipedia', source_url=$6, guide_why=$7, updated_at=now() WHERE id=$1`,
+ [String(id), clean(guide.intro, 600), guide.features ? clean(guide.features, 400) : null,
+ guide.visit ? clean(guide.visit, 400) : null, guide.season ? clean(guide.season, 400) : null, guide.url,
+ guide.why ? clean(guide.why, 600) : null]);
+ return placeById(id);
+ }
+
async function routeById(id) {
async function routeById(id) {
const row = await db.maybe(`SELECT r.*, c.slug AS city_slug, c.name AS city_name
const row = await db.maybe(`SELECT r.*, c.slug AS city_slug, c.name AS city_name
FROM tours_routes r JOIN tours_cities c ON c.id = r.city_id WHERE r.id = $1`, [String(id || '')]);
FROM tours_routes r JOIN tours_cities c ON c.id = r.city_id WHERE r.id = $1`, [String(id || '')]);
@@ -298,7 +330,7 @@ function createStore(s, { config = {}, sharing = null } = {}) {
@@ -298,7 +330,7 @@ function createStore(s, { config = {}, sharing = null } = {}) {
return await db.many('SELECT id, updated_at FROM tours_routes ORDER BY id LIMIT $1', [limit]);
return await db.many('SELECT id, updated_at FROM tours_routes ORDER BY id LIMIT $1', [limit]);
}
}
async function publicPlacesForSitemap(limit = 20000) {
async function publicPlacesForSitemap(limit = 20000) {
- return await db.many('SELECT id, updated_at FROM tours_places ORDER BY id LIMIT $1', [limit]);
+ return await db.many('SELECT id, name, name_en, updated_at FROM tours_places ORDER BY id LIMIT $1', [limit]);
}
}
async function publicCitiesForLlms(limit = 200) {
async function publicCitiesForLlms(limit = 200) {
return await db.many('SELECT slug, name, country, summary FROM tours_cities ORDER BY population DESC NULLS LAST LIMIT $1', [limit]);
return await db.many('SELECT slug, name, country, summary FROM tours_cities ORDER BY population DESC NULLS LAST LIMIT $1', [limit]);
@@ -310,20 +342,23 @@ function createStore(s, { config = {}, sharing = null } = {}) {
@@ -310,20 +342,23 @@ function createStore(s, { config = {}, sharing = null } = {}) {
const slug = c.slug || slugify(c.name);
const slug = c.slug || slugify(c.name);
await db.exec(`INSERT INTO tours_cities
await db.exec(`INSERT INTO tours_cities
(id, slug, name, country, country_code, region, lat, lon, population, wikidata_id, wikipedia_title, wikivoyage_title,
(id, slug, name, country, country_code, region, lat, lon, population, wikidata_id, wikipedia_title, wikivoyage_title,
- summary, image_url, image_credit, image_license, image_license_url, source, source_url, fetched_at, updated_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,now())
+ summary, image_url, image_credit, image_license, image_license_url, source, source_url, fetched_at, updated_at, image_source_url)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,now(),$21)
ON CONFLICT (id) DO UPDATE SET
ON CONFLICT (id) DO UPDATE SET
slug = EXCLUDED.slug, name = EXCLUDED.name, country = EXCLUDED.country, country_code = EXCLUDED.country_code,
slug = EXCLUDED.slug, name = EXCLUDED.name, country = EXCLUDED.country, country_code = EXCLUDED.country_code,
region = EXCLUDED.region, lat = EXCLUDED.lat, lon = EXCLUDED.lon, population = EXCLUDED.population,
region = EXCLUDED.region, lat = EXCLUDED.lat, lon = EXCLUDED.lon, population = EXCLUDED.population,
wikidata_id = EXCLUDED.wikidata_id, wikipedia_title = EXCLUDED.wikipedia_title, wikivoyage_title = EXCLUDED.wikivoyage_title,
wikidata_id = EXCLUDED.wikidata_id, wikipedia_title = EXCLUDED.wikipedia_title, wikivoyage_title = EXCLUDED.wikivoyage_title,
summary = COALESCE(EXCLUDED.summary, tours_cities.summary), image_url = EXCLUDED.image_url,
summary = COALESCE(EXCLUDED.summary, tours_cities.summary), image_url = EXCLUDED.image_url,
image_credit = EXCLUDED.image_credit, image_license = EXCLUDED.image_license, image_license_url = EXCLUDED.image_license_url,
image_credit = EXCLUDED.image_credit, image_license = EXCLUDED.image_license, image_license_url = EXCLUDED.image_license_url,
- source = EXCLUDED.source, source_url = EXCLUDED.source_url, fetched_at = EXCLUDED.fetched_at, updated_at = now()`,
+ source = EXCLUDED.source, source_url = EXCLUDED.source_url, fetched_at = EXCLUDED.fetched_at,
+ image_source_url = EXCLUDED.image_source_url, updated_at = now()`,
[id, slug, clean(c.name, 120), clean(c.country, 120), c.countryCode || null, c.region ? clean(c.region, 120) : null,
[id, slug, clean(c.name, 120), clean(c.country, 120), c.countryCode || null, c.region ? clean(c.region, 120) : null,
Number(c.lat), Number(c.lon), c.population == null ? null : Number(c.population), c.wikidataId,
Number(c.lat), Number(c.lon), c.population == null ? null : Number(c.population), c.wikidataId,
c.wikipediaTitle || null, c.wikivoyageTitle || null, c.summary ? clean(c.summary, 1200) : null,
c.wikipediaTitle || null, c.wikivoyageTitle || null, c.summary ? clean(c.summary, 1200) : null,
c.imageUrl || null, c.imageCredit ? clean(c.imageCredit, 250) : null, c.imageLicense ? clean(c.imageLicense, 120) : null,
c.imageUrl || null, c.imageCredit ? clean(c.imageCredit, 250) : null, c.imageLicense ? clean(c.imageLicense, 120) : null,
- c.imageLicenseUrl || null, c.source || 'wikidata', c.sourceUrl, new Date(c.fetchedAt || s.now()).toISOString()]);
+ c.imageLicenseUrl || null, c.source || 'wikidata', c.sourceUrl, new Date(c.fetchedAt || s.now()).toISOString(), c.imageSourceUrl || null]);
+ indexNow.add(`/city/${slug}`);
+ indexNow.add(`/city/${slug}/free`);
return id;
return id;
}
}
@@ -348,21 +383,25 @@ function createStore(s, { config = {}, sharing = null } = {}) {
@@ -348,21 +383,25 @@ function createStore(s, { config = {}, sharing = null } = {}) {
const id = stableId('plc', `${p.osmType}:${p.osmId}`);
const id = stableId('plc', `${p.osmType}:${p.osmId}`);
await db.exec(`INSERT INTO tours_places
await db.exec(`INSERT INTO tours_places
(id, city_id, osm_type, osm_id, name, kind, lat, lon, address, website, opening_hours, wheelchair, fee,
(id, city_id, osm_type, osm_id, name, kind, lat, lon, address, website, opening_hours, wheelchair, fee,
- wikidata_id, wikipedia, image_url, image_credit, image_license, description, source, source_url, fetched_at, name_en, updated_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,now())
+ wikidata_id, wikipedia, image_url, image_credit, image_license, description, source, source_url, fetched_at, name_en, updated_at, image_license_url, image_source_url, gallery, rank_score, guide_why)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,now(),$24,$25,$26::jsonb,$27,$28)
ON CONFLICT (osm_type, osm_id) DO UPDATE SET
ON CONFLICT (osm_type, osm_id) DO UPDATE SET
city_id = EXCLUDED.city_id, name = EXCLUDED.name, name_en = EXCLUDED.name_en, kind = EXCLUDED.kind, lat = EXCLUDED.lat, lon = EXCLUDED.lon,
city_id = EXCLUDED.city_id, name = EXCLUDED.name, name_en = EXCLUDED.name_en, kind = EXCLUDED.kind, lat = EXCLUDED.lat, lon = EXCLUDED.lon,
address = EXCLUDED.address, website = EXCLUDED.website, opening_hours = EXCLUDED.opening_hours,
address = EXCLUDED.address, website = EXCLUDED.website, opening_hours = EXCLUDED.opening_hours,
wheelchair = EXCLUDED.wheelchair, fee = EXCLUDED.fee, wikidata_id = EXCLUDED.wikidata_id, wikipedia = EXCLUDED.wikipedia,
wheelchair = EXCLUDED.wheelchair, fee = EXCLUDED.fee, wikidata_id = EXCLUDED.wikidata_id, wikipedia = EXCLUDED.wikipedia,
image_url = EXCLUDED.image_url, image_credit = EXCLUDED.image_credit, image_license = EXCLUDED.image_license,
image_url = EXCLUDED.image_url, image_credit = EXCLUDED.image_credit, image_license = EXCLUDED.image_license,
- description = EXCLUDED.description, source = EXCLUDED.source, source_url = EXCLUDED.source_url,
+ image_license_url = EXCLUDED.image_license_url, image_source_url = EXCLUDED.image_source_url, gallery = EXCLUDED.gallery, rank_score = EXCLUDED.rank_score,
+ description = EXCLUDED.description, guide_why = COALESCE(EXCLUDED.guide_why, tours_places.guide_why), source = EXCLUDED.source, source_url = EXCLUDED.source_url,
fetched_at = EXCLUDED.fetched_at, updated_at = now()`,
fetched_at = EXCLUDED.fetched_at, updated_at = now()`,
[id, p.cityId, p.osmType, String(p.osmId), clean(p.name, 200), p.kind, Number(p.lat), Number(p.lon),
[id, p.cityId, p.osmType, String(p.osmId), clean(p.name, 200), p.kind, Number(p.lat), Number(p.lon),
p.address ? clean(p.address, 250) : null, p.website || null, p.openingHours ? clean(p.openingHours, 200) : null,
p.address ? clean(p.address, 250) : null, p.website || null, p.openingHours ? clean(p.openingHours, 200) : null,
p.wheelchair || null, p.fee || null, p.wikidataId || null, p.wikipedia || null, p.imageUrl || null,
p.wheelchair || null, p.fee || null, p.wikidataId || null, p.wikipedia || null, p.imageUrl || null,
p.imageCredit ? clean(p.imageCredit, 250) : null, p.imageLicense ? clean(p.imageLicense, 120) : null,
p.imageCredit ? clean(p.imageCredit, 250) : null, p.imageLicense ? clean(p.imageLicense, 120) : null,
p.description ? clean(p.description, 800) : null, p.source || 'openstreetmap', p.sourceUrl,
p.description ? clean(p.description, 800) : null, p.source || 'openstreetmap', p.sourceUrl,
- new Date(p.fetchedAt || s.now()).toISOString(), p.nameEn ? clean(p.nameEn, 200) : null]);
+ new Date(p.fetchedAt || s.now()).toISOString(), p.nameEn ? clean(p.nameEn, 200) : null,
+ p.imageLicenseUrl || null, p.imageSourceUrl || null, JSON.stringify(p.gallery || []), Math.max(0, int(p.rankScore)),
+ p.guideWhy ? clean(p.guideWhy, 600) : null]);
+ indexNow.add(placePath({ id, name: p.name, name_en: p.nameEn }));
return id;
return id;
}
}
@@ -399,18 +438,18 @@ function createStore(s, { config = {}, sharing = null } = {}) {
@@ -399,18 +438,18 @@ function createStore(s, { config = {}, sharing = null } = {}) {
const id = stableId('liv', `${item.kind}:${item.externalId}`);
const id = stableId('liv', `${item.kind}:${item.externalId}`);
await db.exec(`INSERT INTO tours_live
await db.exec(`INSERT INTO tours_live
(id, kind, external_id, username, display_name, title, category, thumbnail_url, viewer_count, city_id, city_name,
(id, kind, external_id, username, display_name, title, category, thumbnail_url, viewer_count, city_id, city_name,
- url, started_at, ended_at, fetched_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
+ url, started_at, ended_at, fetched_at, place_id)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
ON CONFLICT (kind, external_id) DO UPDATE SET
ON CONFLICT (kind, external_id) DO UPDATE SET
username = EXCLUDED.username, display_name = EXCLUDED.display_name, title = EXCLUDED.title,
username = EXCLUDED.username, display_name = EXCLUDED.display_name, title = EXCLUDED.title,
category = EXCLUDED.category, thumbnail_url = EXCLUDED.thumbnail_url, viewer_count = EXCLUDED.viewer_count,
category = EXCLUDED.category, thumbnail_url = EXCLUDED.thumbnail_url, viewer_count = EXCLUDED.viewer_count,
- city_id = EXCLUDED.city_id, city_name = EXCLUDED.city_name, url = EXCLUDED.url,
+ city_id = EXCLUDED.city_id, city_name = EXCLUDED.city_name, place_id = EXCLUDED.place_id, url = EXCLUDED.url,
started_at = EXCLUDED.started_at, ended_at = EXCLUDED.ended_at, fetched_at = EXCLUDED.fetched_at`,
started_at = EXCLUDED.started_at, ended_at = EXCLUDED.ended_at, fetched_at = EXCLUDED.fetched_at`,
[id, item.kind, String(item.externalId), item.username, item.displayName ? clean(item.displayName, 80) : null,
[id, item.kind, String(item.externalId), item.username, item.displayName ? clean(item.displayName, 80) : null,
clean(item.title, 240), item.category ? clean(item.category, 40) : null, item.thumbnailUrl || null,
clean(item.title, 240), item.category ? clean(item.category, 40) : null, item.thumbnailUrl || null,
Math.max(0, int(item.viewerCount)), item.cityId || null, item.cityName ? clean(item.cityName, 120) : null,
Math.max(0, int(item.viewerCount)), item.cityId || null, item.cityName ? clean(item.cityName, 120) : null,
item.url, item.startedAt ? new Date(item.startedAt).toISOString() : null,
item.url, item.startedAt ? new Date(item.startedAt).toISOString() : null,
- item.endedAt ? new Date(item.endedAt).toISOString() : null, new Date(item.fetchedAt || s.now()).toISOString()]);
+ item.endedAt ? new Date(item.endedAt).toISOString() : null, new Date(item.fetchedAt || s.now()).toISOString(), item.placeId || null]);
return id;
return id;
}
}
@@ -647,8 +686,8 @@ function createStore(s, { config = {}, sharing = null } = {}) {
@@ -647,8 +686,8 @@ function createStore(s, { config = {}, sharing = null } = {}) {
}
}
return {
return {
- counts, listCities, cityBySlug, cityPlaces, cityRoutes, cityLive, cityStays, citiesForOsm, markCityOsmFetched,
- placeById, routeById, routes, routesForPlace, placeOfTheDay, liveNow, recentTours, stays,
+ counts, listCities, cityBySlug, cityById, cityPlaces, cityRoutes, cityLive, placeLive, cityStays, citiesForOsm, markCityOsmFetched, markCityOsmAttempted,
+ placeById, updatePlaceGuide, routeById, routes, routesForPlace, placeOfTheDay, liveNow, recentTours, stays,
latestJobRuns, recordJobStart, recordJobFinish,
latestJobRuns, recordJobStart, recordJobFinish,
publicCitiesForSitemap, publicRoutesForSitemap, publicPlacesForSitemap, publicCitiesForLlms,
publicCitiesForSitemap, publicRoutesForSitemap, publicPlacesForSitemap, publicCitiesForLlms,
upsertCity, upsertCitySource, citySourcesMissing, citySource, upsertPlace, replaceRoutesForCity,
upsertCity, upsertCitySource, citySourcesMissing, citySource, upsertPlace, replaceRoutesForCity,
@@ -658,4 +697,4 @@ function createStore(s, { config = {}, sharing = null } = {}) {
@@ -658,4 +697,4 @@ function createStore(s, { config = {}, sharing = null } = {}) {
};
};
}
}
-module.exports = { createStore, stableId, slugify, wireCity, wirePlace, wireRoute, wireStop, wireLive, wireStay, wireTrip, SLUG_RE, SUBJECT_RE, VISIBILITIES, LIVE_KINDS };
+module.exports = { createStore, stableId, slugify, placePath, wireCity, wirePlace, wireRoute, wireStop, wireLive, wireStay, wireTrip, SLUG_RE, SUBJECT_RE, VISIBILITIES, LIVE_KINDS };
@@ -11,6 +11,7 @@ const { esc, raw } = require('../render/html');
@@ -11,6 +11,7 @@ const { esc, raw } = require('../render/html');
const sc = require('openvibe-shared/showcase');
const sc = require('openvibe-shared/showcase');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const { stateLabel } = require('./labels');
const { stateLabel } = require('./labels');
+const { placePath } = require('../store');
const SOURCE = {
const SOURCE = {
osm: '<a href="https://www.openstreetmap.org/copyright" rel="noopener">© OpenStreetMap contributors</a>, ODbL 1.0',
osm: '<a href="https://www.openstreetmap.org/copyright" rel="noopener">© OpenStreetMap contributors</a>, ODbL 1.0',
@@ -33,12 +34,14 @@ const safeImage = (v) => {
@@ -33,12 +34,14 @@ const safeImage = (v) => {
try {
try {
const { protocol, hostname, pathname } = new URL(u);
const { protocol, hostname, pathname } = new URL(u);
if (protocol !== 'https:' || !IMAGE_HOSTS.test(hostname)) return null;
if (protocol !== 'https:' || !IMAGE_HOSTS.test(hostname)) return null;
- return /wikimedia\.org$/i.test(hostname) || /\.(jpe?g|png|webp|gif|avif|svg)$/i.test(pathname) ? u : null;
+ return /wikimedia\.org$/i.test(hostname) || (hostname === 'openvibe.media' && /^\/api\/v2\/tours\/objects\//.test(pathname))
+ || /\.(jpe?g|png|webp|gif|avif|svg)$/i.test(pathname) ? u : null;
} catch { return null; }
} catch { return null; }
};
};
-// Wikimedia answers a width through the Special:FilePath redirect (OSM/place pictures) or the
-// "<n>px-" segment of a thumb URL (Commons' iiurlwidth, city pictures). A stored original can be
-// turned into a thumb path. Any other picture is left as it is.
+const licensedPhoto = (p) => p && p.image_credit && p.image_license && p.image_source_url && safeImage(p.image_url);
+const credit = (p) => `<figcaption>Photo: ${esc(p.image_credit)} · ${esc(p.image_license)} · <a href="${esc(p.image_source_url)}" rel="noopener">source</a>${p.image_license_url ? ` · <a href="${esc(p.image_license_url)}" rel="noopener">licence</a>` : ''}</figcaption>`;
+// Wikimedia's imageinfo thumbnail URL is already a verified derivative. Only Special:FilePath
+// supports a requested width reliably; Media serves the cached production thumbnail directly.
function imageWidth(u, w) {
function imageWidth(u, w) {
if (!u) return null;
if (!u) return null;
try {
try {
@@ -48,16 +51,10 @@ function imageWidth(u, w) {
@@ -48,16 +51,10 @@ function imageWidth(u, w) {
return url.toString();
return url.toString();
}
}
if (/(^|\.)wikimedia\.org$/i.test(url.hostname)) {
if (/(^|\.)wikimedia\.org$/i.test(url.hostname)) {
- const thumb = url.pathname.match(/^(.*\/thumb\/.*\/)(\d+)px-([^/]+)$/);
- if (thumb) {
- url.pathname = `${thumb[1]}${w}px-${thumb[3]}`;
- return url.toString();
- }
- const original = url.pathname.match(/^\/(wikipedia\/[^/]+)\/([0-9a-f])\/([0-9a-f]{2})\/([^/]+)$/i);
- if (original) {
- url.pathname = `/${original[1]}/thumb/${original[2]}/${original[3]}/${original[4]}/${w}px-${original[4]}`;
- return url.toString();
- }
+ // Commons only guarantees the exact thumbnail URL returned by imageinfo. Asking for a new width
+ // can answer 400 for a file whose derivatives have not been rendered yet.
+ // Commons returns an original URL when the requested thumbnail would enlarge a small image.
+ // Turning that original back into a 640px thumbnail makes Wikimedia answer 400.
}
}
} catch { /* not a URL: use it unchanged */ }
} catch { /* not a URL: use it unchanged */ }
return null;
return null;
@@ -70,10 +67,10 @@ function pictureImg(u, { alt = '', sizes = '(max-width: 720px) 80vw, 330px', wid
@@ -70,10 +67,10 @@ function pictureImg(u, { alt = '', sizes = '(max-width: 720px) 80vw, 330px', wid
const srcset = pairs.length ? ` srcset="${esc(pairs.map(([w, v]) => `${v} ${w}w`).join(', '))}" sizes="${esc(sizes)}"` : '';
const srcset = pairs.length ? ` srcset="${esc(pairs.map(([w, v]) => `${v} ${w}w`).join(', '))}" sizes="${esc(sizes)}"` : '';
return `<img src="${esc(src)}"${srcset} width="${width}" height="${height}" alt="${esc(alt)}"${eager ? ' fetchpriority="high"' : ' loading="lazy"'} decoding="async">`;
return `<img src="${esc(src)}"${srcset} width="${width}" height="${height}" alt="${esc(alt)}"${eager ? ' fetchpriority="high"' : ' loading="lazy"'} decoding="async">`;
}
}
-/** kit.card with a Wikimedia picture resized to the card's 16:9 shape; any other card is unchanged. */
+/** Give safe card pictures a stable 16:9 box, using the verified Commons or Media URL. */
function card(opts) {
function card(opts) {
const html = kit.card(opts);
const html = kit.card(opts);
- if (!imageWidth(opts.image, IMAGE_WIDTHS[0])) return html;
+ if (!safeImage(opts.image)) return html;
return html.replace(/<img\b[^>]*>/, pictureImg(opts.image, { alt: opts.alt || '', sizes: '(max-width: 720px) 80vw, 330px' }));
return html.replace(/<img\b[^>]*>/, pictureImg(opts.image, { alt: opts.alt || '', sizes: '(max-width: 720px) 80vw, 330px' }));
}
}
const money = (n, c) => `${Number(n).toLocaleString('en-US', { maximumFractionDigits: 2 })} ${c}`;
const money = (n, c) => `${Number(n).toLocaleString('en-US', { maximumFractionDigits: 2 })} ${c}`;
@@ -105,7 +102,7 @@ const cityCard = (c) => card({
@@ -105,7 +102,7 @@ const cityCard = (c) => card({
href: `/city/${c.slug}`,
href: `/city/${c.slug}`,
title: c.name,
title: c.name,
type: 'map',
type: 'map',
- image: safeImage(c.image_url) || '',
+ image: licensedPhoto(c) || '',
alt: c.image_credit ? `${c.name} (${c.image_credit})` : c.name,
alt: c.image_credit ? `${c.name} (${c.image_credit})` : c.name,
badge: c.country || '',
badge: c.country || '',
who: c.country || '',
who: c.country || '',
@@ -122,10 +119,10 @@ const localName = (p) => (p && p.name_en && p.name && p.name !== p.name_en ? p.n
@@ -122,10 +119,10 @@ const localName = (p) => (p && p.name_en && p.name && p.name !== p.name_en ? p.n
const kindUnlessNamed = (p) => { const k = String(p.kind || '').replace(/_/g, ' '); return k && !placeName(p).toLowerCase().endsWith(k.toLowerCase()) ? k : ''; };
const kindUnlessNamed = (p) => { const k = String(p.kind || '').replace(/_/g, ' '); return k && !placeName(p).toLowerCase().endsWith(k.toLowerCase()) ? k : ''; };
const placeCard = (p) => card({
const placeCard = (p) => card({
- href: `/place/${p.id}`,
+ href: placePath(p),
title: placeName(p),
title: placeName(p),
type: 'map',
type: 'map',
- image: safeImage(p.image_url) || '',
+ image: licensedPhoto(p) || '',
badge: p.kind ? p.kind.replace(/_/g, ' ') : '',
badge: p.kind ? p.kind.replace(/_/g, ' ') : '',
who: p.city_name || '',
who: p.city_name || '',
peek: p.description ? String(p.description).slice(0, 120) : '',
peek: p.description ? String(p.description).slice(0, 120) : '',
@@ -175,8 +172,8 @@ const clip = (s, n) => { const t = String(s || '').trim(); if (t.length <= n) re
@@ -175,8 +172,8 @@ const clip = (s, n) => { const t = String(s || '').trim(); if (t.length <= n) re
function placeOfDayCard(p) {
function placeOfDayCard(p) {
if (!p) return '';
if (!p) return '';
return `<article class="tr-pod" data-place="${esc(p.id)}">
return `<article class="tr-pod" data-place="${esc(p.id)}">
-<a class="tr-pod-link" href="/place/${esc(p.id)}">
-${safeImage(p.image_url) ? pictureImg(p.image_url, { alt: placeName(p) }) : '<span class="tr-pod-noimg" aria-hidden="true">◆</span>'}
+<a class="tr-pod-link" href="${esc(placePath(p))}">
+${licensedPhoto(p) ? pictureImg(p.image_url, { alt: placeName(p) }) : '<span class="tr-pod-noimg" aria-hidden="true">◆</span>'}
<span class="tr-pod-body"><span class="tr-kicker">Place of the day · ${esc(p.city_name || '')}</span><strong>${esc(placeName(p))}</strong>
<span class="tr-pod-body"><span class="tr-kicker">Place of the day · ${esc(p.city_name || '')}</span><strong>${esc(placeName(p))}</strong>
<span class="tr-muted">${esc(p.kind ? p.kind.replace(/_/g, ' ') : 'place')}${p.description ? ` · ${esc(clip(p.description, 150))}` : ''}</span></span>
<span class="tr-muted">${esc(p.kind ? p.kind.replace(/_/g, ' ') : 'place')}${p.description ? ` · ${esc(clip(p.description, 150))}` : ''}</span></span>
</a></article>`;
</a></article>`;
@@ -184,12 +181,12 @@ ${safeImage(p.image_url) ? pictureImg(p.image_url, { alt: placeName(p) }) : '<sp
@@ -184,12 +181,12 @@ ${safeImage(p.image_url) ? pictureImg(p.image_url, { alt: placeName(p) }) : '<sp
/** The home's questions: the accordion on the page and the FAQPage data (server/http/discovery.js) are this one list. */
/** The home's questions: the accordion on the page and the FAQPage data (server/http/discovery.js) are this one list. */
const HOME_FAQ = [
const HOME_FAQ = [
- ['Where does the data come from?', 'OpenStreetMap (Overpass, tourism=*) under ODbL, Wikivoyage under CC BY-SA, Wikidata under CC0, OpenVibe.Live\'s public stream list and OpenVibe.Rent\'s public listings. Every city, place, route and source row shows its source and fetch time.'],
+ ['Where does the data come from?', 'Wikivoyage (CC BY-SA), Wikidata (CC0), Wikipedia (CC BY-SA) and OpenStreetMap (ODbL) for cities and places; photos from Wikimedia Commons and Openverse under CC BY, CC BY-SA, CC0 or public domain, each with its author and licence; OpenVibe.Live\'s public streams and OpenVibe.Rent\'s public listings. Every city, place, route and source row shows its source and fetch time.'],
['Are the routes real guided tours?', 'No. They are self-guided walks generated from Wikivoyage\'s See lists and marked as generated. Check opening hours and access before you go; the note at each stop is the Wikivoyage text, cited.'],
['Are the routes real guided tours?', 'No. They are self-guided walks generated from Wikivoyage\'s See lists and marked as generated. Check opening hours and access before you go; the note at each stop is the Wikivoyage text, cited.'],
['Do you sell flights or stays?', 'No. There is no flight API key in v1: the flight form opens the official airline-neutral search with the city pair filled in, with no affiliate parameter. Stays link to the listing on OpenVibe.Rent; Tours never takes a payment.'],
['Do you sell flights or stays?', 'No. There is no flight API key in v1: the flight form opens the official airline-neutral search with the city pair filled in, with no affiliate parameter. Stays link to the listing on OpenVibe.Rent; Tours never takes a payment.'],
['Is there audio on the stops?', 'Not yet. OpenVibe.AI has no speech-synthesis route today, so every stop says audio is off instead of shipping a broken player. The audio fields are in the schema for when an API exists.'],
['Is there audio on the stops?', 'Not yet. OpenVibe.AI has no speech-synthesis route today, so every stop says audio is off instead of shipping a broken player. The audio fields are in the schema for when an API exists.'],
['Who can see my trip?', 'Only you, until you share it. Everyone and Anyone with the link are readable by whoever has the address; Specific people is only the accounts you add, each Can view or Can edit; Only me is you. Your Share button changes one trip, and /trips sets what a new trip starts as. Delete it at any time; account deletion erases your trips.'],
['Who can see my trip?', 'Only you, until you share it. Everyone and Anyone with the link are readable by whoever has the address; Specific people is only the accounts you add, each Can view or Can edit; Only me is you. Your Share button changes one trip, and /trips sets what a new trip starts as. Delete it at any time; account deletion erases your trips.'],
- ['Why is a city missing?', 'The v1 catalogue is the top 100 cities by population; every city is fetched from Wikidata and Wikivoyage. A failed source run leaves the previous data and says so in the job receipt rather than inventing a place.'],
+ ['Why is a city missing?', 'It is not missing: search it. Any city or town opens on demand from OpenStreetMap, Wikidata and Wikivoyage (the first visit takes a moment while its places are gathered). A failed source run leaves the previous data and says so in the job receipt rather than inventing a place.'],
];
];
/** The walk scene: the first real route's own first stops, each linked from the route page. */
/** The walk scene: the first real route's own first stops, each linked from the route page. */
@@ -234,8 +231,8 @@ function home({ counts = {}, live = [], recent = [], placeOfDay = null, cities =
@@ -234,8 +231,8 @@ function home({ counts = {}, live = [], recent = [], placeOfDay = null, cities =
room: 'tours',
room: 'tours',
hero: {
hero: {
title: 'Go anywhere: fly, stay, walk, watch',
title: 'Go anywhere: fly, stay, walk, watch',
- lede: 'Places, notes and pictures from open data, IRL tours live on OpenVibe.Live, stays from OpenVibe.Rent, and a map for every walk. Every fact names its source.',
- search: { action: '/cities', name: 'q', placeholder: 'A city: Lisbon, Tokyo, Mexico City…', hints: ['Lisbon', 'Tokyo', 'Mexico City', 'Barcelona'], label: 'Find a city' },
+ lede: 'Places worth going in any city, with real photos and what to look for; plans and lists to go together; streams from the places themselves. Every fact names its source.',
+ search: { action: '/cities', name: 'q', placeholder: 'Any city: Seattle, Lisbon, Tokyo…', hints: ['Seattle', 'Lisbon', 'Tokyo', 'Mexico City'], label: 'Find a city' },
chips: [{ label: 'Live tours', href: '/live' }, { label: 'Routes', href: '/routes' }, { label: 'Stays', href: '/stays' }, { label: 'Flights', href: '/flights' }],
chips: [{ label: 'Live tours', href: '/live' }, { label: 'Routes', href: '/routes' }, { label: 'Stays', href: '/stays' }, { label: 'Flights', href: '/flights' }],
proof,
proof,
motif: heroMotif(),
motif: heroMotif(),
@@ -264,8 +261,8 @@ function home({ counts = {}, live = [], recent = [], placeOfDay = null, cities =
@@ -264,8 +261,8 @@ function home({ counts = {}, live = [], recent = [], placeOfDay = null, cities =
explore: {
explore: {
title: 'What a trip is made of',
title: 'What a trip is made of',
items: [
items: [
- { title: 'Cities', text: 'The top 100 first, from Wikidata coordinates and Wikivoyage pages.', href: '/cities', icon: 'map' },
- { title: 'Places', text: 'Tourism=* from OpenStreetMap, with the OSM link and fetch time.', href: '/cities', icon: 'pin' },
+ { title: 'Cities', text: 'Any city, on demand: things to do and free things to do, from Wikivoyage, Wikidata and OpenStreetMap.', href: '/cities', icon: 'map' },
+ { title: 'Places', text: 'Sights, parks, museums and viewpoints, each with a credited photo, why go, what to look for and how to visit.', href: '/cities', icon: 'pin' },
{ title: 'Live tours', text: 'IRL streams now and recent VODs from OpenVibe.Live.', href: '/live', icon: 'live' },
{ title: 'Live tours', text: 'IRL streams now and recent VODs from OpenVibe.Live.', href: '/live', icon: 'live' },
{ title: 'Stays', text: 'Rentals from OpenVibe.Rent; no payment is handled here.', href: '/stays', icon: 'home' },
{ title: 'Stays', text: 'Rentals from OpenVibe.Rent; no payment is handled here.', href: '/stays', icon: 'home' },
{ title: 'Flights', text: 'No key yet: a form opens the official airline-neutral search.', href: '/flights', icon: 'plane' },
{ title: 'Flights', text: 'No key yet: a form opens the official airline-neutral search.', href: '/flights', icon: 'plane' },
@@ -292,55 +289,150 @@ function home({ counts = {}, live = [], recent = [], placeOfDay = null, cities =
@@ -292,55 +289,150 @@ function home({ counts = {}, live = [], recent = [], placeOfDay = null, cities =
function citiesPage({ cities = [], q = '' } = {}) {
function citiesPage({ cities = [], q = '' } = {}) {
return `<div class="tr-wrap" id="tr-cities-page">
return `<div class="tr-wrap" id="tr-cities-page">
-<header class="tr-page-h"><p class="tr-kicker">Cities</p><h1>The top 100, from open data</h1>
-<p class="tr-lede">Coordinates and population from Wikidata, the page and its See list from Wikivoyage, places from OpenStreetMap. A city page lists its places, routes, live tours and stays.</p>
+<header class="tr-page-h"><p class="tr-kicker">Explore</p><h1>Things to do anywhere</h1>
+<p class="tr-lede">Search for any city or place. Pages are built from OpenStreetMap, Wikivoyage and Wikidata when you need them.</p>
<form class="tr-form tr-form-inline" method="get" action="/cities" role="search"><label class="tr-field"><span>Find a city</span><input type="search" name="q" value="${esc(q)}" maxlength="80" placeholder="Lisbon"></label><button class="tr-btn" type="submit">Search</button></form></header>
<form class="tr-form tr-form-inline" method="get" action="/cities" role="search"><label class="tr-field"><span>Find a city</span><input type="search" name="q" value="${esc(q)}" maxlength="80" placeholder="Lisbon"></label><button class="tr-btn" type="submit">Search</button></form></header>
+<form class="tr-form tr-form-inline" method="get" action="/places" role="search"><label class="tr-field"><span>Find a place</span><input type="search" name="q" maxlength="120" placeholder="Kubota Garden"></label><button class="tr-btn" type="submit">Find place</button></form>
${cities.length ? `<ol class="tr-city-list">${cities.map((c) => `<li class="tr-city-row"><a href="/city/${esc(c.slug)}">
${cities.length ? `<ol class="tr-city-list">${cities.map((c) => `<li class="tr-city-row"><a href="/city/${esc(c.slug)}">
-<span class="tr-city-photo">${safeImage(c.image_url) ? pictureImg(c.image_url, { alt: '', sizes: '58px', width: 58, height: 44, widths: [160, 330] }) : '<span aria-hidden="true">◆</span>'}</span>
+<span class="tr-city-photo">${licensedPhoto(c) ? pictureImg(c.image_url, { alt: '', sizes: '58px', width: 58, height: 44, widths: [160, 330] }) : '<span aria-hidden="true">◆</span>'}</span>
<span class="tr-city-text"><strong>${esc(c.name)}</strong><span class="tr-muted">${esc(c.country)}${c.population ? ` · ${Number(c.population).toLocaleString('en-US')} people` : ''}</span>
<span class="tr-city-text"><strong>${esc(c.name)}</strong><span class="tr-muted">${esc(c.country)}${c.population ? ` · ${Number(c.population).toLocaleString('en-US')} people` : ''}</span>
<span class="tr-muted">${Number(c.place_count) || 0} places · ${Number(c.route_count) || 0} routes</span></span></a></li>`).join('')}</ol>` : empty('No city matched that search.', '/cities', 'See all cities')}
<span class="tr-muted">${Number(c.place_count) || 0} places · ${Number(c.route_count) || 0} routes</span></span></a></li>`).join('')}</ol>` : empty('No city matched that search.', '/cities', 'See all cities')}
<p class="tr-fine">${SOURCE.wikidata} · ${SOURCE.wikivoyage} · ${SOURCE.osm}</p>
<p class="tr-fine">${SOURCE.wikidata} · ${SOURCE.wikivoyage} · ${SOURCE.osm}</p>
</div>`;
</div>`;
}
}
-function cityPage({ city, places = [], routes = [], live = [], stays = [] } = {}) {
- const img = safeImage(city.image_url);
+const OSM_DAY = { Mo: 'Mon', Tu: 'Tue', We: 'Wed', Th: 'Thu', Fr: 'Fri', Sa: 'Sat', Su: 'Sun', PH: 'holidays' };
+/** OpenStreetMap opening_hours in words for the common shapes ("Mo-Su 06:00-22:00" → "Every day 6:00–22:00"); any
+ * other syntax is shown exactly as written, never guessed. */
+function hoursInWords(value) {
+ const raw = String(value || '').trim();
+ if (!raw) return '';
+ if (raw === '24/7') return 'Open 24 hours';
+ const day = '(?:Mo|Tu|We|Th|Fr|Sa|Su|PH)';
+ const span = `${day}(?:-${day})?`;
+ const re = new RegExp(`^(${span}(?:,${span})*)\\s+(off|closed|\\d{1,2}:\\d{2}-\\d{1,2}:\\d{2}(?:,\\d{1,2}:\\d{2}-\\d{1,2}:\\d{2})*)$`);
+ const parts = raw.split(/\s*;\s*/).filter(Boolean).map((part) => {
+ const m = part.match(re);
+ if (!m) return null;
+ const days = m[1] === 'Mo-Su' ? 'Every day' : m[1].replace(/Mo|Tu|We|Th|Fr|Sa|Su|PH/g, (d) => OSM_DAY[d]).replace(/-/g, '–').replace(/,/g, ', ');
+ const times = /^(off|closed)$/.test(m[2]) ? 'closed' : m[2].replace(/\b0(\d):/g, '$1:').replace(/-/g, '–').replace(/,/g, ', ');
+ return `${days} ${times}`;
+ });
+ return parts.length && parts.every(Boolean) ? parts.join(' · ') : raw;
+}
+/** "Nov 8, 2026" from a stored ISO time. */
+const updatedOn = (iso) => {
+ const d = new Date(iso);
+ return Number.isFinite(d.getTime()) ? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }) : '—';
+};
+const hostOf = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return 'website'; } };
+const sourceName = (p) => (p.source === 'wikivoyage' ? 'Wikivoyage' : p.source === 'wikipedia' ? 'Wikipedia' : 'OpenStreetMap');
+/** A short credit for a photo inside a card: author, licence, source link (every image keeps all three). */
+const shortCredit = (p) => `<p class="tr-pcard-credit">Photo: ${esc(p.image_credit)} · ${esc(p.image_license)} · <a href="${esc(p.image_source_url)}" rel="noopener">source</a></p>`;
+
+/** One place in a city's grid: the photo (or its map pin when no licensed photo exists), name, kind and facts. */
+function placeTile(p) {
+ const photo = licensedPhoto(p);
+ const facts = [localName(p), kindUnlessNamed(p), p.address, p.wheelchair === 'yes' ? 'step-free' : '', p.fee === 'no' ? 'free to visit' : ''].filter(Boolean).join(' · ');
+ return `<li class="tr-pcard${photo ? '' : ' tr-pcard-text'}">
+${photo ? `<div class="tr-pcard-media">${pictureImg(p.image_url, { alt: placeName(p), width: 640, height: 480, sizes: '(max-width: 560px) 46vw, (max-width: 900px) 30vw, 280px' })}</div>` : ''}
+<div class="tr-pcard-body">${photo ? '' : '<svg class="tr-pcard-pin" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M12 22s7-6.2 7-12a7 7 0 1 0-14 0c0 5.8 7 12 7 12z" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="10" r="2.6" fill="currentColor"/></svg>'}<h3><a href="${esc(placePath(p))}">${esc(placeName(p))}</a></h3>${facts ? `<p class="tr-pcard-meta">${esc(facts)}</p>` : ''}${p.fee === 'no' ? '<span class="tr-pill">Free</span>' : ''}</div>
+${photo ? shortCredit(p) : ''}
+</li>`;
+}
+
+function cityPage({ city, places = [], routes = [], live = [], stays = [], free = false } = {}) {
+ const img = licensedPhoto(city);
+ // Photographed places first (rank order kept within each group): rows of the grid stay even.
+ places = [...places.filter((p) => licensedPhoto(p)), ...places.filter((p) => !licensedPhoto(p))];
+ const mapHref = `https://www.openstreetmap.org/?mlat=${city.lat}&mlon=${city.lon}#map=12/${city.lat}/${city.lon}`;
return `<div class="tr-wrap" id="tr-city-page">
return `<div class="tr-wrap" id="tr-city-page">
-<header class="tr-page-h tr-city-head">
+<nav class="crumbs" aria-label="Breadcrumbs"><a href="/cities">Cities</a> / ${esc(city.name)}</nav>
+<header class="tr-city-hero${img ? '' : ' tr-city-hero-plain'}">
+${img ? `<figure class="tr-city-cover">${pictureImg(img, { alt: city.name, sizes: '(max-width: 720px) 100vw, 1180px', width: 1200, height: 560, widths: [640, 1200], eager: true })}<figcaption>Photo: ${esc(city.image_credit)} · ${esc(city.image_license)} · <a href="${esc(city.image_source_url)}" rel="noopener">source</a>${city.image_license_url ? ` · <a href="${esc(city.image_license_url)}" rel="noopener">licence</a>` : ''}</figcaption></figure>` : ''}
+<div class="tr-city-intro">
<p class="tr-kicker">${esc(city.country)}${city.region ? ` · ${esc(city.region)}` : ''}</p>
<p class="tr-kicker">${esc(city.country)}${city.region ? ` · ${esc(city.region)}` : ''}</p>
-<h1>${esc(city.name)}</h1>
+<h1>${free ? 'Free things to do' : 'Things to do'} in ${esc(city.name)}</h1>
${city.summary ? `<p class="tr-lede">${esc(city.summary)}</p>` : note('No Wikivoyage summary was fetched for this city yet.', 'warn')}
${city.summary ? `<p class="tr-lede">${esc(city.summary)}</p>` : note('No Wikivoyage summary was fetched for this city yet.', 'warn')}
-<p class="tr-fine">${city.population ? `${Number(city.population).toLocaleString('en-US')} people · ` : ''}<a href="https://www.openstreetmap.org/?mlat=${city.lat}&mlon=${city.lon}#map=11/${city.lat}/${city.lon}" rel="noopener">Map</a> · <a href="${esc(city.source_url)}" rel="noopener">${esc(city.source === 'wikivoyage' ? 'Wikivoyage' : 'Wikidata')}</a> · fetched ${when(city.fetched_at)}</p>
-${img ? `<figure class="tr-city-img"><img src="${esc(img)}" alt="${esc(city.name)}" loading="lazy" decoding="async"><figcaption>${esc(city.image_credit || 'Wikimedia Commons')}${city.image_license ? ` · ${esc(city.image_license)}` : ''}${city.image_license_url ? ` · <a href="${esc(city.image_license_url)}" rel="noopener">licence</a>` : ''}</figcaption></figure>` : ''}
+<p class="tr-fine">${city.population ? `${Number(city.population).toLocaleString('en-US')} people · ` : ''}<a href="${esc(mapHref)}" rel="noopener">Map</a> · <a href="${esc(city.source_url)}" rel="noopener">${esc(city.source === 'wikivoyage' ? 'Wikivoyage' : 'Wikidata')}</a> · fetched ${esc(updatedOn(city.fetched_at))}</p>
+</div>
</header>
</header>
-${routes.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Self-guided routes</h2><span class="tr-muted">generated from Wikivoyage's See list</span></div><ol class="tr-route-list">${routes.map((r) => `<li><a href="/routes/${esc(r.id)}"><strong>${esc(r.title)}</strong><span class="tr-muted">${Number(r.stop_count)} stops · ${km(r.distance_m)} · ${Number(r.duration_min)} min</span></a></li>`).join('')}</ol></section>` : ''}
-<section class="tr-sec" id="tr-city-places"><div class="tr-sec-head"><h2>Places</h2><a class="tr-more" href="https://www.openstreetmap.org/?mlat=${city.lat}&mlon=${city.lon}#map=13/${city.lat}/${city.lon}" rel="noopener">OpenStreetMap</a></div>
-${places.length ? `<ol class="tr-place-list">${places.map((p) => `<li class="tr-place-row"><a href="/place/${esc(p.id)}"><strong>${esc(placeName(p))}</strong><span class="tr-muted">${esc([localName(p), kindUnlessNamed(p), p.address, p.wheelchair === 'yes' ? 'step-free' : '', p.fee === 'no' ? 'free to visit' : ''].filter(Boolean).join(' · '))}</span></a></li>`).join('')}</ol>` : empty('No OpenStreetMap place was stored for this city yet. The places job fills it.', '/cities', 'Another city')}
+<nav class="tr-seg" aria-label="Which places">
+<a href="/city/${esc(city.slug)}"${free ? '' : ' aria-current="page"'}>All places</a>
+<a href="/city/${esc(city.slug)}/free"${free ? ' aria-current="page"' : ''}>Free things to do</a>
+</nav>
+<section class="tr-sec" id="tr-city-places"><div class="tr-sec-head"><h2>${free ? 'Verified free places' : 'Places worth going'}${places.length ? ` <span class="tr-count">${places.length}</span>` : ''}</h2><a class="tr-more" href="${esc(mapHref)}" rel="noopener">Open the map</a></div>
+${free ? '<p class="tr-muted">Free means OpenStreetMap currently marks the place fee=no. Check the place’s own site before you go.</p>' : ''}
+${places.length ? `<ol class="tr-pgrid">${places.slice(0, 24).map(placeTile).join('')}</ol>${places.length > 24 ? `<details class="tr-fold"><summary>More places (${places.length - 24})</summary><ol class="tr-pgrid">${places.slice(24).map(placeTile).join('')}</ol></details>` : ''}` : empty(free ? 'No place has a confirmed free admission tag yet.' : 'No sourced place was stored for this city yet.', '/cities', 'Another city')}
<p class="tr-fine">${SOURCE.osm}</p></section>
<p class="tr-fine">${SOURCE.osm}</p></section>
-${live.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Live tours and recordings</h2><a class="tr-more" href="/live">All live</a></div><ol class="tr-live-list">${live.map((l) => `<li><a href="${esc(l.url)}" rel="noopener"><strong>${esc(l.title)}</strong><span class="tr-muted">${esc(l.kind === 'stream' ? 'Live now' : l.kind === 'clip' ? 'Clip' : 'Recording')} · ${esc(l.display_name || l.username)}${l.city_name ? ` · ${esc(l.city_name)}` : ''}${l.kind === 'stream' ? ` · ${Number(l.viewer_count)} watching` : ''}</span></a></li>`).join('')}</ol><p class="tr-fine">OpenVibe.Live's public stream list · fetched ${when(live[0].fetched_at)}</p></section>` : ''}
+${routes.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Self-guided routes</h2><span class="tr-muted">from Wikivoyage's See list</span></div><ol class="tr-route-list">${routes.map((r) => `<li><a href="/routes/${esc(r.id)}"><strong>${esc(r.title)}</strong><span class="tr-muted">${Number(r.stop_count)} stops · ${km(r.distance_m)} · ${Number(r.duration_min)} min</span></a></li>`).join('')}</ol></section>` : ''}
+${live.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Live tours and recordings</h2><a class="tr-more" href="/live">All live</a></div><ol class="tr-live-list">${live.map((l) => `<li><a href="${esc(l.url)}" rel="noopener"><strong>${esc(l.title)}</strong><span class="tr-muted">${esc(l.kind === 'stream' ? 'Live now' : l.kind === 'clip' ? 'Clip' : 'Recording')} · ${esc(l.display_name || l.username)}${l.city_name ? ` · ${esc(l.city_name)}` : ''}${l.kind === 'stream' ? ` · ${Number(l.viewer_count)} watching` : ''}</span></a></li>`).join('')}</ol><p class="tr-fine">OpenVibe.Live's public stream list · fetched ${esc(updatedOn(live[0].fetched_at))}</p></section>` : ''}
${stays.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Stays</h2><a class="tr-more" href="/stays">All stays</a></div><ol class="tr-stay-list">${stays.map((s0) => `<li><a href="${esc(s0.url)}" rel="noopener"><strong>${esc(s0.title)}</strong><span class="tr-muted">${esc(s0.kind)} · ${money(s0.price, s0.currency)} / ${esc(s0.period)}${s0.neighbourhood ? ` · ${esc(s0.neighbourhood)}` : ''}</span></a></li>`).join('')}</ol><p class="tr-fine">OpenVibe.Rent's public listings · never a payment on Tours.</p></section>` : ''}
${stays.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Stays</h2><a class="tr-more" href="/stays">All stays</a></div><ol class="tr-stay-list">${stays.map((s0) => `<li><a href="${esc(s0.url)}" rel="noopener"><strong>${esc(s0.title)}</strong><span class="tr-muted">${esc(s0.kind)} · ${money(s0.price, s0.currency)} / ${esc(s0.period)}${s0.neighbourhood ? ` · ${esc(s0.neighbourhood)}` : ''}</span></a></li>`).join('')}</ol><p class="tr-fine">OpenVibe.Rent's public listings · never a payment on Tours.</p></section>` : ''}
-<section class="tr-sec"><div class="tr-sec-head"><h2>Plan a trip here</h2></div><p class="tr-muted">Sign in, pick dates, save places from this city and add a route and a stay.</p><p><a class="tr-btn" href="/trips/new?city=${encodeURIComponent(city.slug)}">Start a trip in ${esc(city.name)}</a> <a class="tr-btn tr-btn-quiet" href="/flights?to=${encodeURIComponent(city.name)}">Find a flight</a></p></section>
+<section class="tr-sec tr-band"><h2>Plan a trip here</h2><p class="tr-muted">Pick dates, save places from ${esc(city.name)} and add a route and a stay.</p><p class="tr-actions"><a class="tr-btn" href="/trips/new?city=${encodeURIComponent(city.slug)}">Start a trip in ${esc(city.name)}</a> <a class="tr-btn tr-btn-quiet" href="/flights?to=${encodeURIComponent(city.name)}">Find a flight</a></p></section>
</div>`;
</div>`;
}
}
-function placePage({ place, routeList = [], trips = [] } = {}) {
+function placePage({ place, routeList = [], trips = [], nearby = [], live = [], summary = null } = {}) {
+ const sentences = (place.description || '').split(/(?<=[.!?])\s+/).filter(Boolean);
+ const seasonal = sentences.find((s) => /\b(spring|summer|autumn|fall|winter|bloom|blossom|season)\b/i.test(s));
+ const guided = sentences.find((s) => /\b(guided tour|free tour)\b/i.test(s));
+ const now = new Date();
+ const springYear = now.getUTCMonth() < 2 || (now.getUTCMonth() === 2 && now.getUTCDate() < 21) ? now.getUTCFullYear() : now.getUTCFullYear() + 1;
+ const springDate = `${springYear}-03-21`;
+ const name = placeName(place);
+ const lede = sentences[0] || `${name} in ${place.city_name}.`;
+ // Why go never repeats the lede: the AI summary when there is one, else the description after its first sentence.
+ const why = summary?.text || sentences.slice(1).join(' ');
+ const lookFor = place.guide_features && place.guide_features !== place.description ? place.guide_features : '';
+ const season = place.guide_season || seasonal || '';
+ const visit = place.guide_visit || guided || '';
+ const planHref = `https://openvibe.fun/plans/new?${new URLSearchParams({ place: name, city: place.city_name, url: `https://openvibe.tours${placePath(place)}`, lat: String(place.lat), lon: String(place.lon) })}`;
+ const listHref = `https://openvibe.fun/places/new?${new URLSearchParams({ place: name, url: `https://openvibe.tours${placePath(place)}` })}`;
+ const liveHref = `https://openvibe.live/broadcast?${new URLSearchParams({ place: name, place_url: `https://openvibe.tours${placePath(place)}`, title: `At ${name}` })}`;
+ const photos = [];
+ if (licensedPhoto(place)) photos.push({ url: place.image_url, credit: place.image_credit, license: place.image_license, sourceUrl: place.image_source_url, licenseUrl: place.image_license_url });
+ for (const photo of place.gallery || []) {
+ if (photos.length >= 6) break;
+ if (photos.some((x) => x.url === photo.url)) continue;
+ if (licensedPhoto({ image_url: photo.url, image_credit: photo.credit, image_license: photo.license, image_source_url: photo.sourceUrl })) photos.push(photo);
+ }
+ const figure = (photo, i) => `<figure class="${i ? 'tr-shot' : 'tr-hero-shot'}">${pictureImg(photo.url, i
+ ? { alt: `${name}, photo ${i + 1} of ${photos.length}`, sizes: '(max-width: 720px) 70vw, 280px', width: 440, height: 300 }
+ : { alt: name, sizes: '(max-width: 720px) 100vw, 760px', width: 1000, height: 620, widths: [640, 1000], eager: true })}<figcaption>Photo: ${esc(photo.credit)} · ${esc(photo.license)} · <a href="${esc(photo.sourceUrl)}" rel="noopener">source</a>${photo.licenseUrl ? ` · <a href="${esc(photo.licenseUrl)}" rel="noopener">licence</a>` : ''}</figcaption></figure>`;
+ const hours = hoursInWords(place.opening_hours);
+ const admission = place.fee === 'no' ? 'Free to visit' : place.fee === 'yes' ? 'Paid admission' : 'Admission not confirmed';
+ const reminder = `<form method="get" action="/remind-place/${esc(place.id)}" class="tr-form tr-form-inline tr-remind"><label class="tr-field"><span>Remind us on a date</span><input type="date" name="date" required></label><button class="tr-btn tr-btn-quiet" type="submit">Set reminder</button></form>`;
return `<div class="tr-wrap" id="tr-place-page">
return `<div class="tr-wrap" id="tr-place-page">
<nav class="crumbs" aria-label="Breadcrumbs"><a href="/cities">Cities</a> / <a href="/city/${esc(place.city_slug)}">${esc(place.city_name)}</a></nav>
<nav class="crumbs" aria-label="Breadcrumbs"><a href="/cities">Cities</a> / <a href="/city/${esc(place.city_slug)}">${esc(place.city_name)}</a></nav>
-<header class="tr-page-h"><p class="tr-kicker">${esc(String(place.kind || '').replace(/_/g, ' '))}</p><h1>${esc(placeName(place))}</h1>${localName(place) ? `\n<p class="tr-local-name">${esc(localName(place))}</p>` : ''}
-<p class="tr-lede">${esc(place.description || `${placeName(place)} in ${place.city_name}, from OpenStreetMap.`)}</p>
-<p class="tr-fine">${place.osm_url ? `<a href="${esc(place.osm_url)}" rel="noopener">OpenStreetMap ${esc(place.osm_type)} ${esc(place.osm_id)}</a> · ` : ''}<a href="${esc(place.source_url)}" rel="noopener">${esc(place.source === 'wikivoyage' ? 'Wikivoyage' : 'source')}</a> · fetched ${when(place.fetched_at)}${place.wikidata_id ? ` · <a href="https://www.wikidata.org/wiki/${esc(place.wikidata_id)}" rel="noopener">Wikidata</a>` : ''}</p></header>
-${safeImage(place.image_url) ? `<figure class="tr-place-img"><img src="${esc(place.image_url)}" alt="${esc(placeName(place))}" loading="lazy" decoding="async"><figcaption>${esc(place.image_credit || 'OpenStreetMap contributor')}${place.image_license ? ` · ${esc(place.image_license)}` : ''}</figcaption></figure>` : ''}
-<dl class="tr-facts">
-${place.address ? `<div><dt>Address</dt><dd>${esc(place.address)}</dd></div>` : ''}
-${place.opening_hours ? `<div><dt>Opening hours</dt><dd>${esc(place.opening_hours)}</dd></div>` : ''}
-${place.wheelchair ? `<div><dt>Step-free</dt><dd>${esc(place.wheelchair)}</dd></div>` : ''}
-${place.fee ? `<div><dt>Fee</dt><dd>${esc(place.fee)}</dd></div>` : ''}
-<div><dt>Coordinates</dt><dd>${Number(place.lat).toFixed(5)}, ${Number(place.lon).toFixed(5)} · <a href="${esc(place.map_url)}" rel="noopener">map</a></dd></div>
-${safeHttp(place.website) ? `<div><dt>Website</dt><dd><a href="${esc(place.website)}" rel="nofollow noopener">${esc(place.website)}</a></dd></div>` : ''}
-</dl>
+<div class="tr-place-top${photos.length ? '' : ' tr-place-top-solo'}">
+<header class="tr-place-head">
+<p class="tr-kicker">${esc(String(place.kind || 'place').replace(/_/g, ' '))}${place.fee === 'no' ? ' <span class="tr-pill">Free</span>' : ''}</p>
+<h1>${esc(name)}</h1>${localName(place) ? `\n<p class="tr-local-name">${esc(localName(place))}</p>` : ''}
+<p class="tr-lede">${esc(lede)}</p>
+<div class="tr-together"><p class="tr-together-h">Go here together</p><p class="tr-actions"><a class="tr-btn" href="${esc(planHref)}">Make a plan together</a> <a class="tr-btn tr-btn-quiet" href="${esc(listHref)}">Add to Our places</a></p><p class="tr-fine">A date poll for your people, or a shared list of places you want to see.</p></div>
+</header>
+${photos.length ? `<div class="tr-place-media">${figure(photos[0], 0)}${photos.length > 1 ? `<div class="tr-rail-wrap"><p class="tr-rail-count">${photos.length - 1} more ${photos.length - 1 === 1 ? 'photo' : 'photos'}</p><ul class="tr-rail" aria-label="More photos of ${esc(name)}">${photos.slice(1).map((p, i) => `<li>${figure(p, i + 1)}</li>`).join('')}</ul></div>` : ''}</div>` : ''}
+<ul class="tr-quick" aria-label="At a glance">
+${place.fee === 'no' ? '<li><strong>Free to visit</strong><span>OpenStreetMap</span></li>' : ''}
+${hours ? `<li><strong>${esc(hours)}</strong><span>opening hours</span></li>` : ''}
+${place.wheelchair ? `<li><strong>${place.wheelchair === 'yes' ? 'Step-free' : place.wheelchair === 'limited' ? 'Partly step-free' : 'Not step-free'}</strong><span>access</span></li>` : ''}
+<li><a href="${esc(place.map_url)}" rel="noopener"><strong>Directions</strong><span>${place.address ? esc(place.address) : 'open the map'}</span></a></li>
+${safeHttp(place.website) ? `<li><a href="${esc(place.website)}" rel="nofollow noopener"><strong>Official site</strong><span>${esc(hostOf(place.website))}</span></a></li>` : ''}
+${place.fee === 'no' ? '' : `<li><strong>${esc(admission)}</strong><span>${place.fee ? 'OpenStreetMap' : 'check before you go'}</span></li>`}
+</ul>
+</div>
+<div class="tr-guide">
+${why ? `<section class="tr-sec"><h2>Why go</h2><p>${esc(why)}</p><p class="tr-fine">${summary ? 'A summary by OpenVibe.AI of the cited sources · ' : ''}Source: <a href="${esc(place.source_url)}" rel="noopener">${esc(sourceName(place))}</a></p></section>` : ''}
+${lookFor ? `<section class="tr-sec"><h2>Look for</h2><p>${esc(lookFor)}</p><p class="tr-fine">Source: <a href="${esc(place.source_url)}" rel="noopener">${esc(sourceName(place))}</a></p></section>` : ''}
+<section class="tr-sec"><h2>How to visit</h2><p>${esc(admission)}${place.fee ? ' (OpenStreetMap)' : ''}. Check the hours and access before you travel.</p>${visit ? `<p>${esc(visit)} <a href="${esc(place.source_url)}" rel="noopener">Source</a></p>` : ''}${place.address ? `<p class="tr-muted">${esc(place.address)}</p>` : ''}</section>
+${season ? `<section class="tr-sec tr-season"><h2>Best time</h2><p>${esc(season)}</p><p class="tr-fine"><a href="${esc(place.source_url)}" rel="noopener">Source</a></p><p class="tr-actions"><a class="tr-btn tr-btn-quiet" href="/remind-place/${esc(place.id)}?date=${springDate}">Remind us in spring</a></p>${reminder}</section>` : ''}
+</div>
+${season ? '' : `<section class="tr-sec"><h2>Come back later</h2><p class="tr-muted">Ask for a reminder on the day you want to go.</p>${reminder}</section>`}
+<section class="tr-sec"><div class="tr-sec-head"><h2>See it live</h2><a class="tr-btn tr-btn-quiet" href="${esc(liveHref)}">Stream it from here</a></div>${live.length ? `<ol class="tr-live-list">${live.map((l) => `<li><a href="${esc(l.url)}" rel="noopener"><strong>${esc(l.title)}</strong><span class="tr-muted">${esc(l.kind === 'stream' ? 'Live now' : l.kind === 'clip' ? 'Clip' : 'Recording')} · ${esc(l.display_name || l.username)}</span></a></li>`).join('')}</ol>` : '<p class="tr-muted">No public streams or clips from here yet. Stream it and you are the first: OpenVibe.Live opens its go-live setup, and you choose when to start.</p>'}</section>
+${nearby.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Nearby</h2></div><ol class="tr-pgrid tr-pgrid-small">${nearby.map(placeTile).join('')}</ol></section>` : ''}
${routeList.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>On these routes</h2></div><ol class="tr-route-list">${routeList.map((r) => `<li><a href="/routes/${esc(r.id)}"><strong>${esc(r.title)}</strong><span class="tr-muted">${esc(r.city_name)} · ${Number(r.stop_count)} stops</span></a></li>`).join('')}</ol></section>` : ''}
${routeList.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>On these routes</h2></div><ol class="tr-route-list">${routeList.map((r) => `<li><a href="/routes/${esc(r.id)}"><strong>${esc(r.title)}</strong><span class="tr-muted">${esc(r.city_name)} · ${Number(r.stop_count)} stops</span></a></li>`).join('')}</ol></section>` : ''}
-${trips.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Save it to a trip</h2></div><form class="tr-form tr-form-inline" method="post" action="/trips/${esc(trips[0].slug)}/places"><input type="hidden" name="place_id" value="${esc(place.id)}"><label class="tr-field"><span>Trip</span><select name="trip">${trips.map((t) => `<option value="${esc(t.slug)}">${esc(t.title)}</option>`).join('')}</select></label><label class="tr-field"><span>Note (optional)</span><input name="note" maxlength="500"></label><button class="tr-btn" type="submit">Save place</button></form></section>` : `<section class="tr-sec"><p class="tr-muted"><a href="/auth/login?next=${encodeURIComponent(`/place/${place.id}`)}">Sign in</a> to save this place to a trip.</p></section>`}
-<p class="tr-fine">${SOURCE.osm}</p>
+${trips.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Save it to a trip</h2></div><form class="tr-form tr-form-inline" method="post" action="/trips/${esc(trips[0].slug)}/places"><input type="hidden" name="place_id" value="${esc(place.id)}"><label class="tr-field"><span>Trip</span><select name="trip">${trips.map((t) => `<option value="${esc(t.slug)}">${esc(t.title)}</option>`).join('')}</select></label><label class="tr-field"><span>Note (optional)</span><input name="note" maxlength="500"></label><button class="tr-btn" type="submit">Save place</button></form></section>` : `<section class="tr-sec"><p class="tr-muted"><a href="/auth/login?next=${encodeURIComponent(placePath(place))}">Sign in</a> to save this place to a trip.</p></section>`}
+<footer class="tr-sources"><h2>Sources</h2><p>${place.osm_url ? `<a href="${esc(place.osm_url)}" rel="noopener">OpenStreetMap</a> · ` : ''}<a href="${esc(place.source_url)}" rel="noopener">${esc(sourceName(place))}</a>${place.wikidata_id ? ` · <a href="https://www.wikidata.org/wiki/${esc(place.wikidata_id)}" rel="noopener">Wikidata</a>` : ''} · fetched ${esc(updatedOn(place.fetched_at))}</p><p>${SOURCE.osm}</p></footer>
</div>`;
</div>`;
}
}
@@ -478,7 +570,7 @@ ${canEdit ? `<section class="tr-sec tr-card"><h2>Share and edit</h2><p class="tr
@@ -478,7 +570,7 @@ ${canEdit ? `<section class="tr-sec tr-card"><h2>Share and edit</h2><p class="tr
<label class="tr-field"><span>Notes</span><textarea name="notes" rows="3" maxlength="2000">${esc(trip.notes || '')}</textarea></label>
<label class="tr-field"><span>Notes</span><textarea name="notes" rows="3" maxlength="2000">${esc(trip.notes || '')}</textarea></label>
<button class="tr-btn" type="submit">Save the trip</button></form></details></section>` : ''}
<button class="tr-btn" type="submit">Save the trip</button></form></details></section>` : ''}
<section class="tr-sec"><div class="tr-sec-head"><h2>Saved places</h2>${canEdit ? '' : `<span class="tr-muted">${places.length} places</span>`}</div>
<section class="tr-sec"><div class="tr-sec-head"><h2>Saved places</h2>${canEdit ? '' : `<span class="tr-muted">${places.length} places</span>`}</div>
-${places.length ? `<ol class="tr-trip-places">${places.map((p) => `<li><span class="tr-stop-num" aria-hidden="true">${Number(p.position)}</span><div><a href="/place/${esc(p.id)}"><strong>${esc(placeName(p))}</strong></a><span class="tr-muted">${localName(p) ? `${esc(localName(p))} · ` : ''}${esc(p.city_name)}${p.trip_note ? ` · ${esc(p.trip_note)}` : ''}</span>${canEdit ? `<form class="tr-inline-form" method="post" action="/trips/${esc(trip.slug)}/places/${esc(p.id)}/remove"><button class="tr-link-btn" type="submit">Remove</button></form>` : ''}</div></li>`).join('')}</ol>` : empty('No place saved yet. Open a city and a place, then save it here.', trip.city_slug ? `/city/${trip.city_slug}` : '/cities', 'Find places')}
+${places.length ? `<ol class="tr-trip-places">${places.map((p) => `<li><span class="tr-stop-num" aria-hidden="true">${Number(p.position)}</span><div><a href="${esc(placePath(p))}"><strong>${esc(placeName(p))}</strong></a><span class="tr-muted">${localName(p) ? `${esc(localName(p))} · ` : ''}${esc(p.city_name)}${p.trip_note ? ` · ${esc(p.trip_note)}` : ''}</span>${canEdit ? `<form class="tr-inline-form" method="post" action="/trips/${esc(trip.slug)}/places/${esc(p.id)}/remove"><button class="tr-link-btn" type="submit">Remove</button></form>` : ''}</div></li>`).join('')}</ol>` : empty('No place saved yet. Open a city and a place, then save it here.', trip.city_slug ? `/city/${trip.city_slug}` : '/cities', 'Find places')}
${canEdit ? `<form class="tr-form tr-form-inline" method="post" action="/trips/${esc(trip.slug)}/places"><label class="tr-field"><span>Place id</span><input name="place_id" required placeholder="plc_…" pattern="plc_[0-9A-HJKMNP-TV-Z]{26}"></label><label class="tr-field"><span>Note</span><input name="note" maxlength="500"></label><button class="tr-btn" type="submit">Save place</button></form><p class="tr-fine">The place id is on the place page. In the API: POST /api/v1/trips/${esc(trip.slug)}/places.</p>` : ''}
${canEdit ? `<form class="tr-form tr-form-inline" method="post" action="/trips/${esc(trip.slug)}/places"><label class="tr-field"><span>Place id</span><input name="place_id" required placeholder="plc_…" pattern="plc_[0-9A-HJKMNP-TV-Z]{26}"></label><label class="tr-field"><span>Note</span><input name="note" maxlength="500"></label><button class="tr-btn" type="submit">Save place</button></form><p class="tr-fine">The place id is on the place page. In the API: POST /api/v1/trips/${esc(trip.slug)}/places.</p>` : ''}
</section>
</section>
<section class="tr-sec"><div class="tr-sec-head"><h2>Route</h2>${canEdit ? `<a class="tr-more" href="${trip.city_slug ? `/city/${trip.city_slug}` : '/routes'}">Find a route</a>` : ''}</div>
<section class="tr-sec"><div class="tr-sec-head"><h2>Route</h2>${canEdit ? `<a class="tr-more" href="${trip.city_slug ? `/city/${trip.city_slug}` : '/routes'}">Find a route</a>` : ''}</div>
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+'use strict';
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { reusableImage } = require('../server/sources/wikidata');
+const { overpassQuery } = require('../server/sources/osm');
+const { streamOf, clipOf, placeIdOf } = require('../server/sources/live');
+const { rankPlace } = require('../server/jobs/places');
+const { extractGuide, parseEntity } = require('../server/sources/wikidata');
+
+(async () => {
+ await check('only credited, reusable Commons licences are accepted', async () => {
+ const base = { url: 'https://thumb.wikimedia.org/a.jpg', sourceUrl: 'https://commons.wikimedia.org/wiki/File:A.jpg', credit: 'A photographer' };
+ for (const license of ['CC BY 4.0', 'CC BY-SA 4.0', 'CC0', 'PDM']) assert.ok(reusableImage({ ...base, license }), license);
+ for (const license of ['CC BY-NC 4.0', 'CC BY-ND 4.0', null]) assert.ok(!reusableImage({ ...base, license }), String(license));
+ assert.ok(!reusableImage({ ...base, credit: null, license: 'CC BY 4.0' }));
+ assert.match(overpassQuery(47.6, -122.3, 15000), /leisure.*park\|garden/);
+ assert.ok(rankPlace({ imageUrl: 'photo' }, { sitelinks: 50 }, 10_000) > rankPlace({}, { sitelinks: 2 }, 100));
+ const guide = extractGuide('Kubota Garden is a public Japanese garden in Seattle.\n== Features and programs ==\nMajor features include the Bamboo Grove and Necklace of Ponds. A public tour runs in summer.\n== Flora ==\nJapanese maples change colour in the fall.', 'Kubota Garden');
+ assert.match(guide.features, /Bamboo Grove/);
+ assert.match(guide.visit, /public tour/);
+ assert.strictEqual(guide.season, null, 'a maple species description is not a best-time recommendation');
+ assert.strictEqual(extractGuide('Kubota Garden is a public Japanese garden in Seattle. Visitors cross its bridge. Kubota Garden blooms in spring.', 'Kubota Garden').season,
+ 'Kubota Garden blooms in spring.');
+ assert.strictEqual(extractGuide('Kubota Garden is a public Japanese garden in Seattle. Visitors cross its bridge.', 'Kubota Garden').why,
+ 'Visitors cross its bridge.');
+ assert.strictEqual(parseEntity({ id: 'Q1', claims: { P373: [{ mainsnak: { datavalue: { value: 'Kubota Garden' } } }] } }).commonsCategory, 'Category:Kubota Garden');
+ });
+ await check('only canonical Tours place links attach Live streams and clips', async () => {
+ const id = 'plc_0123456789ABCDEFGHJKMNPQRS';
+ const place_url = `https://openvibe.tours/place/kubota-garden-${id}`;
+ assert.strictEqual(placeIdOf({ place_url }), id);
+ assert.strictEqual(placeIdOf({ place_url: 'https://elsewhere.example/place/kubota-garden-' + id }), null);
+ const options = { fetchedAt: new Date().toISOString(), liveUrl: 'https://openvibe.live', videoBase: 'https://openvibe.video' };
+ assert.strictEqual(streamOf({ id: 1, username: 'ada', place_url }, options).placeId, id);
+ assert.strictEqual(clipOf({ id: 2, username: 'ada', place_url }, options).placeId, id);
+ });
+ const t = await boot();
+ try {
+ t.upstreams.state.nominatim.seattle = [{
+ name: 'Seattle', type: 'city', addresstype: 'city', lat: '47.6062', lon: '-122.3321',
+ address: { city: 'Seattle', state: 'Washington', country: 'United States', country_code: 'us' },
+ extratags: { wikidata: 'Q5083' },
+ }];
+ t.upstreams.state.nominatim['kubota garden'] = [{
+ name: 'Kubota Garden', type: 'garden', osm_type: 'way', osm_id: 42006992,
+ lat: '47.51282', lon: '-122.26607', address: { city: 'Seattle' },
+ extratags: { website: 'https://www.kubotagarden.org/', wikidata: 'Q6441303' },
+ }];
+ await check('a city outside the seed is built once and cached', async () => {
+ const api = await t.get('/api/v1/cities?q=seattle');
+ assert.strictEqual(api.status, 200);
+ assert.strictEqual(api.json().count, 1);
+ const first = await t.get('/city/seattle');
+ assert.strictEqual(first.status, 200);
+ assert.match(first.text, /Things to do in Seattle/);
+ assert.match(first.text, /Kubota Garden/);
+ assert.match(first.text, /Photo: A photographer/);
+ const second = await t.get('/city/seattle');
+ assert.strictEqual(second.status, 200);
+ const calls = t.upstreams.state.requests.filter((r) => r.path === '/search');
+ assert.strictEqual(calls.length, 1);
+ assert.match(calls[0].headers['user-agent'], /OpenVibeTours/);
+ });
+ await check('free view includes only explicit fee=no and place actions hand off safely', async () => {
+ const city = await t.ctx.store.cityBySlug('seattle');
+ const free = await t.get('/city/seattle/free');
+ assert.strictEqual(free.status, 200);
+ assert.match(free.text, /Free things to do in Seattle/);
+ assert.match(free.text, /The old tower/);
+ assert.doesNotMatch(free.text, /Museum of the city/);
+ const place = (await t.ctx.store.cityPlaces(city.id)).find((p) => p.name === 'Kubota Garden');
+ const detail = await t.get(`/place/${place.id}`);
+ assert.match(detail.text, /Go here together/);
+ assert.match(detail.text, /openvibe\.fun\/plans\/new/);
+ assert.match(detail.text, /openvibe\.live\/broadcast\?/);
+ const placeSearch = await t.get('/api/v1/places?q=Kubota%20Garden');
+ assert.strictEqual(placeSearch.status, 200);
+ assert.strictEqual(placeSearch.json().place.name, 'Kubota Garden');
+ const date = new Date(Date.now() + 30 * 86_400_000).toISOString().slice(0, 10);
+ const reminder = await t.get(`/remind-place/${place.id}?date=${date}`);
+ assert.strictEqual(reminder.status, 303);
+ const target = new URL(reminder.headers.get('location'));
+ assert.strictEqual(target.host, 'openvibe.today');
+ assert.strictEqual(target.searchParams.get('from'), 'openvibe.tours');
+ assert.strictEqual(target.searchParams.get('at'), `${date}T18:00:00Z`);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((err) => { process.stdout.write(String(err.stack || err)); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+{
+ "retrieved": "2026-11-08",
+ "source": "https://en.wikivoyage.org/wiki/Seattle",
+ "districts": [
+ "Seattle/Downtown",
+ "Seattle/Pioneer Square-International District",
+ "Seattle/Queen Anne-South Lake Union",
+ "Seattle/Capitol Hill-Central District",
+ "Seattle/Ballard",
+ "Seattle/Fremont",
+ "Seattle/University District",
+ "Seattle/North",
+ "Seattle/Sodo-Georgetown",
+ "Seattle/South",
+ "Seattle/West"
+ ],
+ "pages": [
+ {
+ "title": "Seattle",
+ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle",
+ "wikitext": "{{see\n| name=Seattle CityPASS | alt= | url=https://www.citypass.com/seattle | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree=\n| hours= | price=$127 for adult, $97 for children (ages 5-12), plus tax and a $2 processing fee per ticket\n| lastedit=2024-07-07\n| content=A discount pass that includes admission to five attractions for half the normal fee combined. You are entitled to up to two visits (within 24 hours) to the '''Space Needle''' and a visit to the '''Seattle Aquarium'''. You also get three choices of an '''Argosy Cruises Harbor Tour''', admission to the '''Museum of Pop Culture''', the '''Woodland Park Zoo''', the '''Pacific Science Center''' the '''Museum of Flight''', or '''Chihuly Garden & Glass'''. A CityPASS is valid for 9 consecutive days starting with the use of your first ticket. Transportation and parking are not included.\n}}\n{{do\n| name=Kenmore Air | alt= | url=https://kenmoreair.com | email=\n| address=950 Westlake Ave N | lat= | long= | directions=\n| phone=+1-866-435-9524 | tollfree= | fax=\n| hours= | price=$99.50 per person\n| content=20 minute plane tours over Seattle that are narrated by the pilot, with spectacular views of the city. Tours begin and end at the west side of Lake Union. Reservations required.\n}}\n{{do\n| name=Argosy Cruises | alt= | url=https://www.argosycruises.com/ | email=\n| address=1101 Alaskan Way, Pier 55 | lat= | long= | directions=\n| phone= | tollfree= | fax=\n| hours=Cruise schedule varies by season. Ticket booth: Daily 10AM-4:05PM | price=Harbor cruise $30.50. Locks cruise $42.50, bus connection $5 additional. Discounts for kids 4-12 and seniors\n| lastedit=2018-03-15\n| content=Boat tour company with special dinner and sightseeing cruises. The most common tour is the hour-long '''Harbor Cruise''' on Elliott Bay, which offers excellent views of the Space Needle, Downtown skyline, freight harbor, and usually some sea lions, with a barrage of informative commentary about local history or interesting ships or cargo passing through that day. The '''Locks Cruise''' is a 2-hour one-way trip (in alternating directions) through the locks separating Elliot Bay and Lake Union, with an optional bus ride if you prefer a round trip. Seasonal cruises include the '''Summer Views Cruise''' with a sunset on Puget Sound, and '''Husky Football Cruises''' on Lake Washington before the games. Most cruises offer a decent full-service bar with large drinks and some snacks.\n}}\n{{do\n| name=Waterways Cruises | alt= | url=https://www.waterwayscruises.com/ | email=\n| address=901 Fairview Ave N Suite A100 | lat=47.62784 | long=-122.33318 | directions=\n| phone=+1 206-223 2060 | tollfree= | fax=\n| hours=Cruises on Sat-Sun and sometimes Fri; see schedule online. Office: Mon-Fri 9AM-5PM | price=Brunch $65, Sunday supper $60, dinner $85\n| lastedit=2018-02-02\n| content=Luxury dining cruises touring Lake Union and Lake Washington. Regular cruises include 2-hour weekend brunch and Sunday supper buffets, and a 2½-hour four-course dinner (business casual attire).\n}}\n{{do\n| name=Chinese Lunar New Year | alt= | url=https://www.seattlechinatownid.com/experiences/lunar-new-year | email=\n| address= | lat= | long= | directions=International District\n| phone= | tollfree= | fax=\n| hours=Jan/Feb | price=\n| content=Numerous stalls and performances, and don't forget to partake in all the cheap food!\n}}\n{{do\n| name=Seattle International Film Festival | alt=SIFF | url=https://www.siff.net/ | [email protected]\n| address= | lat= | long= | directions=Downtown, Seattle Center, Uptown\n| phone=+1 206 464-5830 | tollfree= | fax=\n| hours=May | price=\n| wikidata=Q2399132\n| content=One of the largest film festivals in North America, showing international cinema. Watch indie films at Seattle area screens and vote for your favorite; each category winner receives a Golden Space Needle trophy. SIFF also screens films year-round at its venues.\n}}\n{{do\n| name=Northwest Folklife Festival | alt= | url=https://nwfolklife.org/ | [email protected]\n| address=305 Harrison St | lat= | long= | directions=Seattle Center\n| phone=+1-206-684-7300 | tollfree= | fax=\n| hours=Memorial Day weekend | price=Free ($10 donation per person per day encouraged)\n|lastedit=2026-01-21| content=A more low-key and local version of Bumbershoot, mainly family-friendly.\n}}\n{{do\n| name=Fremont Fair | alt= | url=https://fremontfair.com/ | email=\n| address= | lat= | long= | directions=Fremont\n| phone= | tollfree= | fax=\n| hours=a weekend in mid or late June | price=\n| content=Home of the Solstice Parade (including the nude bike ride), and a really fun drunken time all over Fremont. Vendors, bad live music and eclectic crowds at the bars make for an interesting time. Friends who live in Fremont become especially valuable for a place to crash during the fair.\n}}\n{{do\n| name=Seattle PrideFest | alt= | url=https://www.seattlepridefest.org/ | email=\n| address= | lat= | long= | directions=Seattle Center\n| phone= | tollfree= | fax=\n| hours=A weekend in June or July | price=\n| content=One of the biggest gay pride festivals in the country. Food carts, beer gardens, adult theme performances, and the eagerly anticipated Pride Parade.\n}}\n{{do\n| name=Bite of Seattle | alt= | url=https://www.biteofseattle.com/ | email=\n| address= | lat= | long= | directions=Seattle Center\n| phone= | tollfree= | fax=\n| hours=mid/late July | price=\n| content=Part of Seafair festivities. Enjoy some of the best delicacies of Seattle.\n}}\n{{do\n| name=Capitol Hill Block Party | alt= | url=https://www.capitolhillblockparty.com/ | email=\n| address= | lat= | long= | directions=Capitol Hill\n| phone= | tollfree= | fax=\n| hours=a mid-summer weekend | price=\n| content=Yearly live music event held on Capitol Hill over a weekend in mid-summer (usually the end of July). Consists primarily of local independent bands of various styles, coupled with some bigger name independent label acts.\n}}\n{{do\n| name=Seafair | alt= | url=https://www.seafair.org/ | email=\n| address= | lat= | long= | directions=everywhere\n| phone= | tollfree= | fax=\n| hours=July-early August | price=\n| content=Seattle's biggest festival, signifying the arrival of summer. Neighborhood events such as parades and street fairs run throughout the festival, with the downtown Torchlight Parade in late July. Seafair culminates in early August when hydroplane races and the Blue Angels bring loud, fast boats and planes to Lake Washington.\n}}\n{{do\n| name=Bumbershoot | alt= | url=https://bumbershoot.com/ | email=\n| address= | lat= | long= | directions=Seattle Center\n| phone= | tollfree= | fax=\n| hours=Labor Day weekend (early September) | price=\n| content=Seattle's largest music and arts festival, featuring dozens of local and world-class musical acts.\n}}\n{{do\n| name=Night Market & Autumn Moon Festival | alt= | url=https://www.seattlechinatownid.com/experiences/c-id-night-market | email=\n| address= | lat= | long= | directions=International District\n| phone= | tollfree= | fax=\n| hours=Early September | price=\n| content=Chinese festival of food and outdoor market.\n}}\n{{do\n| name=Luminata | alt= | url=https://fremontartscouncil.org/luminata | email=\n| address= | lat= | long= | directions=Green Lake\n| phone= | tollfree= | fax=\n| hours=Evening of the autumnal equinox | price=\n| content=A festival of lights at Green Lake, including a procession from the old Aqua Theatre to a clearing about 3/4 mile north.\n}}\n{{do\n| name=Festál Cultural Center | alt= | url=https://seattlecenter.com/events/featured-events/festal | email=\n| address= | lat= | long= | directions=Seattle Center\n| phone= | tollfree= | fax=\n| hours=Year-round except December | price=\n| content=Celebrate the world with festivals from about 25 countries represented, one country nearly every 2 weeks.\n}}"
+ },
+ {
+ "title": "Seattle/Downtown",
+ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/Downtown",
+ "wikitext": "{{see\n| name=Olympic Sculpture Park | alt= | url=https://www.seattleartmuseum.org/visit/olympic-sculpture-park | email=\n| address=2901 Western Ave | lat=47.61676 | long=-122.3553 | directions=\n| phone= | tollfree=\n| hours=Opens 30 minutes before sunrise, closes 30 minutes after sunset | price=Free\n| wikidata=Q7089122\n| lastedit=2025-03-14\n| content={{acre|9}} of green space on Seattle’s waterfront with a collection of monumental sculptures.\n}}\n{{see\n| name=Seattle Art Museum | alt=SAM | url=https://www.seattleartmuseum.org/ | [email protected]\n| address=100 University St | lat=47.6072 | long=-122.338 | directions=at 1st Ave\n| phone=+1 206 654-3100 | tollfree=\n| hours=Tue Wed Fri-Sun 10AM-5PM, Thu 10AM-9PM, closed Mon | price=Suggested admission: $19.50 adult, $17.50 senior/military, $2.50 students/youth, children 12 and under free. Admission is free on first Thursday of the month\n| wikipedia=Seattle Art Museum | image=SAM Art Ladder 02.jpg | wikidata=Q1816301\n| lastedit=2023-06-17\n| content=The museum displays a good assortment of art from the Pacific Northwest and around the world. Though the permanent exhibitions only occasionally delve deeply into a specific subject (such as the enormous variety of pieces in the porcelain room), extensive special exhibitions fill the gap. Tours available.\n}}\n{{see\n| name=Seattle Public Library-Central Library | alt=Seattle Central Library | url=https://www.spl.org/hours-and-locations/central-library | email=\n| address=1000 4th Ave | lat=47.6061 | long=-122.333 | directions=\n| phone=+1 206 386-4636 | tollfree= | fax=\n| hours=Mon-Thu 10AM-8PM, Fri Sat 10AM-6PM, Sun noon-6PM | price=Free\n| wikipedia=Seattle Central Library | image=Seattle Central Library, Seattle, Washington - 20060418.jpg | wikidata=Q2531939\n|lastedit=2023-06-17| content=A dramatic glass and steel structure in the heart of Downtown, designed by Rem Koolhaas. This is not an average public library and has become a tourist destination in its own right. A popular way to experience the unique architecture of the building is to take an elevator to the 10th floor, the highest observation deck in the building. From here you can walk down to the main floor through the Book Spiral; the core of the structure which organizes the library's books in one continuous path organized by the Dewey decimal system.\n}}\n{{see\n| name=Sky View Observatory | alt=Columbia Center | url=https://skyviewobservatory.com/ | email=\n| address=701 5th Ave | lat=47.604525 | long=-122.330539 | directions=Take an elevator to the 40th floor, then another elevator to the right to the 73rd floor.\n| phone=+1 206 386-5564 | tollfree= | fax=\n| hours=Daily 10AM-8PM (check website for extended summer hours and special hours for holidays) | price=$22 adults, $19 seniors and military with ID, $16.for kids 5 to 13\n| wikidata=Q908703\n|lastedit=2023-06-17| content=This observatory sits on the 73rd floor of the Columbia Center, one of the tallest buildings on the West Coast and the tallest public observation deck in the Pacific Northwest, sitting 932 feet (284.2 meters) above the ground and more than 330 feet taller than the Space Needle. Great views are offered from the top and some people claim the view is better from here than from the Space Needle. Another advantage over the Space Needle is that this building isn't well known as a tourist attraction, so there's usually little to no line to get to the top. Tickets are available for purchase online ($2 less expensive or in the atrium at the Columbia Center). There is a cafe in the observation deck serving sandwiches and drinks (including beer and wine) and a food court at the bottom of the Columbia Center.\n}}\n{{see\n| name=Tilikum Place | alt= | url=https://www.seattle.gov/parks/allparks/tilikum-place | email=\n| address=2701 5th Ave | lat=47.61834 | long=-122.34746 | directions=\n| phone=+1 206 684-4075 | tollfree=\n| hours=6AM-10PM | price=\n| wikipedia=Tilikum Place | wikidata=Q7802152\n|lastedit=2023-06-17| content=Etymologically meaning \"welcome\" in the native Chinook language, Tilikum is home to a statue of Chief Seattle, the chief of the Duwamish tribe that gives this city its name. Sit in the park or in the Tilikum Place Cafe's outdoor seating and enjoy views of the Space Needle while sipping a coffee or enjoying a lunch, and watch the monorail go by.\n}}\n{{see\n| name=Miner's Landing at Pier 57 | alt= | url=https://www.minerslanding.com/ | email=\n| address=1301 Alaskan Way | lat=47.60414 | long=-122.33961 | directions=on the waterfront below Pike Place Market\n| phone=+1 206 623-8600 | tollfree= | fax=+1 206 343-9173\n| hours= | price=\n| wikipedia=Pier 57 (Seattle) | wikidata=Q16983554\n|lastedit=2023-06-17| content=Named for once being the main departure point during the Seattle gold rush, Miner's Landing now hosts a food court and a variety of gourmet restaurants, a sports merchandise store, a carousel for kids and the Seattle Great Wheel.\n}}\n{{see\n| name=Seattle Great Wheel | alt= | url=https://seattlegreatwheel.com/ | email=\n| address=1301 Alaskan Way | lat=47.606139 | long=-122.342528 | directions=\n| phone=+1 206 623-8600 | tollfree= | fax=\n| hours=Hours vary by season; closes Fri Sat at midnight, 11PM (July–September) or 10PM (other months) all other days | price=$13, $8.50 ages 4-11\n| wikipedia=Seattle Great Wheel | image=Seattle Great Wheel.jpg | wikidata=Q7442108\n|lastedit=2023-06-17| content=Rising 175 feet in the air, this Ferris wheel offers a 15-minute ride in one of the wheel's 42 gondolas, each of which can hold up to 8 people. You'll get a wonderful view of the Seattle waterfront, the Downtown skyline, as well as the Puget Sound, the snow-covered Olympics, and the green city hills.\n}}\n{{see\n| name=Seattle Aquarium | alt= | url=https://www.seattleaquarium.org/ | email=\n| address=1483 Alaskan Way (on Pier 59 on the waterfront) | lat=47.6077 | long=-122.343 | directions=\n| phone=+1 206 386-4300 | tollfree= | fax=\n| hours=Exhibits open 9:30AM-6PM; last admission at 5PM | price=$21.95 adults, $14.95 children (4-12), children 3 and under free\n| wikipedia=Seattle Aquarium | image=Seattle - Pier 59 - 01.jpg | wikidata=Q4849544\n|lastedit=2023-06-17| content=Showcases native fish and mammals of the Pacific Northwest. Among the highlights are the Windows on Washington (WOW) exhibit, two touch tanks featuring animals of the inland sea and outer coast, and a display that holds two giant pacific octopuses, gill sharks and Pacific coral reefs. The outdoor exhibit upstairs features a collection of birds, harbor and Northern fur seals, as well as river and sea otters. At a separate area on the lower level, you can go inside an underwater dome to get up-close with swimming animals from the Puget Sound.\n}}\n{{do\n| name=Argosy Cruises | alt= | url=https://www.argosycruises.com/ | email=\n| address=1101 Alaskan Way, Pier 55 | lat=47.60476 | long=-122.33983 | directions=\n| phone= | tollfree= | fax=\n| hours=Cruise times vary by season | price=Prices vary according to your itinerary; $23.75 for harbor cruise\n|lastedit=2023-06-17| content=Offers special dinner and sightseeing cruises. The most common tour visitors take is the hour-long journey on Elliott Bay, which gives you an excellent view not just of the Space Needle and the Downtown skyline, but the freight harbor to the south as well.\n}}\n{{do\n| name=Seattle Convention Center | alt=formerly the Washington State Convention Center | url=https://seattleconventioncenter.com/ | email=\n| address=705 Pike St | lat= | long= | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q7972232\n| lastedit=2025-02-10\n| content=The largest events space in the state, and the host of PAX West, Emerald City Comic Con, Sakura-Con, the Northwest Flower and Garden Show, and many others. The {{sqft|414,722}} Arch building spans across Pike Street, 8th Avenue, and I-5. The {{sqft|573,770}} Summit building, opened in 2023, is a block north.\n}}\n{{do\n| name=Benaroya Hall | alt= | url=https://www.seattlesymphony.org/benaroyahall | email=\n| address=200 University St | lat=47.60810 | long=-122.33697 | directions=at 2nd Ave\n| phone=+1 206 215-4800 | tollfree= | fax=\n| hours=Free tours offered Tue and Fri at noon and 1PM | price=\n| wikipedia=Benaroya Hall | wikidata=Q3892069\n| content=An aesthetically and acoustically beautiful new concert hall. The official home of the Seattle Symphony Orchestra, it also houses other classical and performing arts roadshows.\n}}\n{{do\n| name=5th Avenue Theatre | alt= | url=https://www.5thavenue.org/ | [email protected]\n| address=1308 5th Ave | lat=47.60916 | long=-122.33398 | directions=between Union and University Streets on 5th Avenue\n| phone=+1 206-625-1900 | tollfree=\n| hours= | price=\n| wikidata=Q386819\n| content=The main Seattle venue for touring Broadway musical shows; also hosts a number of original musicals that later head to Broadway.\n}}\n{{do\n| name=Unexpected Productions | alt= | url=https://unexpectedproductions.org/ | [email protected]\n| address=1428 Post Alley | lat=47.60833 | long=-122.34026 | directions=Pike Place Market, look for the gum-covered wall in front\n| phone=+1 206-587-2414 | tollfree=\n| hours= | price=\n| wikidata=Q7884255\n| lastedit=2020-11-13\n| content=Seattle's longest running improv theater.\n}}\n{{do\n| name=Seattle International Film Festival (SIFF)| alt= | url=https://www.siff.net/ | [email protected]\n| address=[https://www.siff.net/cinema/cinema-venues SIFF Cinemas] and other venues | lat= | long= | directions=\n| phone=+1 206-464-5830 | tollfree= | fax=\n| hours=May | price=\n| wikidata=Q2399132\n| content=One of the largest film festivals in North America. Screenings are at the SIFF Cinema Uptown (3 screens), the SIFF Cinema Downtown, and two venues at Seattle Center, the SIFF Film Center and the PACCAR IMAX Theater.\n}}\n{{do\n| name=SIFF Cinema Downtown | url=https://www.siff.net/cinema/cinema-venues/siff-cinema-downtown | [email protected]\n| address=2100 4th Ave | lat=47.61394 | long=-122.34133 | directions=\n| phone=+1 206-464-5830 | tollfree= | fax=\n| hours= | price=[https://www.siff.net/cinema/box-office Variable pricing]\n| wikidata=Q7442077\n| content=One of just three Cinerama theaters in the world, this cinema shows art, indie, and foreign films and documentaries.\n}}"
+ },
+ {
+ "title": "Seattle/Pioneer Square-International District",
+ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/Pioneer_Square-International_District",
+ "wikitext": "{{see\n| name=Grand Opera House | alt= | url= | email=\n| address=213–217 Cherry St | lat=47.6031 | long=-122.332 | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q5594850\n| lastedit=2020-08-29\n| content=Formerly the city's leading theater. Only its exterior survives as the shell of a parking garage.\n}}\n{{see\n| name=Klondike Gold Rush National Historic Park - Seattle Unit | alt= | url=https://www.nps.gov/klse/ | email=\n| address=319 2nd Ave S | lat=47.59938 | long=-122.33189 | directions=at S Jackson St\n| phone=+1 206- 220-4240 | tollfree=\n| hours=Daily 9AM-5PM except Thanksgiving, Christmas, and New Year's Day | price=Free\n| wikipedia=Klondike Gold Rush National Historical Park | image=Seattle - Cadillac Hotel 01.jpg | wikidata=Q1774856\n| content=A key attraction in the Pioneer Square area, this is the Seattle branch of the Klondike Gold Rush National Historical Park, the remainder of which is in [[Alaska]]. The site highlights the city's key role as the \"Gateway to the Gold Fields\" for most of the Klondike stampeders of 1897-1898. National Park Service Rangers and volunteers staff the museum and can provide information and perspective not only on the gold rush but also on Seattle's past. The museum contains many artifacts and historical photographs related to the Gold Rush, and movies about the gold rush and Seattle's history are shown upon request (except in summer, when they're shown on a regular schedule). Rangers also conduct free walking tours of the Pioneer Square area in the summer and demonstrate gold panning to school groups.\n}}\n{{see\n| name=Nihonmachi Alley | alt= | url=https://www.wingluke.org/exhibit-nihonmachi-alley | email=\n| address= | lat=47.59965 | long=-122.32571 | directions=from Jackson St to Main St, between 6th Ave and Maynard Ave\n| phone= | tollfree=\n| hours= | price=\n| wikidata=\n| lastedit=2026-05-29\n| content=Japantown (Nihonmachi) once stretched 20 blocks, but the residents were forced into internment camps during World War II, and Nihonmachi never fully recovered. This alley features murals and exhibits of four landmark businesses that persisted after incarceration.\n}}\n{{see\n| name=Seattle Buddhist Church | alt= | url=https://seattlebetsuin.com/ | email=\n| address=1427 S Main St | lat=47.5997 | long=-122.313 | directions=south of Yesler Way\n| phone=+1 206-329-0800 | tollfree=\n| hours= | price=\n| wikipedia=Seattle Buddhist Church | image=Seattle Buddhist Church 01.jpg | wikidata=Q7442060\n| content=In the summer the community hosts a Japanese ''bon odori'' festival on the street out front.\n}}\n{{see\n| name=Smith Tower | alt= | url=https://www.smithtower.com/ | [email protected]\n| address=506 2nd Ave | lat=47.601925 | long=-122.331731 | directions=\n| phone=+1 206 624 0414 | tollfree=\n| hours=Mon-Wed Noon-7PM, Thu and Sun Noon-8PM, Fri and Sat Noon-9PM | price=Self tour and observatory: adults $27, seniors/military $24, students/children 5-13 $21, under 5 Free, locals with Washington state ID $18. Guided 45 minute tour is additional\n| wikipedia=Smith Tower | image=SmithTower Seattle WA USA2.jpg | wikidata=Q1196348\n| lastedit=2026-04-25\n| content=Built in 1914, the Smith Tower was Seattle's first skyscraper, and the tallest building on the West Coast for nearly half a century before being overtaken by the Space Needle. Uniformed attendants operate the old copper and brass elevators from which you can peer into the different floors and offices as you ascend, an experience most people today have probably only witnessed in old movies. The outdoor '''Observatory''' on the 35th floor wraps completely around the four sides of the tower, providing panoramic views of the Olympic and Cascade mountain ranges, including Mt. Rainier, as well as of Elliott Bay, Downtown Seattle, and Pioneer Square. The interior of the 35th floor contains a speakeasy-themed bar, formerly known as the '''Chinese Room''' for its ornately carved ceiling and a number of decorative flourishes imported from China in the early 20th century. There is an excellent 45 minute guided tour with a very knowledgeable and entertaining tour guide (separate admission fee) about Seattle's history from the 1920s and beyond that is available. Also, there is an excellent independent coffee shop (Caffe Vita) in the Smith Tower (hours M–F: 7AM-5PM, Sa–Su: 8AM–3PM). Washington state residents can get admission for only $18 (with Washington state ID) and after 4pm Monday to Thursday admission is available to all for only $18 to the observation deck and bar only (museum not included for this price).\n}}\n{{see\n| name=Wing Luke Museum of the Asian Pacific American Experience | alt=The Wing | url=https://www.wingluke.org/ | email=\n| address=719 S King St | lat=47.59825 | long=-122.32294 | directions=\n| phone=+1 206 623-5124 | tollfree=\n| hours=Tue-Sun 10AM-5PM | price=$25/adult, $22/senior, $18/students, $15/child\n| wikipedia=Wing Luke Museum of the Asian Pacific American Experience | image=Seattle - East Kong Yick Building 02.jpg | wikidata=Q8025096\n| lastedit=2026-03-15\n| content=The first Smithsonian affiliate in the Pacific Northwest, this museum features exhibits and programs related to pan-Asian American art, history, and culture. Admission includes a first-come-first-served guided tour of the preserved rooms in its historic 1910 building: an import grocery store, rooms of a hotel and a Chinese family association (allow 45-60 minutes). Neighborhood walking tours are also offered on weekends. Bruce Lee fans will enjoy the exhibits of his personal items. They are open until 8PM on the first Thursday of the month.\n}}\n{{see\n| name=Hing Hay Park | alt= | url=https://www.seattle.gov/parks/allparks/hing-hay-park | email=\n| address=423 Maynard Ave S | lat=47.59859 | long=-122.3254 | directions=\n| phone=+1 206 684-4075 | tollfree=\n| hours= | price=6AM-10PM\n| wikipedia=Hing Hay Park (Seattle) | image=Hing Hay Park 01.jpg | wikidata=Q14713670\n| lastedit=2019-05-28\n| content=A small park in the center of Chinatown. Though you're not too likely to see old folks doing tai chi or playing checkers, it still has character.\n}}\n{{see\n| name=Occidental Park | alt= | url=https://www.seattle.gov/parks/allparks/occidental-square | email=\n| address=Occidental Ave between Main St and Washington St | lat=47.6 | long=-122.3329 | directions=\n| phone=+1 206 684-4075 | tollfree=\n| hours=6AM-10PM | price=Free\n| wikipedia=Occidental Park (Seattle) | image=Seattle - Occidental Park 02.jpg | wikidata=Q7075692\n| lastedit=2019-05-28\n| content=This urban park is best known for its four totem poles carved by local artist Duane Pasco. It also has bocce courts, ping pong and foosball tables.\n}}\n{{see\n| name=Pioneer Square | alt= | url=https://www.seattle.gov/parks/find/parks/pioneer-square | email=\n| address= | lat=47.60199 | long=-122.33388 | directions=Yesler Way and 1st Avenue\n| phone= | tollfree= | fax=\n| hours= | price=\n| image=Seattle - Pioneer Square Park pano 01.jpg\n| content=Pioneer Square proper is just a small corner park that's often occupied by homeless folks, but generally safe during the day.\n}}\n{{see\n| name=Waterfall Garden | alt= | url= | email=\n| address=2nd Ave at S Main St | lat=47.60021 | long=-122.33198 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikidata=Q28153990| wikipedia=Waterfall Garden Park\n| content=This small, tranquil park features a 22-foot high waterfall cascading over granite boulders. The park occupies the site where two 19-year olds, James Casey and Claude Ryan, began a messenger service in 1907 in the basement of a tavern, which eventually grew up to become United Parcel Service (UPS). The Annie E. Casey Foundation, started by James Casey and his siblings in honor of their mother, created the park and maintains it \"in honor of the men and women of United Parcel Service.\"\n}}\n{{do\n| name=Art Walk | alt= | url=https://pioneersquare.org/art-walks/ | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree=\n| hours= | price=\n| content=The Art Walk is almost synonymous with Pioneer Square and takes place on the first Thursday of every month. Galleries are open until 9PM. Show up early for the free wine and hors d'oeuvres.\n}}\n{{do\n| name=Seattle Pinball Museum | alt= | url=https://www.facebook.com/pages/Seattle-Pinball-Museum/131237786915560 | [email protected]\n| address=508 Maynard Ave S | lat=47.59800 | long=-122.32487 | directions=\n| phone=+1 206 623-0759 | tollfree= | fax=\n| hours=Sun-Mon noon-5PM, Thu-Sat noon-10PM | price=$13/adult, $10/child\n| content=A pinball machine museum where all the machines are on free play once you pay admission. There is soda and beer available.\n}}\n{{do\n| name=Theatre Off Jackson | alt= | url=https://theatreoffjackson.org/ | email=\n| address=409 7th Ave S | lat=47.59886 | long=-122.32396 | directions=\n| phone=+1 206-340-1049 | tollfree=\n| hours= | price=\n| lastedit=2017-01-28\n| content=A venue for cutting-edge theatrical productions.\n}}\n{{do\n| name=Bill Speidel's Underground Tour | alt= | url=http://www.undergroundtour.com/ | email=\n| address=614 1st Ave | lat=47.602 | long=-122.334 | directions=\n| phone=+1 206 682-4646 | tollfree= | fax=\n| hours=Apr-Sep: daily 9AM-7PM, Oct-Mar: daily 10AM-6PM. Tours on the hour; Jun-Aug: also on the half-hour daily 9:30AM-4:30AM | price=Adult $22, senior/student $20, youth (7-12) $10\n| image=Seattle Underground - Skylights from underneath current street level.JPG\n| content=The original Underground tour. Tours last 75 minutes, including a 15 minute introduction.\n}}\n{{do\n| name=Beneath the Streets | alt= | url=https://www.beneath-the-streets.com/ | email=\n| address=102 Cherry St | lat=47.60275 | long=-122.33416 | directions=\n| phone=+1 206 624-1237 | tollfree= | fax=\n| hours=10AM-4:30PM, tours every 30 minutes | price=$19\n| content=Groups are limited to 25 people for a more personal experience. Tours last 60 minutes. Spanish language tours available by appointment.\n}}\n{{do\n| name=Beneath the Streets' Red Light District Tour | alt= | url=https://www.beneath-the-streets.com/ | email=\n| address=102 Cherry St | lat=47.60275 | long=-122.33416 | directions=\n| phone=+1 206 624-1237 | tollfree= | fax=\n| hours=Thu-Mon 6:30PM | price=$22\n| content=An adults-only tour with a focus on the \"underside\" of underground history: prostitution, gambling, drinking and vice. 18 and older. Tours last 90 minutes.\n}}\n{{do\n| name=Chinese Lunar New Year | alt= | url=https://www.seattlechinatownid.com/experiences/lunar-new-year | email=\n| address= | lat= | long= | directions=King St, Hing Hay Park\n| phone= | tollfree= | fax=\n| hours=Jan/Feb | price=\n| content=Numerous stalls and performances, and don't forget to partake in all the cheap food!\n}}\n{{do\n| name=Night Market & Autumn Moon Festival | alt= | url=https://www.seattlechinatownid.com/experiences/c-id-night-market | email=\n| address= | lat= | long= | directions=Centered on Hing Hay Park\n| phone= | tollfree= | fax=\n| hours=Early September | price=\n| content=Chinese festival of food and outdoor market.\n}}"
+ },
+ {
+ "title": "Seattle/Queen Anne-South Lake Union",
+ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/Queen_Anne-South_Lake_Union",
+ "wikitext": "{{see\n| name=Discovery Park | alt= | url=https://www.seattle.gov/parks/allparks/discovery-park | email=\n| address=3801 W Government Way | lat=47.660530 | long=-122.415720 | directions=\n| phone=+1 206-684-4075 | tollfree=\n| hours=4ː30AM-11PM | price=\n| wikipedia=Discovery Park (Seattle) | wikidata=Q3030228\n| lastedit=2026-01-09\n| content=The largest park in the city can be thought of as Seattle's unofficial central park given its huge, somewhat secluded nature, with a number of trails that meander through the green forests down to the lighthouse at the beach. Discovery Park used to be an army installation called Fort Lawton, and several of the fort's buildings remain. The park features the highest point in the city and wildlife you don't typically find in a large city. A visitors' center with information about the wildlife living in the park and the best trails to hike are in the center of the park. The visitor center is closed until summer 2027.\n}}\n{{see\n| name=Kerry Park | alt= | url=https://www.seattle.gov/parks/allparks/kerry-park | email=\n| address=211 W Highland Dr | lat=47.629600 | long=-122.359400 | directions=\n| phone=+1 206-684-4075 | tollfree=\n| hours=6AM-10PM | price=\n| wikipedia=Kerry Park (Seattle) | image=Seattle 4.jpg | wikidata=Q6394618\n| lastedit=2026-01-09\n| content=A photographer's paradise, this is perhaps the most obvious spectacle in Queen Anne. This park affords the quintessential view of Downtown Seattle with the Space Needle in the foreground, and (depending on the weather) Mount Rainier and the Cascades, as well as across the Puget Sound to Bainbridge Island.\n}}\n{{see\n| name=Bhy Kracke Park | alt= | url=https://www.seattle.gov/parks/parks/bhy-kracke-park | email=\n| address=1215 5th Ave N | lat= | long= | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q130286718\n| lastedit=2026-04-21\n| content=Pronounced \"By Crackie\". You can get a view comparable to that from the Space Needle for free.\n}}\n{{see\n| name=Fishermen's Terminal | alt= | url=https://www.portseattle.org/maritime/fishermen-terminal | email=\n| address= | lat=47.65578 | long=-122.38063 | directions=just southwest of the Ballard Bridge\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Fishermen's Terminal | wikidata=Q5454928\n| content=Carefully wander the docks (avoiding stepping on ropes or nets) and look at the boats. Many of them sell fresh crab or fish right off of the boat in season. Much of the Alaska crab fishing fleet moors here (or elsewhere in Lake Union) during the off-season, including vessels featured in the long-running ''Deadliest Catch'' television series. Visit the '''[https://www.seattlefishermensmemorial.org/ Fishermen's Memorial]''' dedicated to all of those who have lost their lives at sea while engaged in commercial fishing.\n}}\n{{see\n| name=Space Needle | alt= | url=https://www.spaceneedle.com/ | [email protected]\n| address=400 Broad St | lat=47.6204 | long=-122.3491 | directions=Seattle monorail is very close to the Needle and multiple buses have a stop nearby\n| phone=+1 206-905-2100 | tollfree=\n| hours=Fri Sat 8:30AM-11:30PM, Sun 8:30AM-10:30PM, Mon-Thu 9AM-10:30PM | price=standard admission is $55 (discounts of up to $20 per ticket if reservation made at lease 1 day in advance)\n| wikipedia=Space Needle | image=Space needle Seattle1.jpg | wikidata=Q5317\n| lastedit=2019-02-26\n| content=A symbol of Seattle, built for the 1962 World's Fair and still a wildly popular attraction. The view is spectacular on a clear day when the sun sets, when Downtown Seattle contrasts beautifully with the ocean to the west and the snow-capped mountains in every other direction. There are floor-to-ceiling windows on both levels, a rotating glass floor (\"The Loupe\") on the lower level that completes one revolution every 45 minutes, and open-air glass walls where you can lean backwards and take a selfie. The revolving Sky City restaurant has been closed due to renovations, but the wine bar is still open.\n}}\n{{see\n| name=Pacific Science Center | alt= | url=https://www.pacificsciencecenter.org/ | [email protected]\n| address=200 Sue Bird Ct N | lat=47.619200 | long=-122.351000 | directions=\n| phone=+1 206-443-2844 | tollfree= | fax=+1 206-443-3631\n| hours=Wed-Sun 10AM–5PM | price=Adults from̩ $22.95-̩$28.20 depending on day. Planetarium and daytime laser shows included, but requires timed tickets\n| wikipedia=Pacific Science Center | image=Pacific Science Center at night 04.jpg | wikidata=Q3848341\n| lastedit=2026-01-09\n| content=An interactive science museum featuring permanent and temporary exhibits, a butterfly atrium, IMAX theater, planetarium, and laser shows.\n}}\n{{see\n| name=Museum of Pop Culture | alt=MoPOP | url=https://www.mopop.org/ | [email protected]\n| address=325 5th Ave N | lat=47.6215 | long=-122.3486 | directions=\n| phone=+1 206-770-2700 | tollfree=\n| hours=Summer (Memorial Day-Labor Day) 10AM-7PM daily; rest of year 10AM-5PM daily | price=$28 adults, $25 seniors/students, $22 military, $19 youth, children 4 and under free. $2 discount if purchased online. Additional $8 for special exhibit. Outdoor playground free\n| wikipedia=Museum of Pop Culture | image=Aerial view of EMPSFM.jpg | wikidata=Q1384356\n| lastedit=2019-01-11\n| content=An excellent pop culture museum housed inside a visually striking building designed by Frank Gehry to \"evoke the rock 'n' roll experience\", with exhibits on all manner of popular culture: film, television, music, video games, fashion, etc. There's a particular focus on science fiction and Seattle musicians, with a sci-fi hall of fame and exhibits dedicated to Jimi Hendrix and Nirvana. Top-notch special exhibits have included the likes of David Bowie, ''Star Trek'', the Marvel comic and movie universe, and ''Minecraft''. There are many interactive exhibits, but don't expect to get a turn without a long wait. Outside, the large playground is a big hit with kids.\n}}\n{{see\n| name=Seattle Children's Museum | alt= | url=https://seattlechildrensmuseum.org/ | email=\n| address=200 2nd Ave N | lat=47.6214 | long=-122.351 | directions=\n| phone=+1 206-441-1768 | tollfree= | fax=\n| hours=Mon-Fri 10AM-5PM, Sat Sun 10AM-6PM | price=General exhibits: $7.50, $6.50 seniors, children under 1 free; no adults admitted without a child\n| wikipedia=Seattle Children's Museum | image=Children’s Museum (2891578446).jpg | wikidata=Q7442070\n|lastedit=2023-06-17| content=An independent non-profit museum which hosts popular traveling exhibits and features permanent exhibits and programs geared towards children.\n}}\n{{see\n| name=Chihuly Garden and Glass | alt= | url=https://www.chihulygardenandglass.com/ | [email protected]\n| address=305 Harrison St | lat=47.6206 | long=-122.35 | directions=\n| phone=+1 407 956-3527 | tollfree= | fax=\n| hours=Mon-Thu 10AM–10PM, Fri-Sun 10AM-11PM; last ticket sold one hour prior to closing; combined tickets with Space Needle available | price=$19 adults, $17 seniors, $12 children, children 3 and under free\n| wikipedia=Chihuly Garden and Glass | image=| wikidata=Q5097315\n|lastedit=2023-06-17| content=This museum provides a look at the inspiration and influences that inform the career of artist Dale Chihuly. The on-site exhibition hall contains eight galleries and three drawing walls, offering visitors a comprehensive look at Chihuly’s significant series of work. The centerpiece of the museum is a glasshouse with a suspended 1,400-piece, 100-foot-long sculpture. Also on the grounds is a lush garden, which serves as a backdrop for four monumental sculptures and other installations.\n}}\n{{see\n| name=Lake Union Park | alt= | url=https://www.seattle.gov/parks/find/parks/lake-union-park | email=\n| address=860 Terry Ave N | lat=47.626380 | long=-122.336640 | directions=on the Lake Union Waterfront\n| phone=+1 206-684-4075 | tollfree=\n| hours=4AM-11ː30PM | price=\n| wikipedia=Lake Union Park | image=Aerial South Lake Union Park May 2012.JPG | wikidata=Q6478207\n| lastedit=2026-01-09\n| content=Home to the Center for Wooden Boats and the Museum of History and Industry. Spray park open Memorial Day to Labor Day.\n}}\n{{see\n| name=The Center for Wooden Boats | alt= | url=https://www.cwb.org/ | email=\n| address=1010 Valley St | lat=47.62711 | long=-122.33587 | directions=\n| phone=+1 206-382-2628 | tollfree= | fax=\n| hours=Opens daily at 10AM except Mondays November through mid-April; closing hours vary by season. Free boat sails Sun 10AM (signup), 11AM-3PM (cruise) | price=Free\n| wikipedia=Center for Wooden Boats | image=Seattle — The Center for Wooden Boats, IMG04.jpg | wikidata=Q5059922\n|lastedit=2023-06-17| content=An intriguing museum where you can learn about maritime culture and experience sailing traditional wooden boats. The center's programs allow you to get a hands-on feel, putting in control of crafting and sailing your own wooden craft. On Sundays, rain or shine, you can get a free boat ride on a classic wooden boat around the lake for an hour. At times artist-in-residence Saaduuts leads programs.\n}}\n{{see\n| name=Museum of History and Industry | alt=MOHAI | url=https://mohai.org/ | email=\n| address=860 Terry Ave. N | lat=47.6276 | long=-122.337 | directions=\n| phone=+1 206-324-1126 | tollfree= | fax=\n| hours=Daily 10AM–5PM, Thursdays until 8PM | price=$25 adults, $20 seniors, children under 14 free; free first Thursday of the month\n| wikipedia=Museum of History & Industry (MOHAI) | image=MOHAI Armory building at Lake Union Park west side.jpg | wikidata=Q6940877\n|lastedit=2026-04-21| content=The museum focuses on the history of Seattle and the greater Puget Sound region, with exhibitions focusing on the maritime history of the city and the rise of the region's technology industry, as well as a set of changing exhibits.\n}}\n{{see\n| name=Northwest Seaport/Maritime Heritage Center | alt= | url=https://www.nwseaport.org/ | email=\n| address=1002 Valley St | lat=47.62808 | long=-122.33665 | directions=in Lake Union Park\n| phone=+1 206-447-9800 | tollfree= | fax=\n| hours= | price=\n| wikipedia=Northwest Seaport | wikidata=Q7060198\n|lastedit=2023-06-17| content=Home to a number of historic ships, including a schooner, a tugboat, a fireboat, a lightship, a steamer, and several others.\n}}\n{{see\n| name=South Lake Union Discovery Center | url=https://www.discoverslu.com/ | email=\n| address=101 Westlake Ave N | lat=47.61883 | long=-122.33879 | directions=\n| phone=+1 206-342-5900 | tollfree= | fax=\n| hours=11AM-6PM daily | price=\n|lastedit=2023-06-17| content=An introduction to the neighborhood, with displays explaining the history of South Lake Union. There is also a model of the whole neighborhood in the building.\n}}\n{{see\n| name=Amazon HQ tour | url=https://www.seattlespheres.com/visit-hq | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree= | fax=\n| hours=Wed 10AM, 2PM | price=\n| content=90-minute tour of Amazon's urban campus including Doppler, Day 1, and the Spheres. There is also a '''self-guided audio tour'''; you can even get a free pair of headphones to keep from the receptionists at Day 1 (2121 7th Ave; M-F 7AM-6PM).\n}}\n{{see\n| name=Seattle Spheres | url=https://www.seattlespheres.com/the-spheres-weekend-public-visits | email=\n| address=2117 7th Ave | lat= | long= | directions=\n| phone= | tollfree= | fax=\n| hours=1st and 3rd Sat of the month 10AM-6PM | price=Free; reservations required, available 30 days in advance\n| wikidata=Q48596158\n| content=A building made of three glass domes filled with lush plants. Take an unguided stroll through the tropical heart of the Spheres.\n}}\n{{see\n| name=Understory | alt= | url=https://www.seattlespheres.com/visit-understory | [email protected]\n| address=2101 7th Ave | lat= | long= | directions=\n| phone= | tollfree=\n| hours=Tue-Sat 10AM-6PM | price=Free\n| lastedit=2026-01-04\n| content=Visitor center about Amazon and the Spheres' flora and design. It's in the bottom floor of the Spheres and doesn't get you access to the upper floors where the plants live, but it's available 5 days a week unlike public access to the building.\n}}\n{{see\n| name=Public Art at Amazon Headquarters | url= | email=\n| address=426 Terry Ave N | lat=47.62262 | long=-122.33673 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| content=While the Amazon buildings are not open to the public, there are several signed outdoor art pieces around the complex and other nearby buildings, and you can hob-nob with \"blue badges\" at local restaurants and coffee shops. Seattle Architecture Foundation also leads guided walking tours.\n}}\n{{do\n| name=Northwest Folklife Festival | alt= | url=https://nwfolklife.org/ | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree= | fax=\n| hours=Memorial Day weekend (end of May) | price=free ($10 donation suggested)\n| wikipedia=Northwest Folklife | image=Seattle Center Pavilion during Folklife.jpg | wikidata=Q375649\n|lastedit=2023-06-17| content=A more low-key and global version of Bumbershoot.\n}}\n{{do\n| name=Bite of Seattle | alt= | url=https://biteofseattle.com/ | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree= | fax=\n| hours=Mid-late July | price=\n| wikipedia=Bite of Seattle | wikidata=Q4918822\n|lastedit=2023-06-17| content=Part of the Seafair festivities.\n}}\n{{do\n| name=Bumbershoot | url=https://bumbershoot.com/ | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree= | fax=\n| hours=Labor Day weekend (early Sep) | price=\n|lastedit=2023-06-17| content=A music and arts festival featuring dozens of local and world-class musical acts.\n}}\n{{do\n| name=Seattle Center WinterFest | alt= | url=https://www.seattlecenter.com/winterfest/ | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree=\n| hours=Late Nov to late Dec or early Jan | price=\n| lastedit=2025-01-21\n| content=Various activities/events during the holidays such as the WinterFest Ice rink (skate rental available for fee), Winter Train & Village, ice sculpting displays.\n}}\n{{do\n| name=Festál Cultural Center | alt= | url=https://seattlecenter.com/events/featured-events/festal | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree= | fax=\n| hours=Year-round except Dec | price=\n| content=Celebrate the world with festivals from about 25 countries represented, one country nearly every 2 weeks.\n}}\n{{do\n| name=Climate Pledge Arena | alt= | url=https://climatepledgearena.com/ | email=\n| address=334 1st Ave N | lat=47.622 | long=-122.354 | directions=\n| phone=+1 206-460-7825 (ticketing) | tollfree= | fax=\n| hours=\n| wikipedia=Climate Pledge Arena | image=Climate pledge arena.jpg | wikidata=Q977529\n| content=The region's largest sports arena is home to the NHL's Seattle Kraken, the WNBA's Seattle Storm, and college men's basketball games of the Seattle Redhawks (Seattle University), as well as a major concert venue. It was built for the 1962 World's Fair. The arena's interior was completely gutted and rebuilt for the Kraken, but its original exterior remains intact, maintaining its historic listing. Tours not available; if you want to see the inside, you have to attend an event.\n}}\n{{do\n| name=Seattle Chamber Music Society | alt= | url=https://www.seattlechambermusic.org/ | email=\n| address=10 Harrison St | lat=47.6222 | long=-122.3565 | directions=\n| phone=+1 206-283-8710 | tollfree= | fax=\n| hours= | price=\n| wikipedia=Seattle Chamber Music Society | wikidata=Q7442066\n| lastedit=2018-03-02\n| content=\n}}\n{{do\n| name=McCaw Hall | alt= | url=https://www.mccawhall.com/ | email=\n| address=321 Mercer St | lat=47.62397 | long=-122.35039 | directions=NE corner of Seattle Center\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q11705574\n| content=Home of the [https://www.seattleopera.org/ Seattle Opera] and the [http://www.pnb.org/ Pacific Northwest Ballet], each considered among the best in the United States.\n}}\n{{do\n| name=Seattle Repertory Theatre | alt= | url=https://www.seattlerep.org/ | email=\n| address=155 Mercer St | lat=47.62459 | long=-122.3533 | directions=\n| phone=+1 206-443-2222 (call), +1 206-565-2996 (text) | tollfree=\n| hours=Box officeː Mon-Fri noon-5PM, by phone noon-6PM | price=\n| wikidata=Q7442163\n| lastedit=2025-02-15\n| content=The largest nonprofit resident theatre in the Pacific Northwest.\n}}\n{{do\n| name=Seattle International Film Festival (SIFF)| alt= | url=https://www.siff.net/ | [email protected]\n| address=[https://www.siff.net/cinema/cinema-venues SIFF Cinemas] and other venues | lat= | long= | directions=\n| phone=+1 206-464-5830 | tollfree= | fax=\n| hours=May | price=\n| wikidata=Q2399132\n| content=One of the largest film festivals in North America. Screenings are at the [[w:SIFF Cinema Uptown]], [https://www.siff.net/cinema/cinema-venues/siff-film-center SIFF Film Center], and at venues in adjacent districts.\n}}\n{{do\n| name=SIFF Cinema Uptown | url=https://www.siff.net/cinema/cinema-venues/siff-cinema-uptown | [email protected]\n| address=511 Queen Anne Ave N | lat=47.623532 | long=-122.357005 | directions=\n| phone=+1 206-464-5830 | tollfree= | fax=\n| hours= | price=[https://www.siff.net/cinema/box-office Variable pricing]\n| wikidata=Q43306738\n| content=Three-screen theater showing art, indie, and foreign films and documentaries.\n}}\n{{do\n| name=Lake Union Crew | alt= | url=https://lakeunioncrew.com/ | email=\n| address=2520 Westlake Ave N | lat=47.64315 | long=-122.34324 | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=\n| lastedit=2025-01-19\n| content=A club boathouse for the local rowing community. If you have no rowing experience, classes are offered.\n}}\n{{do\n| name=Northwest Outdoor Center | alt= | url=https://www.nwoc.com/ | email=\n| address=2100 Westlake Ave N Ste 1 | lat=47.63861 | long=-122.33987 | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=\n| lastedit=2025-01-19\n| content=Kayak and paddleboard rentals.\n}}\n{{do\n| name=The Electric Boat Company | alt= | url=https://www.theelectricboatco.com/ | email=\n| address=2046 Westlake Ave #102 | lat=47.63803 | long=-122.34006 | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=\n| lastedit=2025-01-21\n| content=Electric boat rentals for up to 12 passengers. Offers enclosed and heated vessels for year-round appeal. No prior experience needed.\n}}"
+ },
+ {
+ "title": "Seattle/Capitol Hill-Central District",
+ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/Capitol_Hill-Central_District",
+ "wikitext": "{{see\n| name=Northwest Nikkei Museum | alt=Japanese Cultural Community Center of Washington | url=https://www.jcccw.org/museum/ | email=\n| address=1414 S Weller St | lat=47.5977 | long=-122.3121 | directions=\n| phone=+1 206-568-7114 | tollfree=\n| hours=Mon-Fri 10AM-5PM | price=Free\n| wikipedia=Japanese Cultural and Community Center of Washington\n| lastedit=2021-07-18\n| content=Small exhibits relating to the history of ''nikkei'' (Japanese immigrants) in the Pacific Northwest.\n}}\n{{see\n| name=Seattle Asian Art Museum | alt=SAAM | url=https://www.seattleartmuseum.org/visit/seattle-asian-art-museum | email=\n| address=1400 E Prospect St | lat=47.6303 | long=-122.314 | directions=in Volunteer Park\n| phone=+1 206-654-3100 | tollfree=\n| hours=Fri-Sun 10AM-5PM | price=$15/adult, $13/seniors, $13/military, $10/student, children free, last Fridays free, all tickets +$3 if not bought in advance\n| wikipedia=Seattle Asian Art Museum | image=Seattle Asian Art Museum.jpg | wikidata=Q836066\n| lastedit=2023-06-25\n| content=An offshoot of downtown's Seattle Art Museum, SAAM displays a portion of the permanent collection balanced with rotating, consistently well-curated special exhibitions. The focus is usually on Chinese or Japanese art, reflecting long-established ties across the Pacific, but does include works from as far as India and the Middle East. The Art Deco building (the Seattle Art Museum's original home) is an attraction in its own right.\n}}\n{{see\n| name=Frye Art Museum | alt= | url=https://fryemuseum.org/ | [email protected]\n| address=704 Terry Ave | lat=47.6071 | long=-122.324 | directions=on First Hill\n| phone=+1 206-622-9250 | tollfree= | fax=+1 206 223-1707\n| hours=Tue-Sun 11AM-5PM, Thu 11AM-7PM | price=Free\n| wikipedia=Frye Art Museum | image=Frye Museum winter.jpg | wikidata=Q5506587\n|lastedit=2023-06-17| content=A small private collection of modern art with rotating contemporary exhibitions. Parking is always available and the place is worth a visit.\n}}\n{{see\n| name=Cal Anderson Park | alt= | url=https://www.seattle.gov/parks/allparks/cal-anderson-park | email=\n| address=1635 11th Ave | lat=47.61703 | long=-122.31916 | directions=near Broadway and E Pine St\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikidata=Q12054012\n| lastedit=2026-10-04\n| content=A very popular park on sunny days. It includes a signature fountain and pond, a basketball court, tennis and softball fields, and a playground. Great for peoplewatching, and you can often see groups of people doing activities varying from hackeysack to drum circles to freeze tag to bike polo to twirling around with colorful scarves.\n}}\n{{see\n| name=''Hot Rat Summer'' Mosaic | alt=Saint Rat | url= | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q135418161\n| lastedit=2026-10-04\n| content=A small tile-and-glass mosaic of a rat in a devotional pose, labeled with its namesake caption. It was illicitly installed on the side of a utility gatehouse in 2024. The city repeatedly tried to remove it, but community members swiftly striped away the paint covering it up amidst a swell of public appreciation for the art, so it was eventually allowed to stay.\n}}\n{{see\n| name=Lakeview Cemetery | alt= | url=https://www.lakeviewcemeteryassociation.com/ | email=\n| address=1554 15th Ave E | lat=47.63389 | long=-122.31528 | directions=at junction with Garfield\n| phone=+1 206-322-1582 | tollfree= | fax=\n| hours=Summer 9AM-8PM; spring 9AM-6PM; winter 9AM-4PM | price=Free\n| image=Seattle - Lakeview Cemetery 12.jpg | wikidata=Q872253\n|lastedit=2023-06-17| content=Incorporated in 1872, Lakeview Cemetery is set on a hillside with views of Lake Union, the Cascades, Lake Washington and the Olympic Mountains. The site holds the final resting places of Seattle's first families, many with diverse backgrounds. Many come to pay tribute at the graves of Bruce Lee and his son Brandon.\n}}\n{{see\n| name=Viretta Park | alt= | url=https://www.seattle.gov/parks/parks/viretta-park | email=\n| address=151 Lake Washington Blvd E | lat= | long= | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q7933704\n| lastedit=2026-10-05\n| content=The park's wooden benches are covered with graffiti messages to ''Nirvana'' frontman Kurt Cobain, as it is next door to the home where he died in 1994. This is the closest thing in Seattle to a memorial to the rock icon; serious fans should consider a road trip to his childhood hometown of [[Aberdeen_(Washington)|Aberdeen]].\n}}\n{{see\n| name=Volunteer Park | alt=at Prospect St | url=https://www.seattle.gov/parks/find/parks/volunteer-park | email=\n| address=1247 15th Ave E | lat=47.63040 | long=-122.31559 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikidata=Q7941040\n| content=Designed by John Charles Olmsted and Fredrick Law Olmsted Jr, this is the largest park in Capitol Hill. It is the site of a botanical conservatory and the Seattle Asian Art Museum (above).\n}}\n{{see\n| name=Volunteer Park Conservatory | alt= | url=https://www.volunteerparkconservatory.org/ | [email protected]\n| address=Highland Dr | lat= | long= | directions=north end of park\n| phone= | tollfree=\n| hours=Tue-Sun 10AM-4PM | price=Adult $6; Youth under 18, students, seniors 65+, military $4; children under 6 Free\n| wikidata=Q7941038\n| lastedit=2026-10-04\n| content=A Victorian-style greenhouse with thousands of living specimens on display. There are five sections: bromeliads, ferns, palms, seasonal, and cacti/succulents.\n}}\n{{see\n| name=Water Tower | url= | email=\n| address= | lat=47.62903 | long=-122.31451 | directions=at Prospect St park entrance\n| phone= | tollfree= | fax=\n| hours=10AM-sunset | price=Free\n| content=The 1906 tower at the highest point of Capitol Hill has an observation deck at the top, with views from the Cascades to the Olympics interspersed with a series of panels explaining the history of Seattle's Olmsted-designed park system. The views are somewhat obstructed by metal grates, but the clever photographer can work around them. No elevator - the only way up is the staircase wound around the water tank, seven stories high.\n}}\n{{see\n| name=Washington Park Arboretum | alt= | url=https://botanicgardens.uw.edu/washington-park-arboretum/ | email=\n| address=2300 Arboretum Dr E | lat=47.63920 | long=-122.29433 | directions=\n| phone= | tollfree= | fax=\n| hours=Daily, dawn-dusk | price=\n| wikidata=Q7972107\n|lastedit=2023-06-17| content=The Arboretum is a 230-acre park additionally serving as a botanical garden and horticultural research center, with thousands of trees and plants from temperate climates represented. An extensive network of walking trails covers the park. The visitor's center, near the northeast corner of the park, is open from 10AM to 4PM daily, and has limited parking available. Guided tours are offered on the first and third Sundays of every month, and free trail maps highlighting the major parts of the collection are available at any time.\n}}\n{{see\n| name=Seattle Japanese Garden | alt= | url=https://www.seattlejapanesegarden.org/ | email=\n| address=1075 Lake Washington Blvd E | lat=47.6292 | long=-122.296 | directions=southwest area of the Arboretum\n| phone=+1 206-684-4725 | tollfree= | fax=\n| hours=Hours vary seasonally: generally Tue-Sun 10AM until sunset. Extended hours from May to September, closed December to February; check the link above for more detailed information | price=$6 adults, $4 children over 5 years and seniors\n| wikidata=Q7442119\n|lastedit=2023-06-17| content=A small, formal Japanese garden within the grounds of the Arboretum.\n}}\n{{see\n| name=Streissguth Gardens | alt= | url=https://streissguthgardens.com/ | email=\n| address=E Blaine St between 10th Ave E and Broadway E | lat=47.6347 | long=-122.321 | directions=park at E Blaine St and 10th Ave E and go down public stairs; gardens on your left\n| phone= | tollfree= | fax=\n| hours= | price=\n| image=Flickr - brewbooks - Mary Ellen at Streissguth Gardens - Seattle (2).jpg | wikidata=Q7623187\n| content=A small, family-maintained garden on the northwest side of Seattle's Capitol Hill, on a steep hillside. Noisy but offering great views over Lake Union, Downtown Seattle, and the Olympic Mountains in the distance.\n}}\n{{see\n| name=Madison Park | alt= | url=https://www.seattle.gov/parks/find/parks/madison-park | email=\n| address=4000 E Madison St | lat=47.63513 | long=-122.27783 | directions=south of Evergreen Point Bridge\n| phone=+1 206-684-4075 | tollfree= | fax=\n| hours=4AM-11:30PM | price=\n| wikidata=Q6728065\n| content=Activities include swimming, bathhouse, restaurants, tennis court (with lights), and great views of the floating bridge and the Cascades. Lifeguards patrol the area during the summer, which creates a safer playground for kids, but it's still a beautiful park to hang out for teens and adults.\n}}\n{{see\n| name=Jimi Hendrix statue | alt=The Electric Lady Studio Guitar | url= | email=\n| address= | lat= | long= | directions=NE corner of E Pine St and Broadway\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q17151376\n| lastedit=2026-10-04\n| content=A life-size bronze sculpture of Hendrix rocks out on the sidewalk. Seattle was the birthplace and childhood home of the influential guitarist.\n}}\n{{see\n| name=Seattle University | alt= | url=https://www.seattleu.edu/ | email=\n| address=901 12th Ave | lat=47.61007 | long=-122.319 | directions=\n| phone=+1 206-296-6000 | tollfree= | fax=\n| hours= | price=\n| wikipedia=Seattle University | image=Seattle U Fountain 03.jpg | wikidata=Q615873\n|lastedit=2023-06-17| content=A private Jesuit university. Scenic campus makes a good walking detour.\n}}\n{{see\n| name=Chapel of St. Ignatius | alt= | url= | email=\n| address= | lat=47.61122 | long=-122.31797 | directions=Spring St and 12th Ave\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q114630227\n| lastedit=2025-04-17\n| content=A hidden gem, this small campus chapel was described by architect Stephen Holl as \"seven bottles of light in a stone box\". It is probably the most notable religious building built in Seattle in the last fifty years.\n}}\n{{do\n| name=Hothouse Spa & Sauna | alt= | url= | email=\n| address=1019 E Pike St | lat=47.61392 | long=-122.31839 | directions=\n| phone=+1 206-568-3240 | tollfree= | fax=\n| hours= | price=\n| content=A small urban women-only spa with an industrial/basement vibe. Great for a relaxing soak in the tub. Expect to be nude and quiet, and bring your own towels.\n}}\n{{do\n| name=Club Z | alt= | url= | email=\n| address=1117 Pike St | lat=47.61361 | long=-122.32806 | directions=\n| phone=+1 206-622-9958 | tollfree= | fax=\n| hours= | price=\n| content=On the other end of the spectrum, Club Z is a gay male bathhouse with no tubs and a lot of glory holes! Don't walk in if you expect to do any dancing or, um, bathing.\n}}\n{{do | name=Club Comedy Seattle | url=https://clubcomedyseattle.com/ | address=340 15th Ave E #201 | lat=47.62164 | long=-122.31248 |lastedit=2023-06-17| content=Boutique style stand-up comedy club.}}\n{{do\n| name=Raygun Lounge | alt= | url=https://www.raygunlounge.com/ | email=\n| address=501 E Pine St | lat=47.6151 | long=-122.3251 | directions=\n| phone=+1 206-812-2521 | tollfree= | fax=\n| hours=Mon-Fri 3PM-midnight, Sat-Sun noon-midnight | price=\n| content=All-ages gaming bar with board games and pinball machines. The atmosphere is more like a dive bar: the beer, cider and wine are good, but the food is mediocre, and the TVs are usually playing B-movies (but sometimes have some good campy sci-fi like ''Doctor Who'').\n}}\n{{do\n| name=I-5 Colonnade Mountain Bike Skills Park | alt=I-5 Colonnade | url=https://www.seattle.gov/parks/allparks/i-5-colonnade | email=\n| address=Lakeview Blvd E and Harvard Ave E | lat=47.635931 | long=-122.322535 | directions=\n| phone= | tollfree= | fax=\n| hours=4AM-11:30PM | price=\n| wikidata=Q5967559| image=I-5 Colonnade, 2014-10-13, no. 8.jpg\n| lastedit=2017-02-23\n| content=The bike park was built underneath a section of 12 elevated lanes of Interstate 5. It consists of several mountain bike skills trails, including novice, intermediate, and advanced sections, a dirt jumping section, pump track, and trials area. Skills classes, organized by the Evergreen Mountain Bike Alliance, are occasionally held at the park.\n}}\n{{do\n| name=Central Cinema | alt= | url=https://central-cinema.com/ | email=\n| address=1411 21st Ave | lat=47.61319 | long=-122.30530 | directions=\n| phone=+1 206-686-MOVI (listings), +1 206-328-3230 (office) | tollfree= | fax=\n| hours= | price=$8\n| wikidata=Q43306569\n| lastedit=2017-01-26\n| content=A unique, smaller movie theater that shows older films as well as plenty of late night movie screenings of hilariously bad films. All seats have table in front of them so you can have excellent food and beer during the show. A couple times a month there are $1.99 movie nights for popular older or cult films.\n}}\n{{do\n| name=Northwest Film Forum | url=https://nwfilmforum.org/ | email=\n| address=1515 12th Ave | lat=47.61441 | long=-122.31708 | directions=\n| phone=+1 206-829-7863 | tollfree= | fax=\n| hours= | price=\n| wikidata=Q43305852\n|lastedit=2023-06-17| content=A Seattle-based non-profit organization for film artists, showcasing American and international cinema as well as quarterly world premiere live performances.\n}}\n{{do\n| name=SIFF Cinema Egyptian | url=https://www.siff.net/year-round-cinema/cinema-venues/siff-cinema-egyptian | [email protected]\n| address=805 E Pine St | lat=47.6151 | long=-122.3217 | directions=\n| phone=+1 206-464-5830 | tollfree= | fax=\n| hours= | price=[https://www.siff.net/cinema/box-office Variable pricing]\n| wikidata=Q131038502\n| content=One-screen theater showing art, indie, and foreign films and documentaries.\n}}\n{{do\n| name=Seattle International Film Festival (SIFF)| alt= | url=https://www.siff.net/ | [email protected]\n| address= | lat= | long= | directions=\n| phone=+1 206-464-5830 | tollfree= | fax=\n| hours=May | price=\n| wikidata=Q2399132\n| content=One of the largest film festivals in North America. Screenings are at the SIFF Cinema Egyptian and other SIFF venues in adjacent districts.\n}}\n{{do\n| name=Capitol Hill Block Party | alt= | url=https://www.capitolhillblockparty.com/ | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree= | fax=\n| hours=Late July | price=\n| wikipedia=Capitol Hill Block Party | wikidata=Q5035886\n|lastedit=2023-06-17| content=This music festival during the height of summer attracts both local and national musicians.\n}}"
+ },
+ {
+ "title": "Seattle/Ballard",
+ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/Ballard",
+ "wikitext": "{{see\n| name=Hiram M. Chittenden Locks | alt=Ballard Locks | url=https://ballardlocks.org/ | email=\n| address=3015 NW 54th St | lat=47.66546 | long=-122.3971 | directions=at junction with 32nd Ave, served by bus route 44\n| phone=+1 206 783-7059 | tollfree= | fax=\n| hours=7AM-9PM | price=Free\n| wikipedia=Ballard Locks | image=Lake Washington ship canal, Hiram M. Chittenden Locks, 1995.jpg | wikidata=Q5769218\n|lastedit=2023-06-17| content=Linking the freshwater Lake Union and the salt-water Puget Sound across a height difference of 22 feet, the locks allow boats to cross the Lake Washington Ship Canal, relying solely on the force of gravity. A salmon ladder with a viewing gallery allows visitors to witness the salmon migration, which with different species allow for viewing throughout most of the year, but preferably mid to late summer. Adjacent is the Carl S English, Jr Botanical Garden. Guided tours weekdays 1PM and 3PM; weekends 11AM.\n}}\n{{see\n| name=National Nordic Museum | alt= | url=https://nordicmuseum.org/ | email=\n| address=2655 NW Market St | lat=47.6685 | long=-122.39235 | directions=\n| phone=+1 206 789-5707 | tollfree=\n| hours=Tue–Sun 10AM–5PM | price=$20 adults, $16 seniors, $15 college students, $10 children above 5 yrs, under 5 free\n| wikipedia=National Nordic Museum | image=Nordic Heritage Museum Entrance.jpg | wikidata=Q7050698\n| lastedit=2024-07-07\n| content=Documents the history of Scandinavian immigration to Seattle, with rotating art exhibits and a permanent collection of historical artifacts. Julefest includes discounted admission and a Christmas market.\n}}\n{{see\n| name=Edith Macefield \"Up\" House | alt= | url= | email=\n| address=1438 NW 46th St | lat=47.662316 | long=-122.375358 | directions=in the Ballard Blocks\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q5338613\n| lastedit=2025-02-01\n| content=Edith Macefield received worldwide attention in 2006 when she turned down an offer of $1 million to sell her house to make way for a five-story commercial development. Instead, the Ballard Blocks project was built surrounding her 108-year-old farmhouse. While production of the film ''Up'', based on a strikingly similar premise, started two years before, Disney acknowledged the story with a promotional tie-in.\n}}\n{{do\n| name=Ballard Commons Park | alt= | url=https://www.seattle.gov/parks/allparks/ballard-commons-park | email=\n| address=5701 22nd Ave. NW | lat=47.67802 | long=-122.33830 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Ballard Commons Park | wikidata=Q118112792\n| lastedit=2024-09-28\n| content= Ballard Commons Park features a playground, skate bowl, water feature, engaging public art, relaxing seating areas and lawns and ADA accessible walkways. \n}}\n{{do\n| name=Golden Gardens Park | alt= | url=https://www.seattle.gov/parks/allparks/golden-gardens-park | email=\n| address= | lat=47.69175 | long=-122.40394 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Golden Gardens Park | wikidata=Q5579416\n| content=A wonderful saltwater beach park in Ballard, past the locks and Shilshole Marina. Water around here is too cold to swim except for crazy and determined kids during the hottest months, but you can wade a little, walk the beach, make sand castles, claim a fire pit, and watch wind surfers and ships go by. On clear days there is a magnificent view of the Olympics on the other side of Puget Sound. Kids of all ages have endless muddy fun trying to dam up or re-route the fresh water stream flowing across one end of the beach.\n}}\n{{do\n| name=Greenlake Park | alt= | url=https://www.seattle.gov/parks/allparks/green-lake-park | email=\n| address= | lat=47.67802 | long=-122.33830 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Green Lake (Seattle) | wikidata=Q3116094\n| lastedit=2023-06-17\n| content=The focal point of the Green Lake neighborhood. The {{mi|2.8}} loop path around the lake is frequented by cyclists, runners, rollerbladers, and walkers. The lake is swimmer friendly; algae growth and closures were sometimes a problem before 2004, when the City of Seattle performed a comprehensive cleanup, and it is now one of the cleanest public swimming areas in the Seattle area.\n}}\n{{do\n| name=Sacred Rain Healing Center | alt= | url=https://www.sacredrainhealing.com/ | [email protected]\n| address=1100 NW 50th Street | lat=47.66518 | long=-122.37102 | directions=\n| phone=+1 206 7896288 | tollfree=\n| hours=10AM-10PM every day | price=$35\n| lastedit=2024-09-28\n| content=Connect with yourself. Seattle's only indoor/outdoor, all-gender clothing-optional hydrotherapy spa. Therapeutic massage, acupuncture, chiropractic, naturopathic physician, yoga, sacred music, nature-bathing. 18 and older.\n}}\n{{do\n| name=Greenwood Classic Car Show | url=https://www.greenwoodcarshow.com | email=\n| address= | lat=47.67797 | long=-122.35525 | directions=\n| phone= | tollfree= | fax=\n| hours=Jun | price=Free for spectators\n| content=Outdoor car show with all types of cars and food vendors about 1 mile along Greenwood Ave North.\n}}\n{{do\n| name=Ballard Seafoodfest | alt= | url=https://seafoodfest.org/ | email=\n| address= | lat=47.66853 | long=-122.38460 | directions=\n| phone= | tollfree=\n| hours=Typically mid-July | price=Free\n| lastedit=2024-07-07\n| content=A parade, a salmon barbecue, local musicians and a lutefisk-eating contest. Most businesses along NW Market St participate by holding sidewalk sales.\n}}\n{{do\n| name=Greenwood/Phinney Art Walk | url=https://www.phinneycenter.org/events/art-walk/ | email=\n| address=6532 Phinney Ave N | lat=47.67769 | long=-122.35428 | directions=\n| phone= | tollfree= | fax=\n| hours=July | price=Free\n| content=Showcases hundreds of local artists.\n}}\n{{do\n| name=Julefest | alt= | url=https://nordicmuseum.org/events/julefest | email=\n| address=2655 NW Market St | lat=47.66519 | long=-122.37098 | directions=\n| phone= | tollfree=\n| hours=Typically a Sat and Sun in late November or early December | price=\n| lastedit=2024-07-07\n| content=Discounted admission to the Nordic Museum and a large collection of food, drink, and craft vendors, with live music. A Nordic-inspired Christmas market.\n}}\n{{do\n| name=Freakout Festival | alt= | url=https://www.the-freakout.com | email=\n| address= | lat=47.66853 | long=-122.38460 | directions=\n| phone= | tollfree=\n| hours=Typically a Thu-Sun period in early November | price=\n| wikipedia=Freakout Festival | wikidata=Q119348565\n| lastedit=2024-09-28\n| content=An annual 4-day underground music festival held at various venues around the neighborhood each November.\n}}\n{{do\n| name=Conor Byrne Pub | url=https://www.conorbyrnepub.com/ | email=\n| address=5140 Ballard Ave NW | lat=47.66550 | long=-122.38188 | directions=\n| phone=+1 206 784-3640 | tollfree= | fax=\n| hours= | price=\n| lastedit=2024-09-28\n| content=A small live music venue and bar.\n}}\n{{do\n| name=Tractor Tavern | alt= | url=https://tractortavern.com/ | email=\n| address=5213 Ballard Ave NW | lat=47.66569 | long=-122.38282 | directions=\n| phone=+1 206 789-3599 | tollfree= | fax=\n| hours= | price=\n| lastedit=2024-09-28\n| content=A medium-sized live music venue typically only open during shows\n}}\n{{do\n| name=Sunset Tavern | url=https://sunsettavern.com/ | email=\n| address=5433 Ballard Ave NW | lat=47.66826 | long=-122.38613 | directions=\n| phone=+1 206 784-4880 | tollfree= | fax=\n| hours= | price=\n| lastedit=2024-09-28\n| content=A small live music venue and bar.\n}}\n{{do\n| name=Egan's Ballard Jam House | url=https://ballardjamhouse.com/ | email=\n| address=1707 NW Market St | lat=47.66852 | long=-122.37935 | directions=\n| phone=+1 206 789-1621 | tollfree= | fax=\n| hours= | price=\n| lastedit=2023-06-17\n| content=A small intimate music supper club with mostly jazz (vocal and instrumental) and other styles of live music offered. Open mic (vocal jam) every other Wednesday evening.\n}}"
+ },
+ {
+ "title": "Seattle/Fremont",
+ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/Fremont",
+ "wikitext": "{{see\n| name=Fremont Bridge | alt= | url= | email=\n| address= | lat= | long= | directions=connects Fremont Ave N and 4th Ave N\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q5501558\n| lastedit=2025-02-03\n| content=A bascule bridge (drawbridge) spanning the Fremont Cut since 1917. The bridge opens an average of 35 times a day, which makes it the most frequently opened drawbridge in the United States and one of the busiest bascule bridges in the world.\n}}\n{{see\n| name=Gas Works Park | alt= | url=https://www.seattle.gov/parks/allparks/gas-works-park | email=\n| address=2101 N Northlake Way | lat=47.646 | long=-122.335 | directions=\n| phone=+1 206 684-4075 | tollfree= | fax=\n| hours=4AM-11:30PM daily | price=Free\n| wikipedia=Gas Works Park | image=Aerial Gas Works Park September 2011.jpg | wikidata=Q5526323\n| content=\"Gasworks\" is Wallingford's oddly endearing refuge along the north part of Lake Union — prominently featuring the rusting, hulking remains of the city's old gasworks piping and machinery. More conventionally, a solitary bump of a hill offers the ideal spot for kite flying and views across the lake of sailboats, float planes landing, and downtown Seattle at sunset. Generally quiet but exceptionally crowded during the 4th of July for firework shows.\n}}\n{{see\n| name=Woodland Park | alt= | url=https://www.seattle.gov/parks/allparks/woodland-park | email=\n| address=1000 N 50th St | lat=47.66873 | long=-122.34403 | directions=across Aurora Ave from the zoo\n| phone= | tollfree=\n| hours= | price=\n| wikidata=\n| lastedit=2025-02-03\n| content=One of the city's most active hubs for sports and recreation, including walkway paths, several playfields, tennis courts, lawn bowling, horseshoe pits, a skatepark, and a large, wooded dog off-leash area.\n}}\n{{see\n| name=Woodland Park Zoo | alt= | url=https://www.zoo.org/ | email=\n| address=5500 Phinney Ave N | lat=47.6693 | long=-122.3532 | directions=\n| phone=+1 206 548-2500 | tollfree= | fax=\n| hours=May–September 9:30AM-6PM; October–April 9:30AM-4PM | price=May–September $18.75 per adult, $11.75 per child; October–May $11.75 per adult, $8.75 per child. Discounts for using public transit or on a rainy day!\n| wikipedia=Woodland Park Zoo | wikidata=Q8032871\n|lastedit=2023-06-17| content=The zoo hosts 1,100 animals from 300 different species, each housed in nice, naturalistic exhibits. You can feed a giraffe or an elephant by yourself for $5, or enter the aviary where the birds fly overhead and show off talents with the aid of a zookeeper. For the kids, the zoomazium is an additional place to visit for slides and a century-old carousel. Allow at least 3 hours of your time to fully explore the zoo.\n}}\n{{see\n| name=Waiting for the Interurban | alt= | url= | email=\n| address=N. 34th St. at Fremont Ave., at the north approach to the Fremont Bridge | lat=47.64944 | long=-122.34953 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Waiting for the Interurban | wikidata=Q7960603\n| content=A statue of five people (and a dog) waiting for the trolley that has not existed since the 1930s. The statue is frequently clothed by local residents. There was a time when this work was considered offbeat (imagine that!).\n}}\n{{see\n| name=Fremont Rocket | alt= | url= | email=\n| address= | lat=47.65062 | long=-122.35116 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Fremont Rocket | wikidata=Q17156031\n| content=A 53' Cold War rocket, improved with neon space blasters and a smoke generator in the engine bay, towers over the corner of N. 35th Street and Evanston Avenue.\n}}\n{{see\n| name=Fremont Troll | alt= | url= | email=\n| address= | lat=47.65100 | long=-122.34732 | directions=on 36th Street under the Aurora Bridge\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Fremont Troll | wikidata=Q1354391\n| content=Strikingly large (photos generally don't do it justice), the Troll is a concrete statue under the Aurora Bridge clutching a full size Volkswagen Bug in its grip. The statue is notable enough to even gets its own street name - just off of N. 36th Street on Troll Avenue.\n}}\n{{see\n| name=Statue of Lenin | alt= | url= | email=\n| address=N. 36th St. and Evanston Ave | lat=47.65138 | long=-122.35097 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Statue of Lenin, Seattle | wikidata=Q932203\n| content=This bronze statue peers out over the street below, oblivious to the continuing controversy over its display. It was salvaged and brought over from [[Slovakia]] and was re-erected in 1995 in a Fremont parking lot that at the time hosted a weekly market before being moved a few years later to its current site.\n}}\n{{do\n| name=Tai Chi at Meridian Park | url=https://www.seattle.gov/parks/allparks/meridian-playground | email=\n| address=4649 Sunnyside Ave. N | lat=47.66381 | long=-122.33208 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| content=On most Sunday mornings, groups of people practice tai chi or assorted other martial arts in the southeast quadrant of Meridian Park.\n}}\n{{do\n| name=Fremont Abbey Arts Center | url=https://www.fremontabbey.org/ | email=\n| address=4272 Fremont Ave N | lat=47.65917 | long=-122.34989 | directions=\n| phone=+1 206-414-8325 | tollfree= | fax=\n| hours= | price=\n| content=A performing and demonstrative arts venue.\n}}\n{{do\n| name=Fremont Fair | url=https://www.fremontfair.org/ | email=\n| address= | lat= | long= | directions=\n| phone= | tollfree= | fax=\n| hours=Weekend closest to June 20 | price=\n|lastedit=2023-06-17| content=If you happen to be in Seattle around the Summer Solstice, Fremont hosts one of the city's best street fairs on the weekend closest to the solstice. The highlight is the '''Solstice Parade''' on Saturday afternoon, an arts-only parade held by the Fremont Art's Council with hundreds, perhaps even thousands, of elaborately costumed participants, no signage (strongly discouraging commercial participation), and only human-powered floats. A contingent of nude and painted bicyclists traditionally show up and ride before the parade begins. Delilah's \"Billion Belly March\" contingent annually brings over a hundred belly dancers and drummers to the parade.\n}}\n{{do\n| name=Fremont Oktoberfest | url=https://fremontoktoberfest.com/ | email=\n| address= | lat= | long= | directions=\n| phone=+1 206 633-0422 | tollfree= | fax=\n| hours=Third week of September | price=From $15 if purchased in advance, $20 same day\n|lastedit=2023-06-17| content=An annual microbrew tasting event that features the Texas Chainsaw Pumpkin Carving Contest, live music, three beer gardens, a dog day event, the Brew HA-HA 5K, a comedy show, and the Miss Buxom Contest.\n}}"
+ },
+ {
+ "title": "Seattle/University District",
+ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/University_District",
+ "wikitext": "{{see\n| name=Suzzallo Library | alt= | url=https://www.lib.washington.edu/suzzallo/ | email=\n| address=at Red Square | lat=47.6557 | long=-122.308 | directions=\n| phone=+1 206 543-0242 | tollfree= | fax=\n| hours= | price=\n| wikipedia=Suzzallo Library | image=UDub Red Square Suzzallo Library.jpg | wikidata=Q7651462\n| content=The Suzzallo is a grand Gothic-style structure. The high-ceiling Graduate Reading room is especially impressive.\n}}\n{{see\n| name=Red Square | alt=formally called the Central Plaza | url= | email=\n| address= | lat=47.65592 | long=-122.30953 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Red Square (University of Washington) | wikidata=Q7305086\n| content=This red brick-paved plaza fills with students moving between classes, hanging out, or some student-led activities.\n}}\n{{see\n| name=The Quad | alt=Liberal Arts Quadrangle | url= | email=\n| address= | lat=47.65742 | long=-122.30697 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikidata=Q47090840\n| lastedit=2026-04-02\n| content=A large courtyard surrounded by the liberal arts buildings, decorated with 29 Japanese cherry trees. [https://www.washington.edu/cherryblossoms/ The Quad turns a wonderful shade of pink for a few weeks when the cherry trees bloom in early spring (usually late March-early April)]. It is the premier cherry blossom picnic spot in the region, filling with hundreds of people during peak bloom.\n}}\n{{see\n| name=Burke Museum | alt=Burke Museum of Natural History and Culture | url=https://www.burkemuseum.org/ | email=\n| address=University of Washington, 17th Ave NE & NE 45th St | lat=47.66097 | long=-122.30977 | directions=\n| phone=+1 206 543-5590 | tollfree=\n| hours=Tue-Sun 10AM-5PM, first Thu of each month 10AM-8PM | price=$24 general, $22 seniors, $16 students and youth, children 3 and under free, free for everyone first Thursday of every month\n| wikipedia=Burke Museum of Natural History and Culture | image=Burke Museum totem poles 01.jpg | wikidata=Q388376\n| lastedit=2026-04-02\n| content=Washington's oldest and largest natural history museum. In addition to exhibits on evolution and dinosaur skeletons, there is an emphasis on native peoples of the Northwest Coast, local ecosystems and windows into the day-to-day work of researchers. Most galleries have non-linear arrangements that reward wandering to whatever catches your eye. Highlights include colorful contemporary and historic crafts from Native artists, a well-preserved ''T. rex'' skull and the only dinosaur fossil found in Washington.\n}}\n{{see\n| name=Henry Art Gallery | alt= | url=https://henryart.org/ | [email protected]\n| address=University of Washington, 15th Ave NE & NE 41st St | lat=47.65650 | long=-122.31165 | directions=\n| phone=+1 206 543-2280 | tollfree= | fax=+1 206 685-3123\n| hours=Thu Fri 11AM-9PM, Sat Sun 11AM-4PM | price=$10 general, $6 seniors, free for UW students and staff, free for everyone on first Thursdays\n| wikipedia=Henry Art Gallery | wikidata=Q5717411\n|lastedit=2023-06-17| content=One of the biggest contemporary art galleries in Washington, The Henry shows well-known contemporary and modern artists of visual media, as well as an annual exhibition of the UW's Master of Fine Arts students.\n}}\n{{do\n| name=University of Washington Waterfront Activities Center | alt= | url=https://www.washington.edu/ima/ | email=\n| address= | lat=47.64778 | long=-122.30005 | directions=behind Husky Stadium at Montlake Blvd. and NE Pacific St\n| phone=+1 206 543-9433 | tollfree=\n| hours=Hours vary, call for details | price=Starting at $21/hour, with substantial discounts for UW students, staff, and alumni\n| lastedit=2023-06-17\n| content=Rents canoes and rowboats, with all the necessary accessories. The ramp is also a good location to launch canoes and kayaks of your own.\n}}\n{{do\n| name=Agua Verde Paddle Club | url=https://aguaverdepaddleclub.com/ | email=\n| address=1303 NE Boat St | lat=47.65161 | long=-122.31437 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n|lastedit=2023-06-17| content=Hourly kayak rentals from Portage Bay providing access to the Arboreteum, Lake Washington and Lake Union.\n}}\n{{do\n| name=University Heights Center for the Community | alt= | url=https://www.uheightscenter.org/ | email=\n| address=5031 University Way NE | lat=47.66559 | long=-122.31332 | directions=five blocks from UW\n| phone=+1 206 527-4278 | tollfree=\n| hours=Mon-Fri 8AM-10PM, Sat 9AM-3PM, Sun 9AM-1PM | price=\n| content=A not-for-profit community-based organization in the University District which offers an array of classes, host activities and events for the University District & Greater Seattle Area. Accepts visitors everyday. Once a Seattle School, now a designated landmark. Stop in the center or just cruise by and visit the Farmer's Market every Saturday year round.\n}}\n{{do\n| name=Figure Drawing | url= | email=\n| address=top floor of the Art building on UW campus | lat=47.65852 | long=-122.30652 | directions=\n| phone= | tollfree= | fax=\n| hours=Sun 10:30AM-1PM | price=$1-4 depending on number of attendees\n| content=Show up early to secure an easel and to find the host room. This nude figure drawing session is open to the public. Bring your own supplies, and cash for the model fee. Model is usually female. All levels welcome.\n}}\n{{do\n| name=U-District Farmer's Market | alt= | url=https://seattlefarmersmarkets.org/udfm | email=\n| address=University Way NE between NE 50th & 52nd | lat=47.6658 | long=-122.313 | directions=\n| phone= | tollfree=\n| hours=Sat 9AM-2PM, year round | price=\n| content=Local produce, cheeses and other products can be found here.\n}}\n{{do\n| name=Ravenna Park | alt= | url=https://www.seattle.gov/parks/allparks/ravenna-park | email=\n| address=5520 Ravenna Ave. NE | lat=47.6714 | long=-122.30571 | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikipedia=Ravenna Park | wikidata=Q7296467\n| content=Fans of comic artist Charles Burns might like to visit this park, the site of some of the creepier scenes in his book ''Black Hole''.\n}}\n{{do\n| name=Husky Stadium | alt=officially Alaska Airlines Field at Husky Stadium | url=https://gohuskies.com/facilities/alaska-airlines-field-at-husky-stadium/4 | email=\n| address=3800 Montlake Blvd NE | lat= | long= | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q1136463\n| lastedit=2025-01-30\n| content=Venue for the Washington Huskies football team\n}}\n{{do | name=Laughs Comedy Club | url=https://laughscomedyclub.com/ | address=5220 Roosevelt Way NE | lat=47.66695 | long=-122.31713 | content=Comedy club featuring both locals and nationally-known stars.}}\n{{do\n| name=Jet City Improv | alt= | url=https://www.jetcityimprov.org/ | email=\n| address=5510 University Way NE | lat=47.66889 | long=-122.31281 | directions=\n| phone=+1 206-352-8291 | tollfree=\n| hours= | price=\n| lastedit=2020-02-29\n| content=Improv comedy theater, with an emphasis on innovative improvisational formats.\n}}\n{{do\n| name=Neptune Theatre | alt= | url=https://www.stgpresents.org/ | email=\n| address=1303 NE 45th St | lat=47.66116 | long=-122.31405 | directions=one block west of University Way NE on 45th\n| phone=+1 206 682-1414 | tollfree=\n| hours= | price=\n| wikidata=Q29829461\n| lastedit=2022-02-01\n| content=Live music venue in a 1921 theater building, operated by the same management as downtown's Paramount and Moore.\n}}\n{{do\n| name=Friends of the Seattle Public Library Book Sale | alt= | url=https://www.friendsofspl.org/ | email=\n| address=Building 30, Magnuson Park | lat= | long= | directions=\n| phone= | tollfree=\n| hours= | price=Many items for $1\n| content=Held twice yearly, in April and September. Over 200,000 books, CDs, and videos from library surplus and private donations occupy an entire airplane hanger.\n}}\n{{do\n| name=U-District Street Fair | url= | email=\n| address= | lat= | long= | directions=along the Ave\n| phone= | tollfree= | fax=\n| hours=May | price=\n| content=This street fair includes some performance artists (mainly musicians), many booths selling their wares, some food stalls and some live music. A rather generic affair over-all.\n}}"
+ },
+ {
+ "title": "Seattle/North",
+ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/North",
+ "wikitext": "{{see\n| name=Carkeek Park | alt= | url=https://www.seattle.gov/parks/parks/carkeek-park | email=\n| address=950 NW Carkeek Park Rd | lat=47.7127 | long=-122.3783 | directions=enter from NW 110th St or 117th St\n| phone=+1 206-684-0877 | tollfree= | fax=\n| hours=6AM-10PM daily | price=Free\n| wikipedia=Carkeek Park | image=Carkeek Park-1.jpg | wikidata=Q5039739\n|lastedit=2023-06-17| content=The century-old trees and surprisingly steep terrain surrounding a small valley give the impression that you're far from the city. Excellent for a walk in the woods. Walkable beach below the train tracks.\n}}\n{{see\n| name=Maple Leaf Park | alt= | url=https://www.seattle.gov/parks/allparks/maple-leaf-reservoir-park | email=\n| address=1020 NE 82nd St | lat=47.6905 | long=-122.3154 | directions=\n| phone=+1 206-615-0810 | tollfree= | fax=\n| hours= | price=\n|lastedit=2023-06-17| content=This area used to be a reservoir. It is now drained, replaced by a wide open green area locals used for playing, but the water tower is still standing. As it is the highest spot in North Seattle, you can catch a wonderful panorama of downtown Seattle, Mount Rainier, the Cascades, Lake Washington, and partially blocked Olympic Mountains.\n}}\n{{see\n| name=Warren G. Magnuson Park | alt= | url=https://www.seattle.gov/parks/allparks/magnuson-park | email=\n| address=7400 Sand Point Way NE | lat=47.6808 | long=-122.248 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Magnuson Park | image=Magnuson Park-1.jpg | wikidata=Q10323074\n|lastedit=2023-06-17| content=Seattle's second-largest park, formerly the Sand Point Naval Air Station. The wide-open spaces of the park support numerous athletic fields and picnic spots, with windsurfing and a boat launch along the Lake Washington shoreline. The buildings remaining from its military days are home to many community organizations and special events.\n}}\n{{do|name=Granite Curling Club|url=http://curlingseattle.org/|email=|address=1440 N 128th St|lat=47.72287|long=-122.34065|directions=|phone=|tollfree=|fax=|hours=|price=|lastedit=2023-06-18|content=An excellent facility dedicated to curling, the loud ice sport with stones and brooms. They host regular open houses to teach people how to play.}}\n{{do\n| name=Kraken Community Iceplex | alt= | url=https://www.krakencommunityiceplex.com/ | email=\n| address=10601 5th Ave NE | lat= | long= | directions=\n| phone= | tollfree=\n| hours= | price=From $18\n| wikidata=Q131374838\n| lastedit=2026-10-08\n| content=This practice facility for the Seattle Kraken hockey team is open for public skate sessions over lunch hours, weekends and evenings.\n}}\n{{do\n| name=Jackson Park Golf Course | alt= | url=https://www.seattlegolf.com/jacksonpark/ | email=\n| address=1000 NE 135th St | lat=47.7286 | long=-122.3166 | directions=\n| phone=+1 206-363-4747 | tollfree= | fax=\n| hours= | price=Championship course: $35 weekday, $45 weekend; Executive course: $8.50 9-hole, $14.00 18-hole (playing twice)\n| content=A decent 18-hole championship and a 9-hole executive golf course plus a driving range.\n}}\n{{do\n| name=Underwater Sports | alt= | url=https://www.underwatersports.com/ | email=\n| address=10545 Aurora Ave N | lat=47.70621 | long=-122.34511 | directions=\n| phone=+1 206-362-3310 | tollfree= | fax=\n| hours=Mon-Fri 9AM-7PM, Sat-Sun 9AM-6PM | price=\n|lastedit=2023-06-17| content=You can buy diving gear here but the unique thing about this place is that they offer open water scuba diving certification classes, as well as more basic swimming classes. Lessons conducted at their indoor pool and then out in the Puget Sound.\n}}\n{{do\n| name=Lake City Summer Festival and Parade | url=https://lakecitysummerfest.com/ | email=\n| address=along NE 125th Street from 25th Avenue NE to Lake City Way NE | lat=47.7193 | long=-122.2992 | directions=\n| phone= | tollfree= | fax=\n| hours=August | price=\n| content=Lake City has a great street fair in late summer, complete with street vendors, food, music and a parade with local kids and performers on up to the SeaFair Pirates. It has a small-town feel while the streets are lined with all sorts of families and crazies from the neighborhood. Fun for all.\n}}"
+ },
+ {
+ "title": "Seattle/Sodo-Georgetown",
+ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/Sodo-Georgetown",
+ "wikitext": "{{see\n| name=Coast Guard Museum Northwest | alt= | url=https://www.rexmwess.com/cgpatchs/cogardmuseum.html | email=\n| address=Pier 36, Base Seattle, 1519 Alaskan Way S | lat=47.5906 | long=-122.338 | directions=\n| phone=+1 206 217-6993 | tollfree=\n| hours=Mon Wed Fri 9AM-3PM | price=Free\n| wikipedia=Coast Guard Museum Northwest | image=USCG Museum NW - Coast Guard Academy exhibit.jpg | wikidata=Q5138152\n| lastedit=2015-09-20\n| content=A small museum with thousands of Coast Guard related items.\n}}\n{{see\n| name=Connections Museum Seattle | alt=Museum of Communications | url=https://www.telcomhistory.org/connections-museum-seattle/ | email=\n| address=7000 E Marginal Way S | lat=47.54075 | long=-122.32302 | directions=\n| phone=+1 206 767-3012 | tollfree=\n| hours=Sun 10AM-3PM | price=Cash-only $5/adult, $2/child\n| wikipedia=Connections Museum | wikidata=Q6940805\n| lastedit=2020-01-28\n| content=A museum of antique telephone equipment, although \"warehouse\" might be a better description than museum, with racks upon racks crammed full of equipment. Many exhibits are kept functional, including mechanical and electronic switches, telephone poles, and antique telephones. Tours take at least an hour, but will go on for 2-3hrs you have strong technical interests and lots of questions. The building itself is still owned by CenturyLink.\n}}\n{{see\n| name=Museum of Flight | alt= | url=https://www.museumofflight.org/ | email=\n| address=9404 E Marginal Way S | lat=47.519 | long=-122.3 | directions=At Boeing Field. Metro bus #124. No convenient access from light rail.\n| phone=+1 206 764-5720 | tollfree=\n| hours=Daily 10AM to 5PM except 1st Thu of month 10AM to 9PM | price=$20 adults, $12 youth (5-17), children (4 and under) free, $17 active military and seniors (65+). Lower fares available for groups of more than 10 people. Free on first Thursday of the month from 5PM until 9PM\n| wikipedia=Museum of Flight | image=Museum of Flight, Seattle.jpg | wikidata=Q1402597\n| content=A massive air and space museum, near the original Boeing factory. The museum consists of an indoor area that is further divided into the history, jet age, airport operations, and space travel, and an outdoor section where you can sneak inside retired airplanes. The collection includes 131 aircraft and spacecraft ranging from wood and fabric crates to the SR-71, Space Shuttle Trainer, a retired Air-Force One and a Concorde. It's arguably better than even the Smithsonian's Air and Space Museum in the other [[Washington, D.C.|Washington]]. Tours to the vicinity of the airport inside a van are available, but most spots do not allow cameras for security purposes! The Museum offers a large educational outreach program including weekend family workshops, the Aerospace Camp Experience (ACE) summer day camp, and an aviation high school.\n}}\n{{see\n| name=Hat & Boots Park | alt= | url=https://www.seattle.gov/parks/allparks/oxbow-park | email=\n| address=6430 Corson Ave. S | lat=47.5451 | long=-122.3217 | directions=In Oxbow Park\n| phone=+1 206-684-4075 | tollfree=\n| hours=4AM-11:30PM daily | price=\n| image=Oxbow Park Panorama.jpg\n| lastedit=2024-01-05\n| content=See a {{ft|44}} wide cowboy hat and a {{ft|22}} high boot in this park.\n}}\n{{do\n| name=Lumen Field | alt= | url=https://www.lumenfield.com | email=\n| address=800 Occidental Ave S | lat=47.5952 | long=-122.3316 | directions=\n| phone=+1 206 381-7555 | tollfree= | fax=\n| hours= | price=\n| wikipedia=Lumen Field | image=Qwest Field North.jpg | wikidata=Q612736\n| content=Home to Seattle's beloved Seahawks [[American football]] team since 2002 and Sounders soccer club, plus the women's soccer club Seattle Reign, Lumen Field is an impressive open air sports arena with great views beyond the goal posts. It was also host to 6 matches during the 2026 World Cup. The stadium itself is notorious for being ''deafening'' while the so-called 12th Man cheers for the Seahawks. If you can't make it to the game, you can join a tour to see a glance of the history of the teams that call the spot home. Tours are held Fridays and Saturdays at 12:30PM and 2:30PM for $12, $5 for child ages 4-12, $8 ages 65+.\n}}\n{{do\n| name=T-Mobile Park | alt= | url=https://www.mlb.com/mariners/ballpark | email=\n| address=1250 1st Ave S | lat=47.5914 | long=-122.3325 | directions=\n| phone=+1 206 346-4287 | tollfree=\n| hours= | price=\n| wikipedia=T-Mobile Park | image=SafecoFieldTop.jpg | wikidata=Q1193117\n| content=Since 1999, T-Mobile Park has been home to the Seattle Mariners [[Baseball in the United States|baseball]] team and is one of the finest ballparks in the country. Right field bleacher seats enjoy a panoramic view of Downtown and Elliott Bay. An element of the unique atmosphere is the train whistle that sounds each time a Mariners' home run occurs. The stadium offers anything from traditional ballpark dogs and the famous garlic fries to fine gourmet fare and local beers. The aroma of food is everywhere inside the park. There couldn't be a better way to spend a summer evening with friends and family in Seattle. Take the elevator to 300 level to the Rooftop Boardwalk and see great views of the Puget Sound and downtown skyline and check out the free Mariners Hall of Fame on the 100 level for interesting exhibits and photos of Mariners players and memorabilia. You can tour the stadium for $10, $5 children ages 3-12. Schedules vary by season, but there are always tours at 12:30PM and 2:30PM, except during game days. You can buy the tickets online or at the Mariners pro shop.\n}}\n{{do\n| name=Showbox SoDo | url=http://www.showboxonline.com/ | email=\n| address=1700 1st Ave S | lat=47.58792 | long=-122.33388 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| content=Sister venue to the original Showbox across from Pike Place Market, Showbox SoDo offers a variety of live music acts, check website for upcoming events. There is also food, of the burger and pizza variety, and drinks available from the lounge.\n}}\n{{do\n| name=Georgetown Art Attack | url=https://georgetownseattle.org/art-attack/ | email=\n| address= | lat= | long= | directions=along Airport Way S\n| phone= | tollfree= | fax=\n| hours=Every second Sat of the month, 6-9PM | price=\n| content=Local artisans present a stellar array of visual and performing arts throughout the historic industrial arts corridor. Featured studios include the Georgetown Arts and Cultural Center, Vecta Photo, the Georgetown Atelier, Nautilus Studio, Krab Jab Studios, and Tenaya Sims.\n}}"
+ },
+ {
+ "title": "Seattle/South",
+ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/South",
+ "wikitext": "{{see\n| name=Northwest African American Museum | alt= | url=https://www.naamnw.org/ | email=\n| address=2300 S Massachusetts St | lat=47.5889 | long=-122.3017 | directions=\n| phone=+1 206 518-6000 | tollfree=\n| hours=Wed 11AM-5PM, Thu 11AM-7PM, Fri-Sun 11AM-5PM | price=$7 adult, $5 children 4-12, students, seniors; free the first Thursday of every month\n| wikipedia=Northwest African American Museum | wikidata=Q7059902\n| content=Short but powerful museum on the history of Blacks in the Northwest.\n}}\n{{see\n| name=Jefferson Park | alt= | url=https://www.seattle.gov/parks/allparks/jefferson-park | email=\n| address=3801 Beacon Ave S | lat=47.5666 | long=-122.307 | directions=on Beacon Hill, 0.5 mi south of Beacon Hill Station\n| phone= | tollfree= | fax=\n| hours=4AM - 11:30PM | price=\n| wikipedia=Jefferson Park (Seattle) | image=Jefferson park trees in snow.jpg | wikidata=Q12060863\n| content=Seattle's sixth largest park at 45.2 acres, this park designed by the Olmsted Brothers offers unparalleled views of the Duwamish River, downtown and the Olympic Mountains. It is the home to the Jefferson Park Golf Course, Jefferson Park driving range, Jefferson Skatepark, tennis courts (lighted), basketball courts, the Jefferson Community Center, Jefferson Lawn Bowling, and Beacon Mountain an ecologically oriented playscape with a water park in the summer.\n}}\n{{see\n| name=Seward Park | alt= | url=https://www.seattle.gov/parks/allparks/seward-park | email=\n| address= | lat=47.5559 | long=-122.2515 | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikipedia=Seward Park (Seattle) | wikidata=Q14713892\n| content=This wooded Olmsted park takes up a large peninsula surrounded by Lake Washington. Attractions include kids activities in the summer, great scenery, and a {{mi|2.5}} paved level path that circles the whole peninsula.\n}}\n{{see\n| name=Kubota Gardens | alt= | url=https://www.seattle.gov/parks/find/parks/kubota-garden | email=\n| address=9817 55th Ave S | lat=47.512 | long=-122.2657 | directions=\n| phone=+1 206 684-4075 | tollfree= | fax=\n| hours=Daily 6AM-10PM | price=Free\n| wikipedia=Kubota Garden | wikidata=Q6441303\n| lastedit=2017-07-13\n| content=In 1927 Fujitaro Kubota bought {{convert|5|acre|m2}} of logged-off swampland in the Rainier Beach neighborhood of Seattle and began his garden. Kubota Garden has since grown into a stunning {{convert|20|acre|m2|adj=on}} landscape that blends Japanese garden concepts with native Northwest plants. Kubota was a horticultural pioneer when he began merging Japanese design techniques with North American materials in his display garden in 1927. The Gardens are a spectacular setting of hills and valleys, interlaced with streams, waterfalls, ponds, bridges, and rock outcroppings with a rich array of plant material.\n}}\n{{do\n| name=The Beacon Cinema | alt= | url=https://thebeacon.film/ | [email protected]\n| address=4405 Rainier Ave S | lat=47.5641 | long=-122.2884 | directions=in Columbia City\n| phone=+1 206 420-7328 | tollfree= | fax=\n| hours=Vary by movie times | price=\n| content=A relatively new movie theater focused on specialized programming.\n}}\n{{do\n| name=Columbia City Theater | alt= | url=https://www.columbiacitytheater.com/ | email=\n| address=4916 Rainier Ave S | lat=47.55727 | long=-122.28451 | directions=\n| phone=+1 206 722-3009 | tollfree=\n| hours=Tue-Sun 6PM-2AM | price=\n| content=Great place to see a band play.\n}}"
+ },
+ {
+ "title": "Seattle/West",
+ "sourceUrl": "https://en.wikivoyage.org/wiki/Seattle/West",
+ "wikitext": "{{see\n| name=Alki Point Lighthouse | alt= | url= | email=\n| address=3200 Point Pl SW | lat=47.57638889 | long=-122.42055556 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Alki Point Light | image=Alkilighthouse.jpg | wikidata=Q2641400\n| content=Historic Lighthouse on Alki Point a short walk from Alki Beach sometimes offers public tours but can at be at least partially seen from the road.\n}}\n{{see\n| name=Statue of Liberty | alt= | url= | email=\n| address= | lat=47.57949 | long=-122.41063 | directions=\n| phone= | tollfree=\n| hours= | price=\n| wikidata=Q56670195\n| lastedit=2025-02-15\n| content=The small replica toward the west end of the beach area is much beloved in the neighborhood. West Seattle has a peculiar affinity to New York City, where a few influential early settlers were from.\n}}\n{{see\n| name=Hamilton Viewpoint Park | alt= | url= | email=\n| address=1531 California Way SW (between Atlantic St & S Ferry Ave) | lat=47.5934 | long=-122.3860 | directions=Take the West Seattle Water Taxi from downtown Seattle\n| phone= | tollfree= | fax=\n| hours=4AM-1130PM | price=Free\n| wikipedia=Hamilton Viewpoint | wikidata=Q5645229\n| content=Hamilton Viewpoint Park rests above the beaches of Alki facing downtown Seattle. Located adjacent to the Seacrest Ferry Dock.\n}}\n{{see\n| name=Lincoln Park | alt= | url=https://www.seattle.gov/parks/allparks/lincoln-park | email=\n| address=8011 Fauntleroy Way SW | lat=47.5307 | long=-122.3978 | directions=From the West Seattle Bridge, continue on Fauntleroy Way until you reach the park on the right.\n| phone=+1 206 684-4075 | tollfree= | fax=\n| hours=4AM-11:30PM daily | price=Free\n| wikipedia=Lincoln Park (Seattle) | wikidata=Q6550922\n| content=Lincoln Park is a big, wild park, next to the Fauntleroy Ferry Dock, serving Vashon Island and Southworth. Lincoln Park has lots of walking paths, picnic areas, beautiful trees, and beach. Unlike Alki Beach, it's quite pebbly and full of driftwood. To reach the park: come across the West Seattle Bridge into West Seattle, bear left at the \"Y\" intersection at Fauntleroy. Stay on Fauntleroy all the way to the park. Follow the signs to the ferries.\n}}\n{{see\n| name=Schmitz Park | alt= | url=https://www.seattle.gov/parks/allparks/schmitz-preserve-park | email=\n| address=5551 SW Admiral Way, 98116 | lat=47.5742 | long=-122.3998 | directions=\n| phone= | tollfree= | fax=\n| hours= | price=\n| wikipedia=Schmitz Park (Seattle) | wikidata=Q7431836\n| content=53.1 acre Schmitz Preserve Park has old growth forest, walking paths and an urban stream.\n}}\n{{see\n| name=The Viewpoint at Admiral | url= | email=\n| address=3600 SW Admiral Way | lat=47.5805 | long=-122.3770 | directions=From I-5, take exit 163 to the West Seattle bridge, exit onto Admiral Way. Just before the top of the long climb there's a pullout on the right and a fenced viewing area.\n| phone= | tollfree= | fax=\n| hours= | price=\n| content=Some of the best views of the Seattle skyline are from West Seattle and the Viewpoint at Admiral is the best of the best. You can't miss it as there are usually people there. Bring your camera.\n}}\n{{do\n| name=Alki Beach Park | alt= | url=https://www.seattle.gov/parks/allparks/alki-beach-park | email=\n| address=1702 Alki Ave SW | lat=47.580989 | long=-122.406729 | directions=From the West Seattle Bridge, exit onto Harbor Avenue, and then bear left onto Alki Avenue. The beach runs on the right side\n| phone=+1 206 233-1509 | tollfree= | fax=\n| hours=Apr 15-Oct 1: 6AM-11PM daily, Oct 2-Apr 14: 4AM-11:30PM daily | price=Free\n| wikipedia=Alki Beach Park | image=West Seattle Statue of Liberty 02.jpg | wikidata=Q4727699\n| content=Alki Beach Park is one of the best and biggest sandy beaches in Seattle. There's a rollerblade/bike/walking path along it for miles and the beach itself is always busy and fun. Great views of the city, the ferries and the Olympics. There are several great cafes and coffee shops right across the street. Good place for barbeque as well as many outdoor recreations. It has one of the best night view of Downtown Seattle and the city light.\n}}\n{{do\n| name=West Seattle Golf Course | url=https://www.seattlegolf.com/westseattle/ | email=\n| address=4470 35th Ave SW | lat=47.5623 | long=-122.3714 | directions=\n| phone=+1 206 935-5187 | tollfree= | fax=\n| hours= | price=\n| content=This golf course is one of the finest in the city with great views of Seattle. Fox Den Grille is the restaurant inside that offers breakfast, lunch, dinner, and beverages. Rates of the course are fair depending on the days you go, and the course is well maintained. There are many tournaments going on for golf lovers to participate.\n}}\n{{do\n| name=Seacrest Park | url=https://www.seattle.gov/parks/allparks/seacrest-park | email=\n| address=1660 Harbor Ave SW | lat=47.5887 | long=-122.3800 | directions=Boathouse and Pier is located on Harbor Avenue next to Don Armeni Boat Ramp and is accessible by boat on Elliott Bay.\n| phone= | tollfree= | fax=\n| hours=4AM - 11:30PM | price=\n| content=Seacrest Cove 2 is a local dive site in West Seattle, Washington. The site is within Seacrest Park. Cove 2 is often used as a training site for Open Water, Navigation and other scuba classes. The site has a moderate slope leading down to depths greater than the recreational limit of 130 feet (40 meters). Because of the easily reached deep areas Technical Divers also use Cove 2 for training. The site has a silty bottom, but many submerged logs and pilings and sunken boats (the Honey Bear) that are well decorated with Pacific Northwest Marine Life. There are restrooms in the pier building and a great little Fish and Chips bar within easy walking distance. The site sports a nice view of the Seattle skyline across Elliot Bay. There is an exclusion zone of 150 feet around the walk-on ferry dock. Other sites (Coves 1 and 3!) are just to the north and south respectively. Most parking is curbside on the street.\n}}\n{{do\n| name=Alki Kayak Tours | alt= | url=https://www.kayakalki.com/ | [email protected]\n| address=1660 Harbor Ave SW | lat=47.5894 | long=-122.3806 | directions=\n| phone=+1 206-953-0237 | tollfree=\n| hours= | price=\n| content=seasonal guided sea kayak tours for beginners and experts exploring the natural & human history of Elliot Bay and Puget Sound. They also offer a range of rentals along Alki beach – including stand up paddle boards, quad and inline skates, bicycles and fishing boats.\n}}\n{{do\n| name=[[Blake Island State Park]] | alt= | url= | email=\n| address= | lat=47.5418 | long=-122.4837 | directions=located in Puget Sound in the waters between West Seattle and the Kitsap Peninsula\n| phone= | tollfree=\n| hours= | price=\n| content=West Seattle is a popular launch site to visit this island state park accessible only by boat that was the birthplace of Chief Seattle.\n}}"
+ }
+ ]
+}
@@ -34,7 +34,8 @@ async function boot(opts = {}) {
@@ -34,7 +34,8 @@ async function boot(opts = {}) {
TOURS_DRILL: '1', TOURS_EVENTS_URL: network.url,
TOURS_DRILL: '1', TOURS_EVENTS_URL: network.url,
OV_LIVE_URL: upstreams.url, OV_RENT_URL: upstreams.url,
OV_LIVE_URL: upstreams.url, OV_RENT_URL: upstreams.url,
OV_WIKIVOYAGE_URL: upstreams.url, OV_WIKIDATA_URL: upstreams.url, OV_WIKIPEDIA_URL: upstreams.url,
OV_WIKIVOYAGE_URL: upstreams.url, OV_WIKIDATA_URL: upstreams.url, OV_WIKIPEDIA_URL: upstreams.url,
- OV_COMMONS_URL: upstreams.url, OV_OVERPASS_URL: `${upstreams.url}/interpreter`,
+ OV_COMMONS_URL: upstreams.url, OV_OVERPASS_URL: `${upstreams.url}/interpreter`, OV_NOMINATIM_URL: upstreams.url,
+ OV_WIKIDATA_QUERY_URL: `${upstreams.url}/sparql`,
TOURS_MIN_INTERVAL_MS: '0', TOURS_CITY_LIMIT: '3', TOURS_OSM_RADIUS_M: '3000',
TOURS_MIN_INTERVAL_MS: '0', TOURS_CITY_LIMIT: '3', TOURS_OSM_RADIUS_M: '3000',
...(opts.env || {}),
...(opts.env || {}),
};
};
@@ -16,6 +16,10 @@
@@ -16,6 +16,10 @@
const http = require('http');
const http = require('http');
const WIKIVOYAGE = {
const WIKIVOYAGE = {
+ Seattle: `'''Seattle''' sits beside Puget Sound, with parks, gardens, and waterfront walks.
+==See==
+{{see | name=Kubota Garden | lat=47.5122 | long=-122.2654 | content=The Japanese garden has a moon bridge and seasonal colour.}}
+`,
Tokyo: `'''Tokyo''' is the capital of Japan and one of the world's great cities. It mixes quiet shrines with dense neighbourhoods, and the trains go everywhere.
Tokyo: `'''Tokyo''' is the capital of Japan and one of the world's great cities. It mixes quiet shrines with dense neighbourhoods, and the trains go everywhere.
==See==
==See==
@@ -86,6 +90,7 @@ The oldest quarter, climbed by tram and on foot.
@@ -86,6 +90,7 @@ The oldest quarter, climbed by tram and on foot.
};
};
const ENTITIES = {
const ENTITIES = {
+ Q5083: { label: 'Seattle', lat: 47.6062, lon: -122.3321, population: 737015, image: 'File:Seattle test.jpg', country: 'Q30', sitelink: 'Seattle' },
Q1490: { label: 'Tokyo', lat: 35.6762, lon: 139.6503, population: 13960000, image: 'File:Tokyo test.jpg', country: 'Q17', sitelink: 'Tokyo' },
Q1490: { label: 'Tokyo', lat: 35.6762, lon: 139.6503, population: 13960000, image: 'File:Tokyo test.jpg', country: 'Q17', sitelink: 'Tokyo' },
Q1353: { label: 'Delhi', lat: 28.6139, lon: 77.2090, population: 16787941, image: 'File:Delhi test.jpg', country: 'Q668', sitelink: 'Delhi' },
Q1353: { label: 'Delhi', lat: 28.6139, lon: 77.2090, population: 16787941, image: 'File:Delhi test.jpg', country: 'Q668', sitelink: 'Delhi' },
Q8686: { label: 'Shanghai', lat: 31.2304, lon: 121.4737, population: 24870895, image: 'File:Shanghai test.jpg', country: 'Q148', sitelink: 'Shanghai' },
Q8686: { label: 'Shanghai', lat: 31.2304, lon: 121.4737, population: 24870895, image: 'File:Shanghai test.jpg', country: 'Q148', sitelink: 'Shanghai' },
@@ -93,8 +98,8 @@ const ENTITIES = {
@@ -93,8 +98,8 @@ const ENTITIES = {
Q90: { label: 'Paris', lat: 48.8566, lon: 2.3522, population: 2140526, image: 'File:Paris test.jpg', country: 'Q142', sitelink: 'Paris' },
Q90: { label: 'Paris', lat: 48.8566, lon: 2.3522, population: 2140526, image: 'File:Paris test.jpg', country: 'Q142', sitelink: 'Paris' },
Q1492: { label: 'Barcelona', lat: 41.3874, lon: 2.1686, population: 1620343, image: 'File:Barcelona test.jpg', country: 'Q29', sitelink: 'Barcelona' },
Q1492: { label: 'Barcelona', lat: 41.3874, lon: 2.1686, population: 1620343, image: 'File:Barcelona test.jpg', country: 'Q29', sitelink: 'Barcelona' },
};
};
-const COUNTRIES = { Q17: { label: 'Japan', code: 'JP' }, Q668: { label: 'India', code: 'IN' }, Q148: { label: 'China', code: 'CN' }, Q45: { label: 'Portugal', code: 'PT' }, Q142: { label: 'France', code: 'FR' }, Q29: { label: 'Spain', code: 'ES' } };
-const TITLE_QID = { Tokyo: 'Q1490', Delhi: 'Q1353', Shanghai: 'Q8686', Lisbon: 'Q597', Paris: 'Q90', Barcelona: 'Q1492', 'New York City': 'Q60' };
+const COUNTRIES = { Q30: { label: 'United States', code: 'US' }, Q17: { label: 'Japan', code: 'JP' }, Q668: { label: 'India', code: 'IN' }, Q148: { label: 'China', code: 'CN' }, Q45: { label: 'Portugal', code: 'PT' }, Q142: { label: 'France', code: 'FR' }, Q29: { label: 'Spain', code: 'ES' } };
+const TITLE_QID = { Seattle: 'Q5083', Tokyo: 'Q1490', Delhi: 'Q1353', Shanghai: 'Q8686', Lisbon: 'Q597', Paris: 'Q90', Barcelona: 'Q1492', 'New York City': 'Q60' };
const LIVE = {
const LIVE = {
streams: [{ id: 1, username: 'ada', display_name: 'Ada', title: 'IRL walk in Lisbon', category: 'irl', tags: '[]', viewer_count: 12, thumbnail_url: null, started_at: '2026-10-31 10:00:00' }],
streams: [{ id: 1, username: 'ada', display_name: 'Ada', title: 'IRL walk in Lisbon', category: 'irl', tags: '[]', viewer_count: 12, thumbnail_url: null, started_at: '2026-10-31 10:00:00' }],
@@ -131,6 +136,7 @@ async function startUpstreams() {
@@ -131,6 +136,7 @@ async function startUpstreams() {
clips: JSON.parse(JSON.stringify(LIVE.clips)),
clips: JSON.parse(JSON.stringify(LIVE.clips)),
listings: JSON.parse(JSON.stringify(LISTINGS)),
listings: JSON.parse(JSON.stringify(LISTINGS)),
overpass: null,
overpass: null,
+ nominatim: {},
down: {},
down: {},
requests: [],
requests: [],
};
};
@@ -140,6 +146,9 @@ async function startUpstreams() {
@@ -140,6 +146,9 @@ async function startUpstreams() {
state.requests.push({ method: req.method, path: url.pathname, query: url.search, headers: req.headers, body: raw });
state.requests.push({ method: req.method, path: url.pathname, query: url.search, headers: req.headers, body: raw });
if (state.down[url.pathname] || state.down.all) return json(503, { error: 'source down' });
if (state.down[url.pathname] || state.down.all) return json(503, { error: 'source down' });
+ if (url.pathname === '/search') return json(200, state.nominatim[String(url.searchParams.get('q') || '').toLowerCase()] || []);
+ if (url.pathname === '/sparql') return json(200, { results: { bindings: state.notable || [] } });
+
if (url.pathname === '/w/api.php') {
if (url.pathname === '/w/api.php') {
const action = url.searchParams.get('action');
const action = url.searchParams.get('action');
if (action === 'parse') {
if (action === 'parse') {
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { boot, check, done } = require('./helpers/boot');
+const { createIndexNow } = require('../server/index-now');
+const KEY = 'testkey1';
+
+(async () => {
+ await check('IndexNow batches only canonical city and place pages', async () => {
+ const posts = [];
+ const index = createIndexNow({ baseUrl: 'https://openvibe.tours', key: KEY,
+ fetchImpl: async (_url, options) => { posts.push(JSON.parse(options.body)); return { status: 200 }; } });
+ index.add('/city/seattle'); index.add('/city/seattle/free');
+ index.add('/place/kubota-garden-plc_0123456789ABCDEFGHJKMNPQRS');
+ index.add('/place/not-a-real-id'); index.add('https://elsewhere.example/');
+ await index.flush();
+ assert.strictEqual(posts.length, 1);
+ assert.deepEqual(posts[0].urlList, [
+ 'https://openvibe.tours/city/seattle', 'https://openvibe.tours/city/seattle/free',
+ 'https://openvibe.tours/place/kubota-garden-plc_0123456789ABCDEFGHJKMNPQRS',
+ ]);
+ assert.strictEqual(posts[0].key, KEY);
+ });
+ const t = await boot({ env: { INDEXNOW_KEY: KEY } });
+ try {
+ await check('IndexNow ownership key is served as a public text file', async () => {
+ const response = await t.get(`/${KEY}.txt`);
+ assert.strictEqual(response.status, 200);
+ assert.strictEqual(response.text, KEY);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((err) => { process.stdout.write(String(err.stack || err)); process.exit(1); });
@@ -25,13 +25,13 @@ const { seed } = require('./helpers/seed');
@@ -25,13 +25,13 @@ const { seed } = require('./helpers/seed');
assert.match(r.text, /href="\/routes\/rte_/);
assert.match(r.text, /href="\/routes\/rte_/);
assert.match(r.text, /href="\/stays"/);
assert.match(r.text, /href="\/stays"/);
assert.match(r.text, /TouristAttraction|TouristDestination|FAQPage/);
assert.match(r.text, /TouristAttraction|TouristDestination|FAQPage/);
- // Cards ask Wikimedia for the width they draw, not the 1200 px file the city page keeps.
- assert.match(r.text, /srcset="[^"]*\/330px-Tokyo_test\.jpg 330w[^"]*\/640px-Tokyo_test\.jpg 640w"/, 'cards carry 330/640');
+ // Commons may reject new widths for a returned thumbnail; cards use the verified URL.
+ assert.match(r.text, /\/1280px-Tokyo_test\.jpg/, 'cards use the Commons imageinfo thumbnail');
assert.match(r.text, /width="640" height="360"/, 'the card shape is declared');
assert.match(r.text, /width="640" height="360"/, 'the card shape is declared');
const cities = await t.get('/cities');
const cities = await t.get('/cities');
assert.strictEqual(cities.status, 200);
assert.strictEqual(cities.status, 200);
- assert.match(cities.text, /sizes="58px"/, 'a city row says the 58 px it draws');
- assert.match(cities.text, /\/160px-Tokyo_test\.jpg 160w[^"]*\/330px-Tokyo_test\.jpg 330w/, 'a city row carries 160/330');
+ assert.match(cities.text, /width="58" height="44"/, 'a city row declares its compact image size');
+ assert.match(cities.text, /\/1280px-Tokyo_test\.jpg/, 'a city row uses the verified Commons thumbnail');
// The FAQPage data asks exactly the questions the page shows (structured data names what is on the page).
// The FAQPage data asks exactly the questions the page shows (structured data names what is on the page).
const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => [].concat(JSON.parse(m[1])))
const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => [].concat(JSON.parse(m[1])))
.flatMap((x) => (x['@graph'] ? x['@graph'] : [x]));
.flatMap((x) => (x['@graph'] ? x['@graph'] : [x]));
@@ -67,7 +67,7 @@ const { seed } = require('./helpers/seed');
@@ -67,7 +67,7 @@ const { seed } = require('./helpers/seed');
assert.match(r.text, /"@type":"TouristDestination"/);
assert.match(r.text, /"@type":"TouristDestination"/);
assert.match(r.text, /"@type":"TouristAttraction"/);
assert.match(r.text, /"@type":"TouristAttraction"/);
assert.match(r.text, /"@type":"TouristTrip"/);
assert.match(r.text, /"@type":"TouristTrip"/);
- assert.match(r.text, /href="\/place\/plc_/);
+ assert.match(r.text, /href="\/place\/[a-z0-9-]+-plc_/);
assert.match(r.text, /href="\/routes\/rte_/);
assert.match(r.text, /href="\/routes\/rte_/);
assert.match(r.text, /A flat in Alfama/);
assert.match(r.text, /A flat in Alfama/);
assert.match(r.text, /IRL walk in Lisbon/);
assert.match(r.text, /IRL walk in Lisbon/);
@@ -39,7 +39,7 @@ const node = (tags) => ({ type: 'node', id: 9001, lat: 29.56, lon: 106.58, tags:
@@ -39,7 +39,7 @@ const node = (tags) => ({ type: 'node', id: 9001, lat: 29.56, lon: 106.58, tags:
await check('the city page lists it by its English name, with its own name in the line under it', async () => {
await check('the city page lists it by its English name, with its own name in the line under it', async () => {
const r = await t.get(`/city/${city.slug}`);
const r = await t.get(`/city/${city.slug}`);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.match(r.text, /<strong>Hongya Cave<\/strong><span class="tr-muted">洪崖洞 · attraction/);
+ assert.match(r.text, /<h3><a href="[^"]+">Hongya Cave<\/a><\/h3><p class="tr-pcard-meta">洪崖洞 · attraction/);
});
});
await check('a row leaves out a kind its name already says, and the facts join without a stray separator', async () => {
await check('a row leaves out a kind its name already says, and the facts join without a stray separator', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+'use strict';
+const assert = require('assert');
+const fixture = require('./fixtures/seattle-districts.json');
+const { boot, check, done } = require('./helpers/boot');
+const { parseCityPage } = require('../server/sources/wikivoyage');
+const { overpassQuery, placeFromElement, createOverpass } = require('../server/sources/osm');
+const { isPlace } = require('../server/places/is-place');
+
+(async () => {
+ await check('recorded Seattle district See and Do listings contain many real places', async () => {
+ const names = fixture.pages.flatMap((page) => {
+ const parsed = parseCityPage(page.wikitext, page.title, { districts: 0 });
+ return [...parsed.see, ...parsed.do].filter((x) => x.hasCoords && isPlace(x.name)).map((x) => x.name);
+ });
+ assert.ok(fixture.districts.length >= 10);
+ assert.ok(names.length >= 24);
+ assert.ok(names.includes('Space Needle'));
+ assert.ok(!names.includes('Seattle CityPASS'));
+ });
+ await check('Overpass queries only named destinations and protects wikidata-tagged results', async () => {
+ const tagged = overpassQuery(47.6, -122.3, 15000, true);
+ const ordinary = overpassQuery(47.6, -122.3, 15000);
+ for (const query of [tagged, ordinary]) {
+ assert.match(query, /\["name"\]/);
+ assert.match(query, /museum\|gallery\|viewpoint/);
+ assert.match(query, /park\|garden\|nature_reserve/);
+ assert.doesNotMatch(query, /tourism"\]/);
+ }
+ assert.match(tagged, /\["wikidata"\]/);
+ assert.ok(ordinary.indexOf('["wikidata"]') < ordinary.lastIndexOf('out center tags 400'));
+ const calls = [];
+ const fetchImpl = async (_url, opts) => {
+ const query = new URLSearchParams(opts.body).get('data');
+ calls.push(query);
+ const elements = [{ type: 'node', id: 1, lat: 47.6, lon: -122.3, tags: { name: 'Space Needle', tourism: 'attraction', wikidata: 'Q1' } },
+ { type: 'node', id: 1, lat: 47.6, lon: -122.3, tags: { name: 'Space Needle', tourism: 'attraction', wikidata: 'Q1' } },
+ { type: 'node', id: 2, lat: 47.61, lon: -122.31, tags: { name: 'Seattle CityPASS', tourism: 'attraction' } }];
+ return { ok: true, status: 200, text: async () => JSON.stringify({ elements }) };
+ };
+ const osm = createOverpass({ config: { upstreams: { overpass: 'https://example.test/interpreter', timeoutMs: 1000 },
+ openData: { userAgent: 'Tours test', radiusM: 15000 } }, fetchImpl });
+ const result = await osm.tourism(47.6, -122.3);
+ assert.strictEqual(result.elements.length, 2);
+ assert.strictEqual(calls.length, 1);
+ assert.match(calls[0], /\["wikidata"\]/);
+ assert.ok(!placeFromElement(result.elements[1], 'cty_1', result.fetchedAt));
+ assert.ok(!placeFromElement({ type: 'node', id: 3, lat: 47.6, lon: -122.3,
+ tags: { name: 'Visitor centre', tourism: 'attraction', shop: 'yes' } }, 'cty_1', result.fetchedAt));
+ assert.ok(!isPlace('A scenic place', { instanceLabel: 'travel agency' }));
+ assert.ok(!isPlace('Seattle Pass'));
+ assert.ok(!isPlace('Seattle Tours'));
+ });
+ const t = await boot();
+ try {
+ t.upstreams.state.nominatim.seattle = [{ name: 'Seattle', type: 'city', addresstype: 'city', lat: '47.6062', lon: '-122.3321',
+ address: { city: 'Seattle', country: 'United States', country_code: 'us' }, extratags: { wikidata: 'Q5083' } }];
+ t.upstreams.state.pages.Seattle += fixture.districts.map((x) => `[[${x}|district]]`).join(' ');
+ for (const page of fixture.pages.slice(1)) t.upstreams.state.pages[page.title] = page.wikitext;
+ t.upstreams.state.notable = [
+ { item: { value: 'http://www.wikidata.org/entity/Q9991' }, label: { value: 'Seattle CityPASS' },
+ coordinate: { value: 'Point(-122.3 47.6)' }, sitelinks: { value: '100' }, instanceLabel: { value: 'tourist attraction' } },
+ { item: { value: 'http://www.wikidata.org/entity/Q9992' }, label: { value: 'New Seattle Viewpoint' },
+ coordinate: { value: 'Point(-122.33 47.62)' }, sitelinks: { value: '50' }, instanceLabel: { value: 'scenic viewpoint' } },
+ { item: { value: 'http://www.wikidata.org/entity/Q9993' }, label: { value: 'Seattle Excursions' },
+ coordinate: { value: 'Point(-122.34 47.63)' }, sitelinks: { value: '40' }, instanceLabel: { value: 'travel agency' } },
+ ];
+ await check('on-demand city follows every district, caches one Wikidata query, and lists at least 24 places', async () => {
+ const city = await t.ctx.discover.ensureCity('Seattle');
+ const places = await t.ctx.store.cityPlaces(city.id, { limit: 200 });
+ assert.ok(places.length >= 24, String(places.length));
+ assert.ok(places.some((p) => p.name === 'New Seattle Viewpoint'));
+ assert.ok(!places.some((p) => p.name === 'Seattle CityPASS'));
+ assert.ok(!places.some((p) => p.name === 'Seattle Excursions'));
+ assert.strictEqual(t.upstreams.state.requests.filter((r) => r.path === '/sparql').length, 1);
+ const queryRequest = t.upstreams.state.requests.find((r) => r.path === '/sparql');
+ const query = new URLSearchParams(queryRequest.query.slice(1)).get('query');
+ assert.match(query, /wdt:P131\+ wd:Q5083/);
+ assert.match(query, /wdt:P31 \?kind/);
+ assert.match(queryRequest.headers['user-agent'], /OpenVibeTours/);
+ assert.strictEqual(t.upstreams.state.requests.filter((r) => r.path === '/w/api.php' && r.query.includes('action=parse')).length,
+ 1 + fixture.districts.length);
+ await t.ctx.jobs.places.populateCity(city);
+ assert.strictEqual(t.upstreams.state.requests.filter((r) => r.path === '/sparql').length, 1);
+ });
+ await check('an existing thin Seattle city page refreshes missing district data', async () => {
+ const city = await t.ctx.store.cityBySlug('seattle');
+ await t.ctx.s.db.exec('DELETE FROM tours_places WHERE city_id=$1', [city.id]);
+ await t.ctx.s.db.exec('DELETE FROM tours_wikidata_city_places WHERE city_id=$1', [city.id]);
+ await t.ctx.store.upsertCitySource(city.id, {
+ wikivoyageTitle: 'Seattle', wikitext: JSON.stringify({ pages: [{ title: 'Seattle',
+ wikitext: t.upstreams.state.pages.Seattle, sourceUrl: 'https://en.wikivoyage.org/wiki/Seattle' }] }),
+ sourceUrl: 'https://en.wikivoyage.org/wiki/Seattle', fetchedAt: new Date().toISOString(),
+ });
+ const before = t.upstreams.state.requests.filter((r) => r.path === '/w/api.php' && r.query.includes('action=parse')).length;
+ const page = await t.get('/city/seattle');
+ assert.strictEqual(page.status, 200);
+ assert.ok((await t.ctx.store.cityPlaces(city.id, { limit: 200 })).length >= 24);
+ assert.strictEqual(t.upstreams.state.requests.filter((r) => r.path === '/w/api.php' && r.query.includes('action=parse')).length - before,
+ fixture.districts.length);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((err) => { process.stdout.write(String(err.stack || err)); process.exit(1); });
@@ -19,11 +19,11 @@ const imageWidth = new Function(`${iw[0]}; return imageWidth;`)();
@@ -19,11 +19,11 @@ const imageWidth = new Function(`${iw[0]}; return imageWidth;`)();
// Cards ask for the width they draw on both URL shapes Wikimedia's imageinfo returns, and on a
// Cards ask for the width they draw on both URL shapes Wikimedia's imageinfo returns, and on a
// stored original; no other host is rewritten.
// stored original; no other host is rewritten.
const thumbUrl = 'https://thumb.wikimedia.org/wikipedia/commons/thumb/a/ab/Foo.jpg/1280px-Foo.jpg?utm_source=commons.wikimedia.org';
const thumbUrl = 'https://thumb.wikimedia.org/wikipedia/commons/thumb/a/ab/Foo.jpg/1280px-Foo.jpg?utm_source=commons.wikimedia.org';
-assert.strictEqual(imageWidth(thumbUrl, 330), 'https://thumb.wikimedia.org/wikipedia/commons/thumb/a/ab/Foo.jpg/330px-Foo.jpg?utm_source=commons.wikimedia.org', 'a thumb URL changes its width');
-assert.strictEqual(imageWidth('https://upload.wikimedia.org/wikipedia/commons/5/51/Foo.png?utm_source=x', 640), 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/51/Foo.png/640px-Foo.png?utm_source=x', 'a stored original becomes a thumb');
+assert.strictEqual(imageWidth(thumbUrl, 330), null, 'a Commons thumbnail keeps the verified imageinfo URL');
+assert.strictEqual(imageWidth('https://upload.wikimedia.org/wikipedia/commons/5/51/Foo.png?utm_source=x', 640), null, 'an unscaled original keeps its URL because Wikimedia may reject an enlarged thumb');
assert.strictEqual(imageWidth('https://commons.wikimedia.org/wiki/Special:FilePath/Foo.jpg?width=1000', 330), 'https://commons.wikimedia.org/wiki/Special:FilePath/Foo.jpg?width=330', 'Special:FilePath takes a width');
assert.strictEqual(imageWidth('https://commons.wikimedia.org/wiki/Special:FilePath/Foo.jpg?width=1000', 330), 'https://commons.wikimedia.org/wiki/Special:FilePath/Foo.jpg?width=330', 'Special:FilePath takes a width');
assert.strictEqual(imageWidth('https://openvibe.media/t/x.jpg', 330), null, 'a non-Wikimedia picture is not rewritten');
assert.strictEqual(imageWidth('https://openvibe.media/t/x.jpg', 330), null, 'a non-Wikimedia picture is not rewritten');
-assert.strictEqual(imageWidth('https://upload.wikimedia.org/wikipedia/commons/a/ab/Foo.jpg', 330), 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Foo.jpg/330px-Foo.jpg', 'a two-letter hash prefix');
+assert.strictEqual(imageWidth('https://upload.wikimedia.org/wikipedia/commons/a/ab/Foo.jpg', 330), null, 'an original with a two-letter hash prefix keeps its URL');
assert.strictEqual(imageWidth(null, 330), null);
assert.strictEqual(imageWidth(null, 330), null);
assert.strictEqual(safeImage('https://photos.app.goo.gl/A7PbTNW1RbBbZUrTA'), null, 'a share link is not a picture');
assert.strictEqual(safeImage('https://photos.app.goo.gl/A7PbTNW1RbBbZUrTA'), null, 'a share link is not a picture');