From 9b3ea7f9a9fdcc3c64c66c3688534386c31f3925 Mon Sep 17 00:00:00 2001 From: ltanafranca1004 Date: Sun, 2 Aug 2026 16:13:34 -0700 Subject: [PATCH 1/4] feat(events-crawler): North Vancouver City Library adapter, staged disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of the orgs deferred from the 2026-07-29 round. The deferral assumed the listing carried no dates and would need a fetch per event; re-checking the live feed showed otherwise — date and time are both in the listing, so there is no N+1. NVCL is the second library whose BiblioCommons tenant is a dead end. Unlike the bpl/Boston trap, `nvcl` really is North Vancouver City Library — but the gateway answers 403 "The Events feature is not available", exactly as Burnaby does. Verified before writing a line, and recorded in the adapter header so nobody re-tests it. The Drupal listing is the only readable surface. The listing carries no year. Every row reads "Tuesday, August 4, 10:30 am to 11:00 am" — 0 of 45 sampled rows across three pages carried one. That matters because the paginated list runs chronologically past the year boundary: page 37 of 38 lists April-June, i.e. the following year, which "assume current year" would file eight months in the past. So the year is derived and then *checked*: each candidate year's weekday is compared against the weekday the page printed, and a row matching neither is skipped rather than guessed at. Two more things the feed does not have. There is no location — the venue slot exists in the markup but is empty on every row sampled — so the source supplies defaultLocation, and the adapter refuses to run without one rather than inserting a wrong place into a NOT NULL column. And there are no images, only an icon font, so covers always fall through to the Pexels/Unsplash tiers. Dedupe-by-link is load-bearing here rather than defensive: every page repeats the same five featured rows above its paginated section, so without it a 24-page walk returns those five 24 times. Safe as an identity — across 45 sampled rows no slug appeared with two different dates, so the slug identifies the occurrence and matches events_external_link_key. Carries forward both parse-failure guards from surrey.ts (page 0 with no blocks, page 0 where every title misses), since this is the same class of scraper. KNOWN: with relevanceFilter on, this source currently yields ZERO rows — see the PR for the evidence and the decision it needs. Landing disabled either way. Verified with the real harness: deno check clean; dryrun --source nvcl parses 25 well-formed rows with the filter off (correct dates, end times, dedupe, covers) and 0 with it on. tsc and lint clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../functions/events-crawler/adapters/nvcl.ts | 325 ++++++++++++++++++ .../functions/events-crawler/lib/sources.ts | 20 ++ .../functions/events-crawler/lib/types.ts | 3 +- 3 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 supabase/functions/events-crawler/adapters/nvcl.ts diff --git a/supabase/functions/events-crawler/adapters/nvcl.ts b/supabase/functions/events-crawler/adapters/nvcl.ts new file mode 100644 index 0000000..b6802e3 --- /dev/null +++ b/supabase/functions/events-crawler/adapters/nvcl.ts @@ -0,0 +1,325 @@ +// 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 = '