202 lines
7.2 KiB
TypeScript
202 lines
7.2 KiB
TypeScript
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
|
||
// <td> cells (the cells themselves carry no column classes — only the <thead>
|
||
// 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<any>): 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<ArbaShow[]> {
|
||
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;
|
||
}
|