[object Object]

← back to Ventura Claw Leads

rel=alternate JSON + HTTP Link header on /find paginated routes

fbd4be4342165add5329d5469387700742a40192 · 2026-05-07 17:50:23 -0700 · Steve

New /api/find route mirrors /find query+filter+pagination, returns JSON.
HTML head adds <link rel="alternate" type="application/json"> when
alternateJsonUrl is in render context. /find route now sets HTTP Link
header with rel=canonical, rel=prev, rel=next, rel=alternate (RFC 8288).

Smoke-tested locally:
- GET /api/find?vertical=beauty&page=2 returns {query,pagination,results}
- HEAD /find?vertical=beauty&page=2 carries 4-entry Link: header
- HTML head emits 3 <link rel> tags including the new alternate

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit fbd4be4342165add5329d5469387700742a40192
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu May 7 17:50:23 2026 -0700

    rel=alternate JSON + HTTP Link header on /find paginated routes
    
    New /api/find route mirrors /find query+filter+pagination, returns JSON.
    HTML head adds <link rel="alternate" type="application/json"> when
    alternateJsonUrl is in render context. /find route now sets HTTP Link
    header with rel=canonical, rel=prev, rel=next, rel=alternate (RFC 8288).
    
    Smoke-tested locally:
    - GET /api/find?vertical=beauty&page=2 returns {query,pagination,results}
    - HEAD /find?vertical=beauty&page=2 carries 4-entry Link: header
    - HTML head emits 3 <link rel> tags including the new alternate
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 routes/public.js        | 93 ++++++++++++++++++++++++++++++++++++++++++++++++-
 views/partials/head.ejs |  3 ++
 2 files changed, 95 insertions(+), 1 deletion(-)

diff --git a/routes/public.js b/routes/public.js
index ebb02f2..50015fe 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -192,6 +192,30 @@ router.get('/find', async (req, res, next) => {
     // indexes /find?vertical=beauty&page=2 as its own page, not as a dup of
     // /find. Use the same buildPageUrl helper to ensure consistent ordering.
     const canonicalPath = buildPageUrl(page).replace(baseUrl, '');
+    const canonicalUrl = baseUrl + canonicalPath;
+
+    // rel=alternate JSON representation of this exact query — apps + agents +
+    // structured-data crawlers can fetch the same paginated results without
+    // HTML scraping. Mirrors the HTML query string into /api/find.
+    const buildApiUrl = (n) => {
+      const qsParts = [];
+      if (q) qsParts.push(`q=${encodeURIComponent(q)}`);
+      if (v) qsParts.push(`vertical=${encodeURIComponent(v)}`);
+      if (city) qsParts.push(`city=${encodeURIComponent(city)}`);
+      if (sortKey && orderClauses[sortKey] && sortKey !== 'featured') qsParts.push(`sort=${encodeURIComponent(sortKey)}`);
+      if (n > 1) qsParts.push(`page=${n}`);
+      return baseUrl + '/api/find' + (qsParts.length ? '?' + qsParts.join('&') : '');
+    };
+    const alternateJsonUrl = buildApiUrl(page);
+
+    // HTTP Link header — equivalent to <link rel> tags in head, but readable
+    // by crawlers that don't parse HTML (and survives any proxy that strips
+    // head tags). Order matches RFC 8288.
+    const linkParts = [`<${canonicalUrl}>; rel="canonical"`];
+    if (prevUrl) linkParts.push(`<${prevUrl}>; rel="prev"`);
+    if (nextUrl) linkParts.push(`<${nextUrl}>; rel="next"`);
+    linkParts.push(`<${alternateJsonUrl}>; rel="alternate"; type="application/json"`);
+    res.set('Link', linkParts.join(', '));
 
     res.render('public/find', {
       title: customTitle,
@@ -200,7 +224,74 @@ router.get('/find', async (req, res, next) => {
       sort: orderClauses[sortKey] ? sortKey : 'featured',
       customH1, verticalLabel,
       page, totalPages, pageSize: PAGE_SIZE,
-      prevUrl, nextUrl, canonicalPath
+      prevUrl, nextUrl, canonicalPath,
+      alternateJsonUrl
+    });
+  } catch (err) { next(err); }
+});
+
+// JSON-API mirror of /find — same query/filter/pagination, JSON output.
+// Surfaced via rel="alternate" link tag + HTTP Link header on the HTML /find
+// route so apps/agents/structured-data crawlers can fetch the same data
+// without scraping HTML. Matches /find's whitelist + page caps exactly.
+router.get('/api/find', async (req, res, next) => {
+  try {
+    const q = (req.query.q || '').trim().slice(0, 120);
+    const v = (req.query.vertical || '').trim();
+    const city = (req.query.city || '').trim().slice(0, 60);
+
+    const where = [`status = 'active'`];
+    const params = [];
+    if (q) {
+      params.push(`%${q.toLowerCase()}%`);
+      where.push(`(LOWER(business_name) LIKE $${params.length} OR LOWER(headline) LIKE $${params.length} OR LOWER(neighborhood) LIKE $${params.length})`);
+    }
+    if (v && BY_KEY[v]) {
+      params.push(v);
+      where.push(`vertical = $${params.length}`);
+    }
+    if (city) {
+      params.push(city);
+      where.push(`city ILIKE $${params.length}`);
+    }
+
+    const sortKey = String(req.query.sort || 'featured').trim();
+    const orderClauses = {
+      featured:   `tier = 'premier' DESC, tier = 'standard' DESC, claim_status IN ('self','claimed') DESC, business_name ASC`,
+      newest:     `created_at DESC`,
+      updated:    `updated_at DESC`,
+      name_asc:   `business_name ASC`,
+      name_desc:  `business_name DESC`,
+      vertical:   `vertical ASC, business_name ASC`
+    };
+    const orderBy = orderClauses[sortKey] || orderClauses.featured;
+
+    const PAGE_SIZE = 200;
+    const MAX_PAGE = 50;
+    const requestedPage = parseInt(req.query.page || '1', 10);
+    const page = Math.max(1, Math.min(MAX_PAGE, isNaN(requestedPage) ? 1 : requestedPage));
+    const offset = (page - 1) * PAGE_SIZE;
+
+    const businesses = await db.many(`
+      SELECT id, slug, business_name, vertical, headline, neighborhood, city, state, tier, claim_status, verified, photo_path
+        FROM businesses
+       WHERE ${where.join(' AND ')}
+       ORDER BY ${orderBy}, id ASC
+       LIMIT ${PAGE_SIZE} OFFSET $${params.length + 1}
+    `, [...params, offset]);
+
+    const totalForFilter = await db.one(
+      `SELECT COUNT(*)::int AS n FROM businesses WHERE ${where.join(' AND ')}`,
+      params
+    );
+    const totalCount = totalForFilter?.n ?? businesses.length;
+    const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
+
+    res.set('Cache-Control', 'public, max-age=300');
+    res.json({
+      query: { q, vertical: v || null, city: city || null, sort: orderClauses[sortKey] ? sortKey : 'featured' },
+      pagination: { page, totalPages, pageSize: PAGE_SIZE, totalCount },
+      results: businesses
     });
   } catch (err) { next(err); }
 });
diff --git a/views/partials/head.ejs b/views/partials/head.ejs
index 5b52894..8a0d95d 100644
--- a/views/partials/head.ejs
+++ b/views/partials/head.ejs
@@ -26,6 +26,9 @@
   <% if (typeof nextUrl !== 'undefined' && nextUrl) { %>
     <link rel="next" href="<%= nextUrl %>">
   <% } %>
+  <% if (typeof alternateJsonUrl !== 'undefined' && alternateJsonUrl) { %>
+    <link rel="alternate" type="application/json" href="<%= alternateJsonUrl %>" title="JSON results">
+  <% } %>
   <meta property="og:type" content="website">
   <meta property="og:url" content="<%= _canonicalUrl %>">
   <meta property="og:site_name" content="Ventura Claw">

← a182931 Footer centered: 5-col equal grid + text-align center on eve  ·  back to Ventura Claw Leads  ·  Fix legal-page ZIP in CAN-SPAM footer: 91335→91403 a6b54c3 →