[object Object]

← 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

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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&apos;');
+    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 →