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;
}
|