Skip to content

feat(events-crawler): Surrey Libraries HTML adapter - #86

Merged
ltanafranca1004 merged 2 commits into
mainfrom
feat/events-crawler-surrey
Aug 2, 2026
Merged

feat(events-crawler): Surrey Libraries HTML adapter#86
ltanafranca1004 merged 2 commits into
mainfrom
feat/events-crawler-surrey

Conversation

@ltanafranca1004

@ltanafranca1004 ltanafranca1004 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

What & why

Fourth and final Phase-2 PR. The only HTML-scraping adapter: Surrey publishes no RSS, no ICS and no JSON, and its BiblioCommons tenant has the Events feature disabled — but /events?page=N is fully server-rendered, so no headless browser is needed.

Stacked on #85 (which is stacked on #84). Base is feat/events-crawler-feeds; GitHub retargets automatically as the chain merges.

It earns the scraping cost. Surrey is by far the most settlement-relevant Phase-2 source — all 25 rows a run would produce are on-mission:

 1. Practice Speaking English            13. English Conversation Program (55+)
 3. Employment Services for Newcomers    18. Practice Speaking English: Advanced
 5. Settlement & Integration Services    20. Employment Services for Intl Students
 7. Résumé Clinic with S.U.C.C.E.S.S.    22. Canadian Job Search Workshop
 8. Settlement Services for Newcomers     9. Newcomer Teen Social Club

That also confirms the relevance filter's accent folding against live data: Résumé Clinic only matches because the title is NFD-folded before testing — the exact case you asked to be handled.

Decisions baked in

Datetimes come from <time datetime="…">, not the eventdate query param the plan specified. Both encode the same instant, but the attribute is a full ISO-8601 timestamp carrying its own offset (2026-07-31T10:00:00-07:00), so it needs no timezone reconstruction and is DST-correct by construction; eventdate is a bare local YYYY-MM-DD HH:MM:SS. Each row carries two <time> elements, giving start and end.

eventdate is still kept in external_link — it's what makes each occurrence of a recurring program distinct under events_external_link_key.

Page walking stops early. Pagination is 0-based and chronological, so the walk starts at today and stops on the first of: listing exhausted, a page past the window, MAX_PER_ORG collected, or the MAX_PAGES cap. The cap is 20 because 10 listings/page against a ~20% keep-rate would otherwise need ~125 requests to fill the cap. Hitting it warns rather than truncating silently, and costs little — the listing is chronological, so events beyond the cap are the furthest out and land on a later weekly run.

No next.config.ts change. The listing ships no image, so covers always fall through to the Pexels topic tier and then the Unsplash pool — both already allowlisted.

Changes

  • adapters/surrey.ts — new.
  • lib/types.tsSourceKind gains surrey-drupal.
  • lib/sources.ts — register surrey-libraries (enabled: false, relevanceFilter: true).

⚠️ Coordination

Nothing changes in production. enabled: false; no migration, cron job, Vault secret or DB object touched; the function was not deployed; no writes to shared prod.

Verification

Check Result
deno check (incl. dryrun.ts) clean
npx tsc --noEmit / eslint clean
dryrun --source surrey-libraries 20 pages → 28 relevant → 25 rows, all on-mission
Regression, all 10 sources 25 / 24 / 14 / 0 / 2 · westvan 5 · vpl 25 · sfu 2 · nvdpl 16 · surrey 25
migrations/backfills guard empty

Reviewer notes

  • Parsing splits on the Drupal block marker rather than trying to balance </div>s — each chunk then yields its fields by first-match, which is resilient to the nested views markup Drupal emits around the address field.
  • description is null: the listing carries no summary and the detail page would be a separate fetch per event. The detail page renders fine without one (the About section hides when empty).
  • Window filtering is tracked before the relevance filter, so the walk can still stop on dates even when a page happens to contain nothing relevant.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added support for Surrey Libraries events, including titles, dates, locations, categories, links, and cover images.
    • Surrey event listings are filtered for relevance and date range, with duplicate results removed and listings presented in a consistent order.
    • Added safeguards for incomplete, unavailable, or malformed event information.
    • Registered Surrey Libraries as a supported event source, currently disabled by default.

@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
web-app Ready Ready Preview Aug 1, 2026 11:46pm

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds a Surrey Libraries Drupal adapter that fetches and parses events, applies filtering and pagination limits, resolves event metadata, and registers the disabled source with the crawler.

Changes

Surrey event ingestion

Layer / File(s) Summary
Source contract and registration
supabase/functions/events-crawler/lib/types.ts, supabase/functions/events-crawler/lib/sources.ts
Adds the surrey-drupal source kind and registers the Surrey adapter in the public adapter registry.
Surrey source definition
supabase/functions/events-crawler/lib/sources.ts
Adds a disabled Surrey Libraries source with host and relevance-filter configuration.
HTML fetching and event parsing
supabase/functions/events-crawler/adapters/surrey.ts
Fetches Drupal pages with timeouts, parses event fields, and applies relevance and date-window filters.
Pagination and EventRow conversion
supabase/functions/events-crawler/adapters/surrey.ts
Traverses paginated results, applies stop conditions, deduplicates and sorts candidates, resolves metadata, and returns EventRow records.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • UnifyCN/web-app#79: Adds related Surrey crawler behavior involving genre classification and date-window filtering.
  • UnifyCN/web-app#84: Introduces related adapter contracts and SourceKind registration patterns.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the Surrey Libraries HTML adapter added by the pull request.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/events-crawler-surrey

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Fourth and final Phase-2 PR. The only HTML-scraping adapter: Surrey publishes no RSS, no
ICS and no JSON, and its BiblioCommons tenant has the Events feature disabled — but
/events?page=N is fully server-rendered, so no headless browser is needed.

It earns the scraping cost. Surrey is by far the most settlement-relevant Phase-2 source:
all 25 rows a run would produce are on-mission — Practice Speaking English (incl.
Advanced), Settlement Services for Newcomers, Settlement & Integration Services,
Employment Services for Newcomers, Employment Services for International Students,
Résumé Clinic with S.U.C.C.E.S.S., Canadian Job Search Workshop, English Conversation
Program (55+). That last set also confirms the relevance filter's accent folding against
live data: "Résumé Clinic" only matches because the title is NFD-folded before testing.

Datetimes come from the <time datetime="…"> attribute rather than the eventdate query
param the plan specified. Both encode the same instant, but the attribute is a full
ISO-8601 timestamp carrying its own offset ("2026-07-31T10:00:00-07:00"), so it needs no
timezone reconstruction and is DST-correct by construction, where eventdate is a bare
local "YYYY-MM-DD HH:MM:SS". eventdate is still kept in external_link, because it is what
makes each occurrence of a recurring program distinct under events_external_link_key.

Pagination is 0-based and chronological, so the walk starts at today and stops on the
first of: listing exhausted, a page past the window, MAX_PER_ORG candidates collected, or
the MAX_PAGES cap. The cap is set at 20 because 10 listings per page against a ~20%
relevance keep-rate would otherwise need ~125 requests to fill the cap. Hitting it warns
rather than truncating silently, and it costs little: the listing is chronological, so the
events beyond the cap are the furthest out and land on a later weekly run.

The listing ships no image, so covers always fall through to the Pexels topic tier and
then the deterministic Unsplash pool — both already allowlisted, so no next.config.ts
change is needed for this source.

Carries the same two fixes as the other adapters: relevance filtering runs on the full
title before truncation, and row mapping uses Promise.allSettled.

Everything stays enabled: false. No migration, cron, or DB object touched; nothing
deployed; no writes to shared prod.

Verification:
  deno check (incl. dryrun.ts)  → clean
  npx tsc --noEmit / eslint     → clean
  dryrun surrey-libraries       → 20 pages, 28 relevant, 25 rows, all on-mission
  regression, all 10 sources    → 25/24/14/0/2, 5, 25, 2, 16, 25
  migrations/backfills guard    → empty

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ltanafranca1004

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@ltanafranca1004
ltanafranca1004 changed the base branch from feat/events-crawler-feeds to main August 1, 2026 22:46
@ltanafranca1004

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 48 minutes.

@ltanafranca1004

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@supabase/functions/events-crawler/adapters/surrey.ts`:
- Around line 144-159: Update the Surrey page-walking logic around parsePage so
a zero-block result on page 0 logs a warning instead of being treated as a
legitimate exhausted listing. Preserve normal exhaustion handling for later
pages, while ensuring the warning identifies the parse or markup failure and the
walk does not silently return zero results as a valid empty calendar.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: df3a689e-cbd0-4350-b3d3-7bc5da39a871

📥 Commits

Reviewing files that changed from the base of the PR and between fa3eb22 and 06b31f4.

📒 Files selected for processing (3)
  • supabase/functions/events-crawler/adapters/surrey.ts
  • supabase/functions/events-crawler/lib/sources.ts
  • supabase/functions/events-crawler/lib/types.ts

Comment thread supabase/functions/events-crawler/adapters/surrey.ts
…t exhausted

parsePage derives `blocks` from splitting on BLOCK_MARKER, so `blocks === 0` collapses
two very different states: the listing genuinely ran out, or the marker stopped matching
— a Drupal theme renaming the wrapper class, a soft-404, or an interstitial served with
HTTP 200. The second is the expected failure mode for a scraping adapter.

Treating both as "exhausted" made the failure silent: the walk breaks, `stoppedEarly`
suppresses the MAX_PAGES warning, and the run returns zero rows behind an info-level
line identical to the one a genuinely empty calendar produces. Nothing in the logs
distinguishes "Surrey has no events" from "Surrey's markup moved".

Page 0 is never legitimately empty for a live listing, so that case now logs an error
naming the marker it failed to find. `pagesWalked === 1` identifies page 0 specifically:
the counter increments before the null-check, so a failed page-0 fetch (already logged by
fetchPage) can't shift the attribution onto page 1.

Log-only. `exhausted` still fires and the walk behaves exactly as before, so crawl
semantics, the page cap and the enabled: false gate are all untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ltanafranca1004

Copy link
Copy Markdown
Collaborator Author

Applied in 533d79e — the finding is valid and I took it as reported.

Verified against the code first. parsePage builds chunks from html.split(BLOCK_MARKER).slice(1), so a marker that never matches yields [] and blocks === 0. That path then sets exhausted, which breaks the walk and sets stoppedEarly — which in turn suppresses the MAX_PAGES warning at line 162. The only surviving output is the info-level console.log reporting 0 in-window + relevant (0 unique), which is byte-for-byte what a genuinely empty calendar produces. So a Drupal theme rename would have taken Surrey to zero rows with nothing in the logs marking it as a failure. That is the real hazard for the one HTML-scraping adapter in the set, and it was worth closing.

On pagesWalked === 1 identifying page 0: pagesWalked++ runs before the html === null check, so if page 0's fetch fails, no later page can reach the parse with pagesWalked === 1 — the attribution can't slide onto page 1, and that case is already logged by fetchPage. It holds under PAGE_CONCURRENCY too, since the batch is iterated in page order.

Scope: log-only. exhausted = true still fires and the walk behaves identically — same pages fetched, same stop conditions, same rows. No change to the page cap, the events_external_link_key uniqueness assumption, or the enabled: false gate. The source remains staged and inert.

@ltanafranca1004

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 48 minutes.

@ltanafranca1004

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@supabase/functions/events-crawler/adapters/surrey.ts`:
- Around line 80-130: Update parsePage to track chunks that fail TITLE_LINK_RE
extraction and expose that diagnostic in its PageParse result. In fetchEvents,
immediately after the blocks === 0 handling, detect page 0 where every parsed
block missed title extraction and log a clear parse-failure error instead of
silently reporting zero unique events. Preserve normal behavior when at least
one title matches or on later pages.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a88d30d9-35f4-467f-8b32-2d0080c74686

📥 Commits

Reviewing files that changed from the base of the PR and between fa3eb22 and 533d79e.

📒 Files selected for processing (3)
  • supabase/functions/events-crawler/adapters/surrey.ts
  • supabase/functions/events-crawler/lib/sources.ts
  • supabase/functions/events-crawler/lib/types.ts

Comment on lines +80 to +130
interface PageParse {
candidates: Candidate[];
/** Blocks seen, before any filtering — 0 means the listing has run out. */
blocks: number;
/** Latest start on the page, to decide whether the window has been walked past. */
maxStartMs: number;
}

function parsePage(html: string, source: Source, ctx: AdapterContext): PageParse {
const chunks = html.split(BLOCK_MARKER).slice(1);
const candidates: Candidate[] = [];
let maxStartMs = 0;

for (const chunk of chunks) {
const titleMatch = chunk.match(TITLE_LINK_RE);
if (!titleMatch) continue;
const href = titleMatch[1];
// Filter on the FULL cleaned title, then truncate for storage.
const fullTitle = clean(titleMatch[2]);
if (!fullTitle) continue;

TIME_RE.lastIndex = 0;
const times = [...chunk.matchAll(TIME_RE)].map((m) => m[1]);
const startIso = offsetIsoToUtc(times[0]);
if (!startIso) continue;
const startMs = Date.parse(startIso);
if (startMs > maxStartMs) maxStartMs = startMs;

// Track the window before relevance, so the walk can stop on dates even when a page
// happens to contain nothing relevant.
if (source.relevanceFilter && !isSettlementRelevant(fullTitle)) continue;
if (startMs < ctx.nowMs || startMs > ctx.windowEndMs) continue;

const locationMatch = chunk.match(LOCATION_RE);
const location = clean(locationMatch?.[1]);
if (!location) continue; // events.location is NOT NULL

let endIso = offsetIsoToUtc(times[1]);
if (endIso && Date.parse(endIso) <= startMs) endIso = null;

candidates.push({
title: fullTitle.slice(0, MAX_TITLE_CHARS),
link: `https://${source.host}${href}`,
startIso,
endIso,
location,
});
}

return { candidates, blocks: chunks.length, maxStartMs };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Generalize the parse-failure diagnostic to title-link extraction.

The fix at lines 148-163 correctly distinguishes a genuine empty listing from a broken BLOCK_MARKER on page 0. TITLE_LINK_RE sits directly downstream of that marker and fails the same way: if Drupal renames the anchor or <h3> markup, every chunk on page 0 fails titleMatch (line 94), candidates stays empty, blocks stays non-zero, and the run logs walked N page(s), 0 ... unique with no error — the exact symptom the block-marker fix was written to prevent, just one regex layer down.

Track title-match misses in parsePage and log when all blocks on page 0 fail to extract a title.

🛠️ Proposed fix to track and log title-extraction failures
 interface PageParse {
   candidates: Candidate[];
   /** Blocks seen, before any filtering — 0 means the listing has run out. */
   blocks: number;
+  /** Blocks whose title link/heading markup did not match, out of `blocks`. */
+  titleMisses: number;
   /** Latest start on the page, to decide whether the window has been walked past. */
   maxStartMs: number;
 }

 function parsePage(html: string, source: Source, ctx: AdapterContext): PageParse {
   const chunks = html.split(BLOCK_MARKER).slice(1);
   const candidates: Candidate[] = [];
   let maxStartMs = 0;
+  let titleMisses = 0;

   for (const chunk of chunks) {
     const titleMatch = chunk.match(TITLE_LINK_RE);
-    if (!titleMatch) continue;
+    if (!titleMatch) {
+      titleMisses++;
+      continue;
+    }
     ...
   }

-  return { candidates, blocks: chunks.length, maxStartMs };
+  return { candidates, blocks: chunks.length, titleMisses, maxStartMs };
 }

Then in fetchEvents, after the blocks === 0 branch:

       candidates.push(...parsed.candidates);
+      if (pagesWalked === 1 && parsed.blocks > 0 && parsed.titleMisses === parsed.blocks) {
+        console.error(
+          `events-crawler: ${source.slug} found ${parsed.blocks} block(s) on page 0 but ` +
+            `none had a matching title link — the title markup has probably changed`,
+        );
+      }
       if (parsed.maxStartMs > ctx.windowEndMs) pastWindow = true;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
interface PageParse {
candidates: Candidate[];
/** Blocks seen, before any filtering — 0 means the listing has run out. */
blocks: number;
/** Latest start on the page, to decide whether the window has been walked past. */
maxStartMs: number;
}
function parsePage(html: string, source: Source, ctx: AdapterContext): PageParse {
const chunks = html.split(BLOCK_MARKER).slice(1);
const candidates: Candidate[] = [];
let maxStartMs = 0;
for (const chunk of chunks) {
const titleMatch = chunk.match(TITLE_LINK_RE);
if (!titleMatch) continue;
const href = titleMatch[1];
// Filter on the FULL cleaned title, then truncate for storage.
const fullTitle = clean(titleMatch[2]);
if (!fullTitle) continue;
TIME_RE.lastIndex = 0;
const times = [...chunk.matchAll(TIME_RE)].map((m) => m[1]);
const startIso = offsetIsoToUtc(times[0]);
if (!startIso) continue;
const startMs = Date.parse(startIso);
if (startMs > maxStartMs) maxStartMs = startMs;
// Track the window before relevance, so the walk can stop on dates even when a page
// happens to contain nothing relevant.
if (source.relevanceFilter && !isSettlementRelevant(fullTitle)) continue;
if (startMs < ctx.nowMs || startMs > ctx.windowEndMs) continue;
const locationMatch = chunk.match(LOCATION_RE);
const location = clean(locationMatch?.[1]);
if (!location) continue; // events.location is NOT NULL
let endIso = offsetIsoToUtc(times[1]);
if (endIso && Date.parse(endIso) <= startMs) endIso = null;
candidates.push({
title: fullTitle.slice(0, MAX_TITLE_CHARS),
link: `https://${source.host}${href}`,
startIso,
endIso,
location,
});
}
return { candidates, blocks: chunks.length, maxStartMs };
}
interface PageParse {
candidates: Candidate[];
/** Blocks seen, before any filtering — 0 means the listing has run out. */
blocks: number;
/** Blocks whose title link/heading markup did not match, out of `blocks`. */
titleMisses: number;
/** Latest start on the page, to decide whether the window has been walked past. */
maxStartMs: number;
}
function parsePage(html: string, source: Source, ctx: AdapterContext): PageParse {
const chunks = html.split(BLOCK_MARKER).slice(1);
const candidates: Candidate[] = [];
let maxStartMs = 0;
let titleMisses = 0;
for (const chunk of chunks) {
const titleMatch = chunk.match(TITLE_LINK_RE);
if (!titleMatch) {
titleMisses++;
continue;
}
const href = titleMatch[1];
// Filter on the FULL cleaned title, then truncate for storage.
const fullTitle = clean(titleMatch[2]);
if (!fullTitle) continue;
TIME_RE.lastIndex = 0;
const times = [...chunk.matchAll(TIME_RE)].map((m) => m[1]);
const startIso = offsetIsoToUtc(times[0]);
if (!startIso) continue;
const startMs = Date.parse(startIso);
if (startMs > maxStartMs) maxStartMs = startMs;
// Track the window before relevance, so the walk can stop on dates even when a page
// happens to contain nothing relevant.
if (source.relevanceFilter && !isSettlementRelevant(fullTitle)) continue;
if (startMs < ctx.nowMs || startMs > ctx.windowEndMs) continue;
const locationMatch = chunk.match(LOCATION_RE);
const location = clean(locationMatch?.[1]);
if (!location) continue; // events.location is NOT NULL
let endIso = offsetIsoToUtc(times[1]);
if (endIso && Date.parse(endIso) <= startMs) endIso = null;
candidates.push({
title: fullTitle.slice(0, MAX_TITLE_CHARS),
link: `https://${source.host}${href}`,
startIso,
endIso,
location,
});
}
return { candidates, blocks: chunks.length, titleMisses, maxStartMs };
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/functions/events-crawler/adapters/surrey.ts` around lines 80 - 130,
Update parsePage to track chunks that fail TITLE_LINK_RE extraction and expose
that diagnostic in its PageParse result. In fetchEvents, immediately after the
blocks === 0 handling, detect page 0 where every parsed block missed title
extraction and log a clear parse-failure error instead of silently reporting
zero unique events. Preserve normal behavior when at least one title matches or
on later pages.

@ltanafranca1004

Copy link
Copy Markdown
Collaborator Author

Taking the TITLE_LINK_RE diagnostic as a tracked follow-up rather than a third fix round in this PR. Recording the reasoning so the decision isn't invisible.

The finding is valid — if Drupal renames the anchor or <h3> markup, every chunk misses titleMatch, candidates stays empty while blocks stays non-zero, and Surrey goes to zero rows.

One correction to the description, though: it states the run logs walked N page(s), 0 … unique with no error. That isn't quite what happens. Because blocks > 0, exhausted stays false; because maxStartMs is only assigned after a successful title match, it stays 0, so pastWindow never trips; and candidates.length never reaches MAX_PER_ORG. The walk therefore runs to the full MAX_PAGES, exits with stoppedEarly === false, and the cap warning at line 162 does fire:

events-crawler: surrey-libraries stopped at the 20-page cap with 0 candidate(s) — …

So the failure is misattributed (it reads as a page-cap effect rather than a parse break), not silent. That is a real diagnostic gap, and it is materially smaller than the blocks === 0 case fixed in 533d79e, which produced no warning at all. Consistent with the 🔵 Trivial rating here versus 🟠 Major there.

Why defer: the source ships enabled: false and is filtered out of every run by ACTIVE_SOURCES, and the deployed function is still v6 — which predates all of this code. There is no production exposure to close. LOCATION_RE and TIME_RE sit in the identical position downstream, so the durable fix is one pass that separates structural extraction misses (lines 99/104/115) from intentional relevance/window filtering (lines 110-111) — a single counter covering the family, rather than one regex per review round. That is better done deliberately, alongside enabling the sources, than incrementally here.

Tracked in BACKLOG.md. Merging on the review at 533d79e.

@ltanafranca1004
ltanafranca1004 merged commit 8647476 into main Aug 2, 2026
4 checks passed
ltanafranca1004 added a commit that referenced this pull request Aug 2, 2026
docs(backlog): track the Surrey parse-failure diagnostic deferred from PR #86
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant