MyKindlebox/mykindlebox/server/lib/arba.ts

202 lines
7.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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