From cd7f34031b7d5a4f25f636d38b044dd2d34795aa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 18:54:19 -0800 Subject: [PATCH] Crossing titles and descriptions match search queries Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JB1Z5Hg5wC7KmJ6iJtBSsy --- CHANGELOG.md | 8 +++++ scripts/check-faq-scope.mjs | 2 +- scripts/prerender.mjs | 4 +-- scripts/test-crossing-snapshot.mjs | 7 +++-- src/lib/crossingNames.js | 9 ------ src/lib/crossingSeo.js | 47 ++++++++++++++++++++++++++++++ src/pages/CrossingDetail.jsx | 11 ++++--- 7 files changed, 67 insertions(+), 21 deletions(-) create mode 100644 src/lib/crossingSeo.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e504c8082..f97a85afd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # CHANGELOG +## 2026-11-10 - Crossing titles and descriptions match how people search (draft) + +- Crossing page titles now follow the query shape from Search Console: " border wait times today" (EN) and "Tiempos de espera en hoy" (ES). Bridges and ports of entry use the name people type: "Anzalduas International Bridge (Hidalgo/Pharr)", "Santa Teresa Port of Entry", "Andrade (Los Algodones)", "Eagle Pass International Bridge II" and others. +- Descriptions are built per crossing instead of one shared template: the search name, the hour with the shortest typical wait over the last 30 days, and cameras where they exist. One function feeds the prerendered page and the client. +- Why: Google Search Console, last 28 days: 51.1K impressions, 1.2% click rate, average position 9.6. "santa teresa port of entry" had 342 impressions and no clicks under the old "Santa Teresa · San Jerónimo" title. +- New `src/lib/crossingSeo.js`, loaded only by crossing pages and prerender, so the entry bundle stays at 61.89 KB gzip (budget 62.25). +- Checks: full `npm test` passes after a prerendered build. Measure: click rate per crossing page in Search Console 2 to 4 weeks after deploy. + ## 2026-11-10 - Spanish and accessibility polish (Phase 4, draft) - Spanish: the mobile menu button is announced as "Abrir menú" / "Cerrar menú" instead of English. The blog index title, "Todos" tab, filter label and empty state follow the site language; all posts stay listed by default so Spanish readers can still open English guides. FAQ wording "job programado" is now "tarea programada". diff --git a/scripts/check-faq-scope.mjs b/scripts/check-faq-scope.mjs index a280701af..5f00c6f2b 100644 --- a/scripts/check-faq-scope.mjs +++ b/scripts/check-faq-scope.mjs @@ -106,7 +106,7 @@ export function checkBuiltFaqScope(distDir = path.join(root, 'dist')) { { route: '/metodologia/', title: 'Metodología |', lang: 'es', types: ['BreadcrumbList'], hreflangs: twins('/methodology/', '/metodologia/') }, { route: '/privacy/', title: 'Privacy and advertising |', hreflangs: twins('/privacy/', '/privacidad/') }, { route: '/privacidad/', title: 'Privacidad y publicidad |', lang: 'es', hreflangs: twins('/privacy/', '/privacidad/') }, - { route: '/crossing/san-ysidro/', title: 'San Ysidro: CBP wait times', types: ['Place', 'FAQPage', 'BreadcrumbList'] }, + { route: '/crossing/san-ysidro/', title: 'San Ysidro border wait times today', types: ['Place', 'FAQPage', 'BreadcrumbList'] }, { route: '/blog/', title: 'Border Pulse Blog |', types: ['Blog', 'BreadcrumbList'] }, { route: enArticle, title: 'San Ysidro vs Otay Mesa:', types: ['BlogPosting', 'BreadcrumbList'], hreflangs: twins(enArticle, esArticle) }, { route: esArticle, title: 'San Ysidro vs Otay Mesa:', lang: 'es', types: ['BlogPosting', 'BreadcrumbList'], hreflangs: twins(enArticle, esArticle) }, diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index b48af39aa..a648da2ee 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -11,7 +11,7 @@ import { COMPARE_PAIRS } from '../src/lib/comparePairs.js'; import { pageHreflangs, blogHreflangs } from '../src/lib/hreflang.js'; import { snapshotHtml, snapshotDocument, injectSnapshot } from './lib/crossing-snapshot-html.mjs'; import { SPANISH_HOME } from '../src/lib/homeMeta.js'; -import { crossingSearchTitle } from '../src/lib/crossingNames.js'; +import { crossingSearchDescription, crossingSearchTitle } from '../src/lib/crossingSeo.js'; import { cameraSourceFor } from '../src/lib/cameras.js'; import { googlePrivacyNotice } from '../src/lib/googlePrivacyNotice.js'; import { privacyParagraphs } from '../src/lib/privacyParagraphs.js'; @@ -56,7 +56,7 @@ function renderCrossingHead(crossing, slug, aggregate) { // Title pattern matches what wins these SERPs: keyword-first, "today"/live // freshness signal, current year. Brand goes last where truncation is cheap. const title = crossingSearchTitle(crossing, 'en', cameraSourceFor(crossing.port_number)?.kind); - const desc = `Reported ${crossing.name} border wait time from U.S. Customs and Border Protection, plus 30-day hour-by-hour patterns, port hours, and historical standard-passenger wait patterns.`; + const desc = crossingSearchDescription(crossing, 'en', { bestHour: aggregate?.overall_best_hour ?? null, hasCameras: Boolean(cameraSourceFor(crossing.port_number)?.kind) }); const canonical = `${BASE}/crossing/${slug}/`; const ogImage = `${BASE}/og/${slug}.png`; diff --git a/scripts/test-crossing-snapshot.mjs b/scripts/test-crossing-snapshot.mjs index 1fa5cd16d..84dbaebd0 100644 --- a/scripts/test-crossing-snapshot.mjs +++ b/scripts/test-crossing-snapshot.mjs @@ -6,7 +6,8 @@ import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { snapshotHtml, snapshotDocument, injectSnapshot } from './lib/crossing-snapshot-html.mjs'; -import { crossingDisplayName, crossingSearchTerms, crossingSearchTitle } from '../src/lib/crossingNames.js'; +import { crossingDisplayName, crossingSearchTerms } from '../src/lib/crossingNames.js'; +import { crossingSearchTitle } from '../src/lib/crossingSeo.js'; const crossing = { port_number: '240202', name: 'El Paso - Paso Del Norte (PDN)', hours: '24 hrs/day', port_status: 'Open', lanes: { passenger_standard: { status: 'No Delay', lanes_open: 1, delay_minutes: 0, update_time: 'At 6:00 pm MDT' } } }; @@ -79,8 +80,8 @@ test('local naming helps search without changing official identity or canonical assert.match(crossingDisplayName(crossing), /Santa Fe/); assert.ok(crossingSearchTerms({ port_number: '240201', name: 'BOTA' }).includes('puente libre')); assert.ok(crossingSearchTerms({ port_number: '240801', name: 'Santa Teresa' }).includes('san jeronimo')); - assert.match(crossingSearchTitle(crossing, 'en', true), /CBP wait times & cameras/); - assert.match(crossingSearchTitle(crossing, 'es', true), /esperas CBP y cámaras/); + assert.match(crossingSearchTitle(crossing, 'en', true), /wait times today & cameras/); + assert.match(crossingSearchTitle(crossing, 'es', true), /hoy y cámaras/); }); test('built homepage and every crossing page contain their own useful visible snapshot', () => { if (!fs.existsSync('dist/index.html')) return; diff --git a/src/lib/crossingNames.js b/src/lib/crossingNames.js index 063992070..9f79cd920 100644 --- a/src/lib/crossingNames.js +++ b/src/lib/crossingNames.js @@ -16,12 +16,3 @@ export function crossingSearchTerms(crossing) { LOCAL_NAMES[String(crossing?.port_number)]?.terms].filter(Boolean).join(' ') .normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); } - -export function crossingSearchTitle(crossing, language = 'en', hasCameras = false) { - const name = crossingDisplayName(crossing, language); - const enCamera = hasCameras === 'approach' ? ' & road cameras' : hasCameras === 'external' ? ' & camera sources' : hasCameras ? ' & cameras' : ''; - const esCamera = hasCameras === 'approach' ? ' y cámaras viales' : hasCameras === 'external' ? ' y fuentes de cámaras' : hasCameras ? ' y cámaras' : ''; - return language === 'es' - ? `${name}: esperas CBP${esCamera} | Border Pulse` - : `${name}: CBP wait times${enCamera} | Border Pulse`; -} diff --git a/src/lib/crossingSeo.js b/src/lib/crossingSeo.js new file mode 100644 index 000000000..53dde666b --- /dev/null +++ b/src/lib/crossingSeo.js @@ -0,0 +1,47 @@ +import { crossingDisplayName } from './crossingNames.js'; + +// How people actually search for a crossing (Search Console queries), used only +// in titles and descriptions. Unlisted crossings fall back to the display name. +const SEARCH_NAMES = { + '250201': 'Andrade (Los Algodones)', + '230503': 'Anzalduas International Bridge (Hidalgo/Pharr)', + '230902': 'Donna International Bridge (Progreso)', + '230901': 'Progreso International Bridge', + '535503': 'Los Indios International Bridge (Brownsville)', + '535502': 'Veterans International Bridge (Brownsville)', + '535504': 'Gateway International Bridge (Brownsville)', + '535501': 'B&M Bridge (Brownsville)', + '230301': 'Eagle Pass International Bridge I', + '230302': 'Eagle Pass International Bridge II', + '240801': 'Santa Teresa Port of Entry', + '240401': 'Tornillo Port of Entry (Marcelino Serna)', + '240301': 'Presidio Port of Entry', + '260401': 'Nogales DeConcini', + '260402': 'Nogales Mariposa', + '260403': 'Nogales Morley Gate', +}; +export function crossingSearchName(crossing, language = 'en') { + return SEARCH_NAMES[String(crossing?.port_number)] || crossingDisplayName(crossing, language); +} +const hasPlaceWord = (name) => /bridge|port of entry|puente|garita/i.test(name); + +// Title mirrors the query shape: " border wait times today". +export function crossingSearchTitle(crossing, language = 'en', hasCameras = false) { + const name = crossingSearchName(crossing, language); + const enCamera = hasCameras === 'approach' ? ' & road cameras' : hasCameras === 'external' ? ' & camera sources' : hasCameras ? ' & cameras' : ''; + const esCamera = hasCameras === 'approach' ? ' y cámaras viales' : hasCameras === 'external' ? ' y fuentes de cámaras' : hasCameras ? ' y cámaras' : ''; + return language === 'es' + ? `Tiempos de espera en ${name} hoy${esCamera} | Border Pulse` + : `${name}${hasPlaceWord(name) ? '' : ' border'} wait times today${enCamera} | Border Pulse`; +} + +// One description for the prerendered page and the client, built from the +// crossing's own data: its search name, its historical quietest hour, cameras. +export function crossingSearchDescription(crossing, language = 'en', { bestHour = null, hasCameras = false } = {}) { + const name = crossingSearchName(crossing, language); + const hour = Number.isInteger(bestHour) ? `${bestHour % 12 || 12} ${bestHour >= 12 ? 'PM' : 'AM'}` : null; + if (language === 'es') { + return `Tiempo de espera actual en ${name} según CBP, por carril.${hour ? ` En los últimos 30 días, la espera típica más corta fue cerca de las ${hour}.` : ''}${hasCameras ? ' Incluye cámaras de la zona.' : ''} Horarios del puerto y tendencias históricas por hora.`; + } + return `Current ${name} wait time from CBP, by lane.${hour ? ` Over the last 30 days, the shortest typical wait came around ${hour}.` : ''}${hasCameras ? ' Includes nearby road cameras.' : ''} Port hours and historical trends by hour.`; +} diff --git a/src/pages/CrossingDetail.jsx b/src/pages/CrossingDetail.jsx index bd2111879..c05b25346 100644 --- a/src/pages/CrossingDetail.jsx +++ b/src/pages/CrossingDetail.jsx @@ -11,7 +11,8 @@ import EmbedSnippetModal from '@/components/dashboard/EmbedSnippetModal'; import CrossingShare from '@/components/dashboard/CrossingShare'; import CrossingCameras from '@/components/dashboard/CrossingCameras'; import { cameraSourceFor } from '@/lib/cameras'; -import { crossingDisplayName, crossingSearchTitle } from '@/lib/crossingNames'; +import { crossingDisplayName } from '@/lib/crossingNames'; +import { crossingSearchDescription, crossingSearchTitle } from '@/lib/crossingSeo'; import SantaTeresaActions from '@/components/dashboard/SantaTeresaActions'; import SanYsidroDecision from '@/components/dashboard/SanYsidroDecision'; import { subscribeToBorderData } from '@/lib/liveDataSubscription'; @@ -392,11 +393,9 @@ export default function CrossingDetail() { useEffect(() => { if (!crossing) return; - const hasHistoricalPattern = Array.isArray(aggregate?.by_hour) && aggregate.by_hour.length > 0; - const title = crossingSearchTitle(crossing, language, cameraSourceFor(crossing.port_number)?.kind); - const desc = language === 'en' - ? `Reported ${crossing.name} border wait times, refreshed regularly. Official CBP data${hasHistoricalPattern ? ', historical trends, and best crossing times' : ', lane status, and port hours'}.` - : `Tiempos de espera en ${crossing.name}, actualizados con regularidad. Datos oficiales de CBP${hasHistoricalPattern ? ', tendencias históricas y mejores horarios para cruzar' : ', estado de carriles y horarios del puerto'}.`; + const cameraKind = cameraSourceFor(crossing.port_number)?.kind; + const title = crossingSearchTitle(crossing, language, cameraKind); + const desc = crossingSearchDescription(crossing, language, { bestHour: aggregate?.overall_best_hour ?? null, hasCameras: Boolean(cameraKind) }); const url = `https://borderpulse.com/crossing/${canonicalSlug}/`; updatePageMeta({ title, description: desc, ogTitle: title, ogDescription: desc, ogUrl: url, canonical: url }); return () => resetPageMeta();