diff --git a/supabase/functions/events-crawler/adapters/nvcl.ts b/supabase/functions/events-crawler/adapters/nvcl.ts new file mode 100644 index 0000000..d848025 --- /dev/null +++ b/supabase/functions/events-crawler/adapters/nvcl.ts @@ -0,0 +1,384 @@ +// Adapter: North Vancouver City Library (Drupal, server-rendered HTML). +// +// The second HTML-scraping adapter, and the second library whose BiblioCommons tenant is a +// dead end. `nvcl` on BiblioCommons *is* North Vancouver City Library — the identity is +// right, unlike the `bpl`/Boston trap documented in adapters/bibliocommons.ts — but the +// gateway answers 403 "The Events feature is not available at North Vancouver City +// Library", exactly as Burnaby's does. Verified before writing this, so the next person +// doesn't re-test it. The Drupal listing is the only readable surface. +// +// It earns the scraping cost: NVCL runs settlement-adjacent programming (conversation +// circles, newcomer drop-ins, the Open Door community hub) alongside the usual storytimes, +// which is what `relevanceFilter` is for. +// +// THE LISTING CARRIES NO YEAR. Every row reads "Tuesday, August 4, 10:30 am to 11:00 am" — +// weekday, month, day, times, and nothing else (verified: 0 of 45 sampled rows across three +// pages carried a year). That matters more than it looks, because the paginated list runs +// chronologically straight past the year boundary: page 37 of 38 lists April-June, i.e. the +// *following* year. Guessing "current year" would file those ~8 months in the past. +// +// So the year is *derived and then checked*, not assumed: for each candidate year the +// weekday of the resulting date is compared against the weekday the page printed, and the +// candidate that matches wins. A row whose weekday matches neither candidate is skipped +// rather than guessed at — a wrong date is worse than a missing event. +// +// NO LOCATION FIELD. The slot exists in the markup (a second

) +// but is empty on every row sampled, so the source supplies `defaultLocation`. Titles +// routinely name the venue anyway ("Outdoor storytime at Semisch Park", "Book Bike at +// MONOVA"), so the specific place is not lost, just not machine-readable. +// +// NO IMAGES either — the listing renders an icon font per event, not a photo, so covers +// always fall through to the Pexels/Unsplash tiers. +// +// Pagination is `?page=N`, 0-based and chronological, so the walk can stop once a page runs +// past the window. Every page additionally repeats the same five "featured" rows before its +// paginated section, which is why dedupe-by-link is load-bearing rather than defensive: +// without it a 20-page walk would return the same five events 20 times. + +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 Drupal listing. */ +const BLOCK_MARKER = '