← back to Costa Rica
VCL features + image attribution: per-region Wikipedia hero images (49/99 cantones), Leaflet map on detail page, sitemap.xml (25,995 URLs), robots.txt, JSON-LD LocalBusiness, rel=alternate Link headers, region cross-link siblings, full details panel
1670949dac88b21813b08e2b54a04e271726e28e · 2026-05-08 01:06:45 -0700 · Steve
Files touched
M public/index.htmlM public/place.htmlA scripts/ingest/region-images-fallback.jsA scripts/ingest/region-images.jsA scripts/migrate_002_images.sqlM server.js
Diff
commit 1670949dac88b21813b08e2b54a04e271726e28e
Author: Steve <steve@designerwallcoverings.com>
Date: Fri May 8 01:06:45 2026 -0700
VCL features + image attribution: per-region Wikipedia hero images (49/99 cantones), Leaflet map on detail page, sitemap.xml (25,995 URLs), robots.txt, JSON-LD LocalBusiness, rel=alternate Link headers, region cross-link siblings, full details panel
---
public/index.html | 5 +-
public/place.html | 168 ++++++++++++++++++++++++++-----
scripts/ingest/region-images-fallback.js | 48 +++++++++
scripts/ingest/region-images.js | 97 ++++++++++++++++++
scripts/migrate_002_images.sql | 12 +++
server.js | 147 ++++++++++++++++++++++++++-
6 files changed, 444 insertions(+), 33 deletions(-)
diff --git a/public/index.html b/public/index.html
index 6661c9e..128575e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -208,8 +208,9 @@
wrap.className = 'card';
wrap.href = '/p/' + encodeURIComponent(p.slug);
const verticalLabel = (p.vertical||'').replace(/_/g,' ');
- const img = p.image_url
- ? `<div class="img"><img loading="lazy" src="${p.image_url}" alt=""></div>`
+ const heroSrc = p.effective_image_url || p.image_url || p.region_image_url;
+ const img = heroSrc
+ ? `<div class="img"><img loading="lazy" src="${heroSrc}" alt=""></div>`
: `<div class="img">⛰</div>`;
const region = p.region_name ? `${p.region_name}${p.province?', '+p.province:''}` : (p.address || '');
wrap.innerHTML = `
diff --git a/public/place.html b/public/place.html
index 97cd89d..c6ea56f 100644
--- a/public/place.html
+++ b/public/place.html
@@ -4,6 +4,8 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Loading… — Costa Rica Directory</title>
+ <link rel="alternate" type="application/json" id="alt-json" />
+ <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<script>(function(){try{var t=localStorage.getItem('cr-theme');if(t)document.documentElement.setAttribute('data-theme',t);}catch(e){}})();</script>
<style>
:root{--bg:#fafaf7;--fg:#0e1015;--muted:#5a6270;--line:#e3e0d6;--card:#fff;--card-line:#ebe7da;--accent:#0a7a3a;--accent-2:#c2a85a}
@@ -12,29 +14,42 @@
body{margin:0;background:var(--bg);color:var(--fg);font-family:-apple-system,BlinkMacSystemFont,'Inter','Segoe UI',Roboto,sans-serif;line-height:1.55}
a{color:inherit} a:hover{color:var(--accent)}
.nav{position:sticky;top:0;z-index:30;background:color-mix(in srgb,var(--bg) 88%,transparent);backdrop-filter:saturate(160%) blur(10px);border-bottom:1px solid var(--line)}
- .nav-in{max-width:980px;margin:0 auto;padding:14px 22px;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
- .brand{font-weight:700;font-size:18px} .brand .pin{color:var(--accent)}
+ .nav-in{max-width:1080px;margin:0 auto;padding:14px 22px;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
+ .brand{font-weight:700;font-size:18px;text-decoration:none} .brand .pin{color:var(--accent)}
.ml-auto{margin-left:auto} .icon-btn{appearance:none;background:transparent;color:inherit;border:1px solid var(--line);border-radius:999px;width:36px;height:36px;display:inline-flex;align-items:center;justify-content:center;cursor:pointer}
- main{max-width:980px;margin:0 auto;padding:34px 22px 80px}
+ main{max-width:1080px;margin:0 auto;padding:34px 22px 80px}
.crumbs{font-size:13px;color:var(--muted);margin-bottom:8px}
h1{font-size:clamp(28px,4vw,40px);letter-spacing:-.02em;margin:0 0 6px}
.where{color:var(--muted);margin-bottom:24px;font-size:14px}
- .hero{aspect-ratio:21/9;border-radius:14px;background:linear-gradient(135deg, color-mix(in srgb, var(--accent) 35%, var(--card)), color-mix(in srgb, var(--accent-2) 30%, var(--card)));margin-bottom:24px;display:flex;align-items:center;justify-content:center;font-size:64px;color:color-mix(in srgb,var(--fg) 30%,transparent)}
- .hero img{width:100%;height:100%;object-fit:cover;border-radius:14px}
- .grid{display:grid;grid-template-columns:2fr 1fr;gap:32px}
+ .hero{position:relative;aspect-ratio:21/9;border-radius:14px;overflow:hidden;background:linear-gradient(135deg, color-mix(in srgb, var(--accent) 35%, var(--card)), color-mix(in srgb, var(--accent-2) 30%, var(--card)));margin-bottom:14px;display:flex;align-items:center;justify-content:center;font-size:64px;color:color-mix(in srgb,var(--fg) 30%,transparent)}
+ .hero img{width:100%;height:100%;object-fit:cover;display:block}
+ .credit{position:absolute;bottom:8px;right:10px;background:rgba(0,0,0,.55);color:#fff;font-size:11px;padding:4px 9px;border-radius:999px;backdrop-filter:blur(4px)}
+ .credit a{color:#fff;text-decoration:underline;text-decoration-color:rgba(255,255,255,.5)}
+ .grid{display:grid;grid-template-columns:2fr 1fr;gap:32px;margin-top:18px}
@media(max-width:760px){.grid{grid-template-columns:1fr}}
- .card{background:var(--card);border:1px solid var(--card-line);border-radius:14px;padding:18px 20px}
- .label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-weight:600;margin-bottom:4px}
- .row{display:flex;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid var(--line)}
+ .card{background:var(--card);border:1px solid var(--card-line);border-radius:14px;padding:18px 20px;margin-bottom:14px}
+ .label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-weight:600;margin-bottom:6px}
+ .row{display:flex;justify-content:space-between;gap:12px;padding:9px 0;border-bottom:1px solid var(--line)}
.row:last-child{border-bottom:0}
- .row .k{color:var(--muted);font-size:13px} .row .v{font-size:14px;text-align:right;max-width:60%;word-break:break-word}
+ .row .k{color:var(--muted);font-size:13px} .row .v{font-size:14px;text-align:right;max-width:62%;word-break:break-word}
.badges{display:flex;gap:6px;flex-wrap:wrap;margin:10px 0 6px}
.badge{font-size:11px;padding:3px 9px;border-radius:999px;background:color-mix(in srgb,var(--fg) 6%,transparent);color:var(--muted)}
.badge.verified{background:color-mix(in srgb,var(--accent) 18%,transparent);color:var(--accent)}
.desc{font-size:15px;line-height:1.7;color:color-mix(in srgb,var(--fg) 92%,transparent)}
- .cta{display:inline-block;padding:9px 16px;border:1px solid var(--accent);color:var(--accent);border-radius:999px;font-size:13px;text-decoration:none;margin-top:10px}
- .cta:hover{background:color-mix(in srgb,var(--accent) 12%,transparent)}
- footer{max-width:980px;margin:0 auto;padding:30px 22px 60px;color:var(--muted);font-size:13px;border-top:1px solid var(--line);margin-top:40px}
+ .ctas{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
+ .cta{display:inline-block;padding:9px 16px;border:1px solid var(--accent);color:var(--accent);border-radius:999px;font-size:13px;text-decoration:none;font-weight:500}
+ .cta:hover{background:color-mix(in srgb,var(--accent) 12%,transparent);text-decoration:none}
+ .cta.alt{border-color:var(--line);color:var(--muted)}
+ #map{aspect-ratio:4/3;border-radius:14px;border:1px solid var(--card-line);overflow:hidden;background:color-mix(in srgb,var(--fg) 4%,transparent)}
+ .siblings{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-top:8px}
+ @media(max-width:760px){.siblings{grid-template-columns:1fr}}
+ .sib{display:flex;gap:10px;align-items:center;padding:8px;border:1px solid var(--card-line);border-radius:10px;text-decoration:none;color:inherit;background:var(--card)}
+ .sib:hover{border-color:var(--accent)}
+ .sib .pic{width:46px;height:46px;border-radius:8px;background:linear-gradient(135deg, color-mix(in srgb, var(--accent) 35%, var(--card)), color-mix(in srgb, var(--accent-2) 30%, var(--card)));flex-shrink:0;background-size:cover;background-position:center}
+ .sib .meta{display:flex;flex-direction:column;font-size:13px}
+ .sib .meta .v{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.05em}
+ footer{max-width:1080px;margin:0 auto;padding:30px 22px 60px;color:var(--muted);font-size:13px;border-top:1px solid var(--line);margin-top:40px}
+ .leaflet-container{background:color-mix(in srgb,var(--fg) 4%,transparent)!important}
</style>
</head>
<body>
@@ -52,7 +67,7 @@
<h1 id="name">Loading…</h1>
<div class="where" id="where">—</div>
- <div class="hero" id="hero">⛰</div>
+ <div class="hero" id="hero">⛰<div class="credit" id="credit" style="display:none"></div></div>
<div class="grid">
<section>
@@ -60,7 +75,18 @@
<div class="label">About</div>
<p class="desc" id="desc">—</p>
<div class="badges" id="badges"></div>
- <a class="cta" id="cta-website" target="_blank" rel="noopener" style="display:none">Visit website ↗</a>
+ <div class="ctas" id="ctas"></div>
+ </div>
+
+ <div class="card">
+ <div class="label">Location</div>
+ <div id="map"></div>
+ <div class="row" style="border-bottom:0;padding-top:12px"><span class="k">Coordinates</span><span class="v" id="d-coords">—</span></div>
+ </div>
+
+ <div class="card" id="siblings-card" style="display:none">
+ <div class="label">More in <span id="sib-region">this region</span></div>
+ <div class="siblings" id="siblings"></div>
</div>
</section>
@@ -68,20 +94,28 @@
<div class="card">
<div class="label">Details</div>
<div class="row"><span class="k">Vertical</span><span class="v" id="d-vertical">—</span></div>
+ <div class="row"><span class="k">Region</span><span class="v" id="d-region">—</span></div>
+ <div class="row"><span class="k">Province</span><span class="v" id="d-province">—</span></div>
<div class="row"><span class="k">Address</span><span class="v" id="d-address">—</span></div>
<div class="row"><span class="k">Phone</span><span class="v" id="d-phone">—</span></div>
<div class="row"><span class="k">Email</span><span class="v" id="d-email">—</span></div>
+ <div class="row"><span class="k">Website</span><span class="v" id="d-website">—</span></div>
<div class="row"><span class="k">Cédula jurídica</span><span class="v" id="d-cedula">—</span></div>
+ <div class="row"><span class="k">Price range</span><span class="v" id="d-price">—</span></div>
+ <div class="row"><span class="k">Rating</span><span class="v" id="d-rating">—</span></div>
<div class="row"><span class="k">Source</span><span class="v" id="d-source">—</span></div>
+ <div class="row"><span class="k">Status</span><span class="v" id="d-status">—</span></div>
+ <div class="row"><span class="k">Added</span><span class="v" id="d-added">—</span></div>
</div>
</aside>
</div>
</main>
<footer>
- <div>© Costa Rica Directory. Aggregated public-record data — owners can correct or claim a listing at <a href="mailto:info@agentabrams.com">info@agentabrams.com</a>.</div>
+ <div>© Costa Rica Directory. Aggregated from public records (MEIC, ICT, municipal patentes) and local web directories. Listing owners can correct or claim this entry at <a href="mailto:info@agentabrams.com">info@agentabrams.com</a>.</div>
</footer>
+ <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
document.getElementById('theme-toggle').addEventListener('click', () => {
const cur = document.documentElement.getAttribute('data-theme') || 'light';
@@ -91,6 +125,12 @@
});
const slug = location.pathname.replace(/^\/p\//,'');
+ const altLink = document.getElementById('alt-json');
+ altLink.href = '/api/places/' + encodeURIComponent(slug);
+
+ function setHTML(id, html) { document.getElementById(id).innerHTML = html; }
+ function setText(id, v) { document.getElementById(id).textContent = v || '—'; }
+
fetch('/api/places/' + encodeURIComponent(slug)).then(r => r.ok ? r.json() : Promise.reject(r)).then(p => {
document.title = p.name + ' — Costa Rica Directory';
document.getElementById('name').textContent = p.name;
@@ -101,26 +141,100 @@
const regionLink = document.getElementById('region-link');
if (p.region_slug){ regionLink.href = '/r/' + p.region_slug; regionLink.textContent = p.region_name; }
+ // Hero image (place's own → falls back to region's Wikipedia hero)
const hero = document.getElementById('hero');
- if (p.image_url){ hero.innerHTML = '<img src="'+p.image_url+'" alt="">'; }
+ const credit = document.getElementById('credit');
+ const heroImg = p.effective_image_url || p.image_url || p.region_image_url;
+ if (heroImg) {
+ const imgEl = document.createElement('img');
+ imgEl.src = heroImg; imgEl.alt = p.name; imgEl.loading = 'eager';
+ hero.firstChild && hero.removeChild(hero.firstChild);
+ hero.insertBefore(imgEl, credit);
+ const creditText = p.effective_image_credit || p.region_image_credit;
+ const creditUrl = p.effective_image_source_url || p.region_image_source_url;
+ if (creditText && creditUrl) {
+ credit.innerHTML = '📷 <a href="'+creditUrl+'" target="_blank" rel="noopener nofollow">'+creditText+'</a>';
+ credit.style.display = '';
+ } else if (creditText) {
+ credit.textContent = '📷 ' + creditText;
+ credit.style.display = '';
+ }
+ }
+
+ setText('desc', p.description);
- document.getElementById('desc').textContent = p.description || '—';
+ // Badges
const badges = document.getElementById('badges');
const tags = [vertical, p.verified ? 'verified' : null, p.rating ? '★ ' + (+p.rating).toFixed(1) : null].filter(Boolean);
tags.forEach(t => { const span=document.createElement('span'); span.className='badge'+(t==='verified'?' verified':''); span.textContent=t; badges.appendChild(span); });
- if (p.website){ const a=document.getElementById('cta-website'); a.href=p.website; a.style.display=''; }
+ // CTAs
+ const ctas = document.getElementById('ctas');
+ const ctaList = [];
+ if (p.website) ctaList.push({label:'Visit website ↗', href: p.website, alt:false});
+ if (p.phone) ctaList.push({label:'Call ' + p.phone, href:'tel:' + p.phone.replace(/[^+\d]/g,''), alt:false});
+ if (p.email) ctaList.push({label:'Email', href:'mailto:' + p.email, alt:true});
+ const lat = p.lat || p.region_lat, lng = p.lng || p.region_lng;
+ if (lat && lng) ctaList.push({label:'Open in Google Maps ↗', href:`https://www.google.com/maps/search/?api=1&query=${lat},${lng}`, alt:true});
+ if (p.region_slug) ctaList.push({label:'More in ' + p.region_name, href:'/r/' + p.region_slug, alt:true});
+ ctaList.forEach(c => { const a=document.createElement('a'); a.className='cta'+(c.alt?' alt':''); a.href=c.href; if(c.href.startsWith('http')) a.target='_blank'; a.rel='noopener'; a.textContent=c.label; ctas.appendChild(a); });
- const set = (id,v) => document.getElementById(id).textContent = v || '—';
- set('d-vertical', vertical);
- set('d-address', p.address);
- set('d-phone', p.phone);
- set('d-email', p.email);
- set('d-cedula', p.cedula_juridica);
- const src = p.source ? (p.source_url ? '' : p.source) : '—';
+ // Details panel
+ setText('d-vertical', vertical);
+ setText('d-region', p.region_name);
+ setText('d-province', p.province);
+ setText('d-address', p.address);
+ setText('d-phone', p.phone);
+ setText('d-coords', (lat && lng) ? (+lat).toFixed(4) + ', ' + (+lng).toFixed(4) : null);
+ setHTML('d-email', p.email ? '<a href="mailto:'+p.email+'">'+p.email+'</a>' : '—');
+ setHTML('d-website', p.website ? '<a href="'+p.website+'" target="_blank" rel="noopener">'+p.website.replace(/^https?:\/\//,'').replace(/\/$/,'')+'</a>' : '—');
+ setText('d-cedula', p.cedula_juridica);
+ setText('d-price', p.price_range);
+ setText('d-rating', p.rating ? (+p.rating).toFixed(1) : null);
+ setText('d-status', p.status);
+ setText('d-added', p.created_at ? new Date(p.created_at).toLocaleDateString() : null);
const dSrc = document.getElementById('d-source');
- if (p.source_url){ dSrc.innerHTML = '<a href="'+p.source_url+'" target="_blank" rel="noopener">'+(p.source||'link')+' ↗</a>'; }
+ if (p.source_url){ dSrc.innerHTML = '<a href="'+p.source_url+'" target="_blank" rel="noopener nofollow">'+(p.source||'link')+' ↗</a>'; }
else { dSrc.textContent = p.source || '—'; }
+
+ // Map (Leaflet + OpenStreetMap)
+ if (lat && lng){
+ const map = L.map('map', { zoomControl: true, scrollWheelZoom: false }).setView([+lat, +lng], p.lat ? 15 : 12);
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' }).addTo(map);
+ const marker = L.marker([+lat, +lng]).addTo(map);
+ marker.bindPopup('<strong>'+p.name+'</strong><br>'+(p.address||p.region_name||'')).openPopup();
+ } else {
+ document.getElementById('map').textContent = 'No coordinates on file.';
+ document.getElementById('map').style.display = 'flex';
+ document.getElementById('map').style.alignItems = 'center';
+ document.getElementById('map').style.justifyContent = 'center';
+ document.getElementById('map').style.color = 'var(--muted)';
+ document.getElementById('map').style.fontSize = '13px';
+ document.getElementById('map').style.aspectRatio = 'auto';
+ document.getElementById('map').style.padding = '20px';
+ }
+
+ // Siblings ("more in region")
+ if (p.siblings_in_region && p.siblings_in_region.length) {
+ document.getElementById('siblings-card').style.display = '';
+ document.getElementById('sib-region').textContent = p.region_name || 'this region';
+ const sibs = document.getElementById('siblings');
+ p.siblings_in_region.forEach(s => {
+ const a = document.createElement('a');
+ a.className = 'sib'; a.href = '/p/' + encodeURIComponent(s.slug);
+ a.innerHTML = `<div class="pic"${s.image_url ? ' style="background-image:url('+s.image_url+')"' : ''}></div><div class="meta"><span>${s.name}</span><span class="v">${(s.vertical||'').replace(/_/g,' ')}</span></div>`;
+ sibs.appendChild(a);
+ });
+ }
+
+ // JSON-LD LocalBusiness — embed as <script type="application/ld+json">
+ fetch('/api/places/' + encodeURIComponent(slug) + '/jsonld').then(r => r.ok ? r.json() : null).then(ld => {
+ if (!ld) return;
+ const s = document.createElement('script');
+ s.type = 'application/ld+json';
+ s.textContent = JSON.stringify(ld);
+ document.head.appendChild(s);
+ }).catch(()=>{});
}).catch(() => {
document.getElementById('name').textContent = 'Listing not found';
document.getElementById('desc').textContent = 'This listing does not exist or was removed.';
diff --git a/scripts/ingest/region-images-fallback.js b/scripts/ingest/region-images-fallback.js
new file mode 100644
index 0000000..2d8a981
--- /dev/null
+++ b/scripts/ingest/region-images-fallback.js
@@ -0,0 +1,48 @@
+'use strict';
+// For regions without an image, fall back to the province image.
+const { fetchJson, sleep, pool } = require('./_lib');
+
+const PROVINCE_TITLES = {
+ 'San José': 'San_José_Province',
+ 'Alajuela': 'Alajuela_Province',
+ 'Heredia': 'Heredia_Province',
+ 'Cartago': 'Cartago_Province',
+ 'Limón': 'Limón_Province',
+ 'Puntarenas': 'Puntarenas_Province',
+ 'Guanacaste': 'Guanacaste_Province',
+};
+
+(async () => {
+ const cache = {};
+ for (const [prov, title] of Object.entries(PROVINCE_TITLES)) {
+ try {
+ const j = await fetchJson(`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`,
+ { headers: { 'Api-User-Agent': 'CR-Directory/0.1 (info@agentabrams.com)' } });
+ const img = (j.originalimage || j.thumbnail);
+ if (img) cache[prov] = {
+ url: img.source,
+ credit: `Wikipedia: ${j.title}`,
+ source_url: j.content_urls?.desktop?.page || `https://en.wikipedia.org/wiki/${encodeURIComponent(title)}`,
+ };
+ console.log(`[prov] ${prov} → ${img ? 'HIT' : 'miss'}`);
+ await sleep(400);
+ } catch (e) { console.warn(`[prov] ${prov} err: ${e.message}`); }
+ }
+
+ const { rows } = await pool.query(
+ `SELECT id, slug, name, province FROM regions WHERE region_type IN ('city','town','canton') AND image_url IS NULL`
+ );
+ let n = 0;
+ for (const r of rows) {
+ const fallback = cache[r.province];
+ if (!fallback) continue;
+ await pool.query(
+ `UPDATE regions SET image_url=$1, image_credit=$2, image_license='CC via Wikimedia Commons',
+ image_source_url=$3, image_fetched_at=NOW() WHERE id=$4`,
+ [fallback.url, fallback.credit + ' (province fallback)', fallback.source_url, r.id]
+ );
+ n++;
+ }
+ console.log(`[fallback] applied province image to ${n} regions`);
+ await pool.end();
+})();
diff --git a/scripts/ingest/region-images.js b/scripts/ingest/region-images.js
new file mode 100644
index 0000000..e6bf26c
--- /dev/null
+++ b/scripts/ingest/region-images.js
@@ -0,0 +1,97 @@
+'use strict';
+
+// Fetches a hero image for every region from Wikipedia's REST summary API.
+// Returns {originalimage, thumbnail} + an attributable page URL. Public/CC
+// licensed via Wikimedia Commons by definition. We never inline-host images
+// from the Wikimedia upload origin in production (browser hot-links the
+// commons URL directly, which is permitted under their TOU when paired
+// with attribution + source link).
+
+const { fetchJson, sleep, pool } = require('./_lib');
+
+const TITLE_FALLBACKS = {
+ 'san-jose': ['San_José,_Costa_Rica', 'San_José_(canton)', 'San_José_Province'],
+ 'la-fortuna': ['La_Fortuna,_San_Carlos', 'Arenal_Volcano'],
+ 'manuel-antonio': ['Manuel_Antonio_National_Park', 'Manuel_Antonio_(canton)'],
+ 'monteverde': ['Monteverde', 'Monteverde_Cloud_Forest_Reserve'],
+ 'santa-elena': ['Santa_Elena,_Puntarenas', 'Monteverde'],
+ 'puerto-viejo': ['Puerto_Viejo_de_Talamanca', 'Puerto_Viejo,_Limón'],
+ 'cahuita': ['Cahuita_National_Park', 'Cahuita'],
+ 'tamarindo': ['Tamarindo,_Costa_Rica'],
+ 'nosara': ['Nosara'],
+ 'santa-teresa': ['Santa_Teresa,_Costa_Rica'],
+ 'montezuma': ['Montezuma,_Costa_Rica'],
+ 'jaco': ['Jacó,_Costa_Rica'],
+ 'quepos': ['Quepos'],
+ 'uvita': ['Uvita'],
+ 'dominical': ['Dominical,_Costa_Rica'],
+ 'limon': ['Limón'],
+ 'puntarenas': ['Puntarenas'],
+ 'papagayo': ['Papagayo,_Costa_Rica', 'Gulf_of_Papagayo'],
+ 'playas-coco': ['Playas_del_Coco'],
+ 'flamingo': ['Playa_Flamingo'],
+ 'samara': ['Sámara,_Costa_Rica'],
+ 'nicoya': ['Nicoya', 'Nicoya_Peninsula'],
+ 'liberia': ['Liberia,_Costa_Rica'],
+ 'heredia': ['Heredia,_Costa_Rica'],
+ 'cartago': ['Cartago,_Costa_Rica'],
+ 'alajuela': ['Alajuela'],
+ 'atenas': ['Atenas,_Costa_Rica'],
+ 'grecia': ['Grecia,_Costa_Rica'],
+ 'escazu': ['Escazú_(canton)'],
+ 'santa-ana': ['Santa_Ana_(canton)'],
+};
+
+// Generic fallback: try "<Name>,_Costa_Rica" then "<Name>" alone.
+function candidateTitles(slug, name) {
+ if (TITLE_FALLBACKS[slug]) return TITLE_FALLBACKS[slug];
+ const safeName = name.replace(/\s+/g, '_');
+ return [`${safeName},_Costa_Rica`, safeName];
+}
+
+async function fetchWikiSummary(title) {
+ const url = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`;
+ try {
+ const j = await fetchJson(url, { headers: { 'Api-User-Agent': 'CR-Directory/0.1 (info@agentabrams.com)' } });
+ if (j.type === 'disambiguation') return null;
+ return j;
+ } catch (e) {
+ if (/HTTP 404/.test(e.message)) return null;
+ throw e;
+ }
+}
+
+(async () => {
+ const { rows: regions } = await pool.query(
+ `SELECT id, slug, name, image_url FROM regions WHERE region_type IN ('city','town','canton') ORDER BY id`
+ );
+ console.log(`[region-images] fetching for ${regions.length} regions`);
+
+ let hits = 0, miss = 0, skipped = 0;
+ for (const r of regions) {
+ if (r.image_url) { skipped++; continue; }
+ const titles = candidateTitles(r.slug, r.name);
+ let summary = null, usedTitle = null;
+ for (const t of titles) {
+ const s = await fetchWikiSummary(t);
+ if (s && (s.thumbnail || s.originalimage)) { summary = s; usedTitle = t; break; }
+ await sleep(300);
+ }
+ if (!summary) { miss++; console.log(`[region-images] miss ${r.slug.padEnd(20)} (${r.name})`); continue; }
+
+ const img = (summary.originalimage || summary.thumbnail).source;
+ const sourceUrl = summary.content_urls?.desktop?.page || `https://en.wikipedia.org/wiki/${encodeURIComponent(usedTitle)}`;
+ const credit = `Wikipedia: ${summary.title}`;
+ const license = 'See Wikimedia Commons file page';
+ await pool.query(
+ `UPDATE regions SET image_url=$1, image_credit=$2, image_license=$3, image_source_url=$4, image_fetched_at=NOW() WHERE id=$5`,
+ [img, credit, license, sourceUrl, r.id]
+ );
+ hits++;
+ console.log(`[region-images] HIT ${r.slug.padEnd(20)} → ${usedTitle}`);
+ await sleep(400);
+ }
+
+ console.log(`[region-images] DONE hits=${hits} miss=${miss} skipped=${skipped}`);
+ await pool.end();
+})();
diff --git a/scripts/migrate_002_images.sql b/scripts/migrate_002_images.sql
new file mode 100644
index 0000000..19b47bf
--- /dev/null
+++ b/scripts/migrate_002_images.sql
@@ -0,0 +1,12 @@
+-- Image attribution (Steve's no-stock-images rule: every image needs a verifiable source).
+-- We store the image we display + the human-readable credit + the page URL we'd link to.
+
+ALTER TABLE regions ADD COLUMN IF NOT EXISTS image_url TEXT;
+ALTER TABLE regions ADD COLUMN IF NOT EXISTS image_credit TEXT;
+ALTER TABLE regions ADD COLUMN IF NOT EXISTS image_license TEXT;
+ALTER TABLE regions ADD COLUMN IF NOT EXISTS image_source_url TEXT;
+ALTER TABLE regions ADD COLUMN IF NOT EXISTS image_fetched_at TIMESTAMPTZ;
+
+ALTER TABLE places ADD COLUMN IF NOT EXISTS image_credit TEXT;
+ALTER TABLE places ADD COLUMN IF NOT EXISTS image_license TEXT;
+ALTER TABLE places ADD COLUMN IF NOT EXISTS image_source_url TEXT;
diff --git a/server.js b/server.js
index 74e6dd3..d01cf37 100644
--- a/server.js
+++ b/server.js
@@ -102,8 +102,14 @@ app.get('/api/places', async (req, res) => {
const sql = `
SELECT p.id, p.slug, p.name, p.category, p.vertical, p.description, p.address,
p.phone, p.email, p.website, p.price_range, p.rating, p.image_url, p.tags,
- p.lat, p.lng, p.verified, p.source, p.source_url, p.created_at,
- r.slug AS region_slug, r.name AS region_name, r.province
+ p.lat, p.lng, p.verified, p.source, p.source_url, p.created_at, p.cedula_juridica,
+ p.image_credit, p.image_license, p.image_source_url,
+ r.slug AS region_slug, r.name AS region_name, r.province,
+ r.image_url AS region_image_url, r.image_credit AS region_image_credit,
+ r.image_source_url AS region_image_source_url, r.lat AS region_lat, r.lng AS region_lng,
+ COALESCE(p.image_url, r.image_url) AS effective_image_url,
+ COALESCE(p.image_credit, r.image_credit) AS effective_image_credit,
+ COALESCE(p.image_source_url, r.image_source_url) AS effective_image_source_url
FROM places p
LEFT JOIN regions r ON p.region_id = r.id
WHERE ${where.join(' AND ')}
@@ -121,22 +127,68 @@ app.get('/api/places', async (req, res) => {
`;
const { rows: [{ total }] } = await pool.query(countSql, countArgs);
+ // RFC 8288 Link header — canonical + prev/next/alternate (VCL pattern)
+ const baseUrl = (process.env.PUBLIC_URL || `https://${SITE_DOMAIN}`).replace(/\/+$/, '');
+ const qs = new URLSearchParams();
+ for (const k of ['q','category','vertical','region','sort']) if (req.query[k]) qs.set(k, req.query[k]);
+ const buildUrl = (path, n) => {
+ const params = new URLSearchParams(qs);
+ if (n != null) params.set('offset', String(n));
+ params.set('limit', String(limit));
+ return `${baseUrl}${path}${params.toString() ? '?' + params.toString() : ''}`;
+ };
+ const linkParts = [`<${buildUrl('/api/places', offset)}>; rel="canonical"`];
+ if (offset > 0) linkParts.push(`<${buildUrl('/api/places', Math.max(0, offset - limit))}>; rel="prev"`);
+ if (offset + limit < total) linkParts.push(`<${buildUrl('/api/places', offset + limit)}>; rel="next"`);
+ res.set('Link', linkParts.join(', '));
+
res.json({ total, limit, offset, places: rows });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
+// JSON mirror of the homepage / region listing — surfaced via rel="alternate"
+// HTML link tag + HTTP Link header so apps + agents can fetch the same paginated
+// results without HTML scraping (VCL pattern, RFC 8288).
+app.get('/api/find', async (req, res) => {
+ // /api/find is just /api/places under a more discoverable name.
+ req.url = '/api/places' + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '');
+ return app._router.handle(req, res, () => {});
+});
+
app.get('/api/places/:slug', async (req, res) => {
try {
const { rows } = await pool.query(`
- SELECT p.*, r.slug AS region_slug, r.name AS region_name, r.province
+ SELECT p.*,
+ r.slug AS region_slug, r.name AS region_name, r.province,
+ r.image_url AS region_image_url, r.image_credit AS region_image_credit,
+ r.image_source_url AS region_image_source_url,
+ r.lat AS region_lat, r.lng AS region_lng,
+ COALESCE(p.image_url, r.image_url) AS effective_image_url,
+ COALESCE(p.image_credit, r.image_credit) AS effective_image_credit,
+ COALESCE(p.image_source_url, r.image_source_url) AS effective_image_source_url
FROM places p
LEFT JOIN regions r ON p.region_id = r.id
WHERE p.slug = $1
`, [req.params.slug]);
if (!rows.length) return res.status(404).json({ error: 'not_found' });
- res.json(rows[0]);
+
+ const place = rows[0];
+ // Sibling listings — "More in {region}" cross-linking (VCL pattern)
+ if (place.region_id) {
+ const { rows: siblings } = await pool.query(`
+ SELECT slug, name, vertical, image_url
+ FROM places
+ WHERE region_id = $1 AND id != $2 AND status = 'active'
+ ORDER BY id DESC LIMIT 8
+ `, [place.region_id, place.id]);
+ place.siblings_in_region = siblings;
+ } else {
+ place.siblings_in_region = [];
+ }
+
+ res.json(place);
} catch (e) {
res.status(500).json({ error: e.message });
}
@@ -190,6 +242,93 @@ app.get('/api/ingest/runs', async (_req, res) => {
}
});
+// SEO: robots.txt — keep /api/* + /unsubscribe + /admin out of search
+app.get('/robots.txt', (_req, res) => {
+ const url = (process.env.PUBLIC_URL || `https://${SITE_DOMAIN}`).replace(/\/+$/, '');
+ res.type('text/plain').send(
+ `User-agent: *\nAllow: /\nDisallow: /api/\n\nSitemap: ${url}/sitemap.xml\n`
+ );
+});
+
+// SEO: sitemap.xml — VCL pattern.
+// Static + per-region + per-vertical + per-place. Capped at 50k URLs for now;
+// when we exceed that we paginate via sitemap-index.
+app.get('/sitemap.xml', async (_req, res, next) => {
+ try {
+ const baseUrl = (process.env.PUBLIC_URL || `https://${SITE_DOMAIN}`).replace(/\/+$/, '');
+ const escape = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
+ const today = new Date().toISOString().slice(0, 10);
+
+ const { rows: regions } = await pool.query(
+ `SELECT slug, name FROM regions WHERE region_type IN ('city','town','canton') ORDER BY name`
+ );
+ const { rows: places } = await pool.query(
+ `SELECT slug, updated_at, image_url FROM places WHERE status='active' ORDER BY id DESC LIMIT 48000`
+ );
+
+ const staticPages = [
+ { loc: '/', changefreq: 'daily', priority: '1.0' },
+ { loc: '/?category=tourism', changefreq: 'daily', priority: '0.8' },
+ { loc: '/?category=rentals', changefreq: 'daily', priority: '0.8' },
+ { loc: '/?category=service', changefreq: 'daily', priority: '0.8' },
+ ];
+ const regionPages = regions.map(r => ({ loc: `/r/${r.slug}`, changefreq: 'weekly', priority: '0.7' }));
+
+ let xml = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">\n';
+ for (const p of [...staticPages, ...regionPages]) {
+ xml += ` <url><loc>${baseUrl}${escape(p.loc)}</loc><lastmod>${today}</lastmod><changefreq>${p.changefreq}</changefreq><priority>${p.priority}</priority></url>\n`;
+ }
+ for (const pl of places) {
+ const lastmod = (pl.updated_at instanceof Date ? pl.updated_at : new Date(pl.updated_at || Date.now())).toISOString().slice(0, 10);
+ const loc = `${baseUrl}/p/${escape(pl.slug)}`;
+ let imgBlock = '';
+ if (pl.image_url) imgBlock = `<image:image><image:loc>${escape(pl.image_url)}</image:loc></image:image>`;
+ xml += ` <url><loc>${loc}</loc><lastmod>${lastmod}</lastmod><changefreq>weekly</changefreq><priority>0.5</priority>${imgBlock}</url>\n`;
+ }
+ xml += '</urlset>\n';
+
+ res.set('Content-Type', 'application/xml; charset=utf-8');
+ res.set('Cache-Control', 'public, max-age=3600');
+ res.send(xml);
+ } catch (err) { next(err); }
+});
+
+// JSON-LD LocalBusiness payload for a single place — embedded by /p/<slug> client.
+app.get('/api/places/:slug/jsonld', async (req, res) => {
+ try {
+ const { rows } = await pool.query(`
+ SELECT p.*, r.name AS region_name, r.province
+ FROM places p LEFT JOIN regions r ON p.region_id = r.id
+ WHERE p.slug = $1`, [req.params.slug]);
+ if (!rows.length) return res.status(404).json({});
+ const p = rows[0];
+ const baseUrl = (process.env.PUBLIC_URL || `https://${SITE_DOMAIN}`).replace(/\/+$/, '');
+ const ld = {
+ '@context': 'https://schema.org',
+ '@type': 'LocalBusiness',
+ '@id': `${baseUrl}/p/${p.slug}`,
+ name: p.name,
+ description: p.description || undefined,
+ url: p.website || `${baseUrl}/p/${p.slug}`,
+ image: p.image_url || undefined,
+ telephone: p.phone || undefined,
+ email: p.email || undefined,
+ identifier: p.cedula_juridica ? { '@type': 'PropertyValue', name: 'Cédula jurídica', value: p.cedula_juridica } : undefined,
+ address: (p.address || p.region_name) ? {
+ '@type': 'PostalAddress',
+ streetAddress: p.address || undefined,
+ addressLocality: p.region_name || undefined,
+ addressRegion: p.province || undefined,
+ addressCountry: 'CR',
+ } : undefined,
+ geo: (p.lat && p.lng) ? { '@type': 'GeoCoordinates', latitude: p.lat, longitude: p.lng } : undefined,
+ aggregateRating: p.rating ? { '@type': 'AggregateRating', ratingValue: p.rating, bestRating: 5 } : undefined,
+ };
+ Object.keys(ld).forEach(k => ld[k] === undefined && delete ld[k]);
+ res.json(ld);
+ } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
// Region landing page — same shell, deep-link a region pre-selected
app.get('/r/:slug', async (req, res) => {
try {
← 1c4ce34 yolo tick 1: place detail page (/p/:slug) + region landing r
·
back to Costa Rica
·
yolo tick 2: dedicated region landing page (/r/:slug) with h cfab471 →