diff --git a/next.config.ts b/next.config.ts index 0885339..cef3fb6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -44,6 +44,10 @@ const nextConfig: NextConfig = { { protocol: "https", hostname: "www.westvanlibrary.ca" }, // BiblioCommons serves each library's event covers off its own tenant subdomain. { protocol: "https", hostname: "vpl.bibliocommons.com" }, + // Capilano serves event images from /media/ on its own host. (NVCL needs no entry — + // its listing renders an icon font rather than photos, so covers always fall through + // to the Pexels/Unsplash tiers.) + { protocol: "https", hostname: "www.capilanou.ca" }, // SFU's LiveWhale calendar serves thumbnails off the calendar host itself. { protocol: "https", hostname: "events.sfu.ca" }, // Communico (NVDPL) hosts event images in a public S3 bucket. Pinned to the exact diff --git a/supabase/functions/events-crawler/adapters/capilano.ts b/supabase/functions/events-crawler/adapters/capilano.ts new file mode 100644 index 0000000..9acaa7d --- /dev/null +++ b/supabase/functions/events-crawler/adapters/capilano.ts @@ -0,0 +1,320 @@ +// Adapter: Capilano University (Terminalfour CMS, server-rendered HTML). +// +// Deferred from the 2026-07-29 round on the belief its listing carried no dates and would +// need a fetch per event. Re-checked 2026-08-02: not so. Each `li.event-item` carries the +// month, day, optional end date, optional times, venue, link and image inline, so there is +// no N+1. +// +// ONE PAGE ONLY, AND THAT IS A ROBOTS DECISION, NOT AN OVERSIGHT. +// capilanou.ca/robots.txt has a `User-agent: *` block disallowing `/*?search=*` and +// `/*&search=*`, and every calendar-navigation URL the page offers is of the form +// `?day=14&month=08&year=2026&search=day`. So the paginated/month views are off-limits to +// us and this adapter reads only the unparameterized listing. The practical ceiling is +// about ten upcoming items — do not "fix" a low count by adding query parameters. +// +// Expect a very low yield, and that is correct rather than broken. The listing is mostly +// the academic administrative calendar — fee deadlines, grade-submission dates, campus +// closures — which `relevanceFilter` is right to drop. Measured 2026-08-02: 1 of 10 items +// passed, "Fall 2026 New International Student Orientation", caught by relevance.ts's +// `international student` term. One genuinely on-mission event is the realistic yield here. +// +// MOST ITEMS HAVE NO TIME. 8 of those 10 are all-day administrative entries with no +// `datelisting` span at all — including the one relevant event. `events.event_datetime` is +// NOT NULL, so those are stored at ALL_DAY_HOUR local. That asserts an hour the source does +// not state, which is a real if small cost; the alternative is dropping every all-day item, +// which would have dropped the only event worth having. +// +// NO WEEKDAY, so the year cannot be resolved the way adapters/nvcl.ts does it. The listing +// is chronological instead, so the year is carried forward monotonically: start on the +// org's current year and roll to the next as soon as a month/day goes backwards relative to +// the previous row. That is what keeps a December-to-January listing from filing January in +// the past. + +import { FETCH_TIMEOUT_MS, MAX_PER_ORG, MAX_TITLE_CHARS, ORG_TIMEZONE, USER_AGENT } from '../lib/constants.ts'; +import { zonedWallClockToUtc } from '../lib/dates.ts'; +import { genreForEvent } from '../lib/genre.ts'; +import { resolveCover } from '../lib/images.ts'; +import { isSettlementRelevant } from '../lib/relevance.ts'; +import { clean, isOnlineVenueName } from '../lib/text.ts'; +import type { AdapterContext, EventRow, Source } from '../lib/types.ts'; + +/** Opens each event row in the listing. */ +const BLOCK_MARKER = '
\s*]*>\s*([\s\S]*?)<\/a>/i;
+/** Start time, and optionally an end time, e.g. "7:30 PM" - "9:30 PM". Absent on all-day items. */
+const TIMES_RE =
+ /\s*([^<]*?)\s*<\/span>(?:\s*-\s*\s*([^<]*?)\s*<\/span>)?/i;
+/** Venue text sits in the anchor after the location icon, prefixed "Venues>". */
+const VENUE_RE = /fa-location-arrow[\s\S]{0,160}?]*>([\s\S]*?)<\/a>/i;
+const VENUE_PREFIX_RE = /^Venues\s*>\s*/i;
+const IMAGE_RE = /]*class="event-image"/i;
+
+const TIME_RE = /^(\d{1,2}):(\d{2})\s*([ap]\.?m\.?)$/i;
+
+const MONTHS: Record