import * as cheerio from 'cheerio'; // The public ARBA show-search listing. The page is a WordPress "Ninja Tables" // table whose ~2,000 rows are server-rendered into the HTML as plain positional // cells (the cells themselves carry no column classes — only the // does). So we fetch the page and read each row's cells by index. // // IMPORTANT: arba.net sits behind Cloudflare. A bare request (no/!browser // User-Agent) gets a "Sorry, you have been blocked" challenge page instead of // the listing. Sending a normal browser User-Agent + Accept header is enough to // pass, so those headers are mandatory here — do not remove them. (We // deliberately do NOT set Accept-Encoding: undici/global-fetch negotiates and // transparently decompresses gzip/br only when it owns that header.) const ARBA_URL = 'https://arba.net/show-search/'; const BROWSER_HEADERS = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.9', }; // US states + DC. Rows whose state isn't one of these are international (blank, // or a Canadian province like QC/ON) and are excluded per the design doc. const US_STATES = new Set([ 'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA', 'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD', 'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ', 'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC', 'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY', 'DC', ]); // The five show-type buckets the browser filter exposes. `FAIR SHOW` wins over // the open/youth × all-breed/specialty split when a show is a fair show. export type ShowType = | 'ALL BREED OPEN' | 'ALL BREED YOUTH' | 'SPECIALTY OPEN' | 'SPECIALTY YOUTH' | 'FAIR SHOW' | 'OTHER'; export interface ArbaShow { sanctionNumber: string; date: string | null; // ISO YYYY-MM-DD, or null if unparseable rawDate: string; // original MM/DD/YYYY as shown on ARBA state: string; district: string; city: string; clubName: string; showType: ShowType; showTypeFull: string; // e.g. "OPEN ALL BREED SHOW" specialBreed: string | null; address: string; mapsUrl: string | null; secretary: string; email: string; catalogUrl: string | null; } // "CLUB NAME, (OPEN|YOUTH) (ALL BREED|SPECIALTY) [BREED] [FAIR] SHOW[, BREED]". // Club names can themselves contain commas, so we anchor on the show-type phrase // rather than naively splitting on the first comma. const SHOW_INFO_RE = /^(.*?),\s*((OPEN|YOUTH)\s+(ALL BREED|SPECIALTY)(?:\s+BREED)?(\s+FAIR)?\s+SHOW)(?:,\s*(.*))?$/i; interface ParsedShowInfo { clubName: string; showType: ShowType; showTypeFull: string; specialBreed: string | null; } export function parseShowInformation(raw: string): ParsedShowInfo { const text = raw.trim(); const m = text.match(SHOW_INFO_RE); if (!m) { // Unexpected format — keep the whole string as the club name so the show // isn't dropped; it just won't match a specific show-type filter. return { clubName: text, showType: 'OTHER', showTypeFull: '', specialBreed: null }; } const clubName = m[1].trim(); const showTypeFull = m[2].replace(/\s+/g, ' ').trim(); const isYouth = /YOUTH/i.test(m[3]); const isSpecialty = /SPECIALTY/i.test(m[4]); const isFair = Boolean(m[5]); const breed = m[6] ? m[6].trim() : ''; let showType: ShowType; if (isFair) showType = 'FAIR SHOW'; else if (isSpecialty) showType = isYouth ? 'SPECIALTY YOUTH' : 'SPECIALTY OPEN'; else showType = isYouth ? 'ALL BREED YOUTH' : 'ALL BREED OPEN'; // A breed segment is only meaningful for specialty shows; all-breed/fair shows // shouldn't carry one even if a stray trailing comma produced text. const specialBreed = isSpecialty && breed ? breed : null; return { clubName, showType, showTypeFull, specialBreed }; } // MM/DD/YYYY or M/D/YYYY → ISO. Returns null for anything else so the caller can // still keep the original via rawDate. function toIsoDate(raw: string): string | null { const m = raw.trim().match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); if (!m) return null; const [, mm, dd, yyyy] = m; return `${yyyy}-${mm.padStart(2, '0')}-${dd.padStart(2, '0')}`; } function cellHref($cell: cheerio.Cheerio): string | null { const href = $cell.find('a').first().attr('href'); return href ? href.trim() : null; } /** * Fetch the ARBA listing and return the normalized, US-only, cavy-excluded set. * Throws on a Cloudflare block, a non-OK response, or a missing/empty table so * the caller can surface an error state and keep serving the previous cache. */ export async function fetchArbaShows(): Promise { let html: string; try { const res = await fetch(ARBA_URL, { headers: BROWSER_HEADERS }); if (!res.ok) { throw new Error(`ARBA responded with HTTP ${res.status}`); } html = await res.text(); } catch (err) { throw new Error(`Failed to reach ARBA: ${(err as Error).message}`); } if (/you have been blocked|cf-error-details|Attention Required/i.test(html)) { throw new Error('ARBA request was blocked by Cloudflare'); } const $ = cheerio.load(html); // The data table is the one whose header row defines the sanction-number // column. Anchoring on that class avoids picking up unrelated page tables. const table = $('th.ninja_clmn_nm_sanctionnumber').first().closest('table'); const rows = table.find('tbody tr'); if (rows.length === 0) { throw new Error('ARBA listing table was empty or not found (page layout may have changed)'); } const shows: ArbaShow[] = []; rows.each((_, tr) => { const cells = $(tr).find('td'); if (cells.length < 10) return; // malformed/placeholder row const sanctionNumber = cells.eq(0).text().trim(); const rawDate = cells.eq(1).text().trim(); const state = cells.eq(2).text().trim().toUpperCase(); const district = cells.eq(3).text().trim(); const city = cells.eq(4).text().trim(); const showInformation = cells.eq(5).text().trim(); const address = cells.eq(6).text().trim(); const mapsUrl = cellHref(cells.eq(7)); const secretary = cells.eq(8).text().trim(); const email = cells.eq(9).text().trim(); const catalogUrl = cells.length > 10 ? cellHref(cells.eq(10)) : null; // Cavy shows are excluded at normalization (CAVY appears as the breed // segment, e.g. "..., OPEN SPECIALTY BREED SHOW, CAVY"). if (/\bCAVY\b/i.test(showInformation)) return; // US-only. if (!US_STATES.has(state)) return; const { clubName, showType, showTypeFull, specialBreed } = parseShowInformation(showInformation); shows.push({ sanctionNumber, date: toIsoDate(rawDate), rawDate, state, district, city, clubName, showType, showTypeFull, specialBreed, address, mapsUrl, secretary, email, catalogUrl, }); }); // Default sort: date ascending, then state. shows.sort((a, b) => { const da = a.date ?? '9999-99-99'; const db = b.date ?? '9999-99-99'; if (da !== db) return da < db ? -1 : 1; return a.state.localeCompare(b.state); }); return shows; }