Philadelphia City Council Calendar Events
Purpose
Return a list of Philadelphia City Council meetings and committee hearings published on phila.legistar.com/Calendar.aspx — one record per event with the meeting body name, date, time, location, plus stable identifiers (event ID, GUID) and the canonical MeetingDetail.aspx URL. Read-only; never books, edits, or submits anything. Output is a Zod-validated array.
When to Use
- Watching the Philadelphia City Council calendar for new committee hearings or full-council sessions to attend / cover / track.
- Building a date-bounded archive of every meeting the City Council and its committees held in a given year (or "All Years" for the full back-catalog to 2000).
- Filtering by meeting body (CITY COUNCIL, Committee on Finance, joint committees, special committees, etc.) for downstream legislative-tracking pipelines.
- Anywhere you'd otherwise scrape the Legistar HTML grid — the Granicus Legistar Web API (see step 1) is faster, paginated by OData, and structurally cleaner.
Workflow
phila.legistar.com is a hosted Granicus Legistar instance. The same data the calendar grid renders is also exposed by the Granicus Legistar Web API at https://webapi.legistar.com/v1/phila/Events — no auth, no cookies, no anti-bot, no stealth needed. Lead with the API path. The browser path works and is the literal task description ("navigate calendar, filter by year") — keep it as the documented fallback because the API returns XML only and CORS-blocks in-browser fetch(), so any deployment that can't reach webapi.legistar.com from server-side code has to drive the calendar grid.
Recommended — Granicus Legistar Web API
-
GET the year-bounded slice:
GET https://webapi.legistar.com/v1/phila/Events ?$filter=EventDate ge datetime'YYYY-01-01' and EventDate lt datetime'YYYY+1-01-01' &$orderby=EventDate desc &$top=1000(Spaces in
$filtermust be URL-encoded as%20or+. Single-quotes must be literal — they're part of OData'sdatetime'…'syntax.) For all years, drop the$filter. Returnsapplication/xml(<ArrayOfGranicusEvent>containing<GranicusEvent>children). -
Parse XML. Field map per
<GranicusEvent>:EventBodyName→ meeting body / committee name (e.g.CITY COUNCIL,Committee on Finance,Joint Committees on …)EventDate→ ISO dateYYYY-MM-DDT00:00:00(always midnight; time is in a separate field)EventTime→ free-text time string (9:00 AM,1:30 PM, occasionally blank)EventLocation→ free-text location (Room 400, City Hall, sometimes with trailing notes)EventId→ integer, stable Legistar primary keyEventGuid→ uppercase UUID, stableEventBodyId→ integer body ID (e.g.10= CITY COUNCIL,39= Committee of the Whole)EventInSiteURL→ canonicalMeetingDetail.aspx?LEGID=<id>&GID=30&G=<root-guid>URLEventAgendaFile/EventMinutesFile→ PDF URLs (nilled withi:nil="true"when not published)EventComment→ notes ("No Calendar for Today", "tabled until …"); often nil
-
Validate with the Zod schema below and emit the array.
-
Paginate if you set
$toplower than the year's record count: use$skip=<n>&$top=<m>with the same$filter+$orderby. The 2025 record count is ~154; the API will happily return all of them in a single$top=1000call. For "All Years" (≥50k records),$topdefaults to ~1000 server-side; paginate explicitly.
Browser fallback — drive Calendar.aspx
Use this when the WebAPI is unreachable (proxy / firewall / DNS) or when the consuming environment can't run server-side HTTP and only has a browser session.
-
Open
https://phila.legistar.com/Calendar.aspx. Default view is "This Month" with body filter "City Council and All Committees". No stealth required — the site has no anti-bot. -
Change the Year filter. The control is a Telerik RadComboBox at
[id*=lstYears]. Click the dropdown's "select" link (an anchor insidecell: selectin the snapshot, adjacent to the year input), wait ~800ms for the option list to render, then click the listitem whose StaticText matches the target —2025,2024, …,All Years, or a relative preset (This Year,Last Year,This Month,Today, etc.). This triggers an ASP.NET full-page postback (~2-3s). -
Confirm filter applied. Read
document.querySelector("[id*=lstYears] input[id*=Input]").value— must equal the year you picked. The page also persists a cookieSetting-30-Calendar Year=<value>for subsequent visits in the same session. -
Extract page 1 from
table[id*=gridCalendar]. Filter out pager rows (tr.querySelectorAll("td").length <= 5). Data row columns by index:td[0]— meeting body name (CITY COUNCIL,Committee on Finance, …)td[1]— meeting date asM/D/YYYYtd[2]— iCalendar export anchor (a[href*="View.ashx?M=IC"])td[3]— meeting timeH:MM AM/PM(occasionally blank)td[4]— location, sometimes followed by a<br>+ emphasized notetd[5]— Meeting Details anchor →MeetingDetail.aspx?ID=<EventId>&GUID=<EventGuid>&Options=info|&Search=td[6..7]— Agenda + Accessible Agenda anchors (or "Not available")td[8..10]— Agenda Packet, Minutes, Accessible Minutes (often "Not available")
-
Paginate. Read
document.body.innerText.match(/Page (\d+) of (\d+)/)(the string appears twice — once per pager, top + bottom — so dedupe). For pages 2..N, trigger the page postback:__doPostBack("ctl00$ContentPlaceHolder1$gridCalendar$ctl00$ctl02$ctl00$ctl04", "");The control ID encodes the page number in the trailing
ctl04→ page 2,ctl05→ page 3, etc. Discover the exact ID by selecting the anchor whoseinnerTextmatches the target page number under#ctl00_ContentPlaceHolder1_gridCalendar_ctl00NPPHTop. Wait ~2-3s for postback completion before re-extracting. -
Stop when the pager shows
items A to B of NwithB === Nor the page number equals the total. Concatenate, dedupe byEventIdif you ran into the rare pager overlap, validate with Zod, emit.
Site-Specific Gotchas
- Granicus Legistar Web API exists and is unauthenticated —
https://webapi.legistar.com/v1/phila/Eventsis the canonical fast-path. Standard OData ($filter,$orderby,$top,$skip,$select,$count). Thephilasegment is the Legistar client slug; other Legistar cities use the same API shape under their own slug (e.g.nyc,chicago,seattle). Nophila.legistar.comcookies or session needed to hit the API — it's a separate origin. - API returns XML only.
$format=jsonis explicitly rejected (400 — Query option 'Format' is not allowed). SettingAccept: application/jsondoes not switch the response. Parse the XML — every field is a simple<EventX>value</EventX>tag, no attributes (excepti:nil="true"on missing values), sofast-xml-parseror a regex strategy both work. - API is CORS-blocked from in-browser
fetch(). Afetch("https://webapi.legistar.com/...")from a page onphila.legistar.com(or any other origin) fails withTypeError: Failed to fetch. The API must be called from server-side code (Nodefetch,curl, Playwrightrequestcontext — anything that isn't a browser-page-context fetch). EventDateis always midnight (T00:00:00). The wall-clock time is a separate string inEventTime("9:00 AM","1:30 PM", occasionally blank or"TBD"). Combine them client-side if you need a full datetime; don't trustEventDate's time component.EventTimeis free-text, not parseable as a fixed format. Most values areH:MM AM/PM. Some events haveEventTimeblank (especially older records and "No Calendar for Today" placeholders). The grid also shows blank time for the same events.- HTML date format is
M/D/YYYY, notMM/DD/YYYY. No zero-padding (5/4/2026not05/04/2026). Parse defensively. - The calendar grid defaults to "This Month" + "All committees". A fresh
GET /Calendar.aspxreturns ~18 rows (the current month). The Year dropdown must be set explicitly to pull a full year. The body dropdown defaults to "City Council and All Committees" which is all events; narrowing to "CITY COUNCIL" alone filters out committee meetings. - ASP.NET WebForms postbacks, not URL-driven filters. Year, body, search, sort, and pagination all go through
__doPostBack(...)with__VIEWSTATEcookies — there is no?year=2025URL param. You can't deep-link to a filtered view; cookies persist filter state but only across requests in the same session (Setting-30-Calendar Year=2025,Setting-30-Calendar Body=All,Setting-30-ASP.calendar_aspx.gridCalendar.SortExpression=MeetingStartDate DESC). - Telerik RadComboBox quirks. The Year and Body dropdowns are not native
<select>elements. Open them by clicking the small "select" anchor adjacent to the textbox (cell roleselect, ref like[1-381]in the a11y tree). Options render in a floating div outside the table cell.<select name=…>selectors do not work. - Pagination chunk is 100 rows. Pager text
Page X of Y, items A to B of Nappears twice (top + bottom pager) — the regex matches both; dedupe before parsing. Total record count is theNat the end. Pages > 1 are reached via__doPostBack("ctl00$ContentPlaceHolder1$gridCalendar$ctl00$ctl02$ctl00$ctl<NN>", "")where<NN>encodes the page number — discover the literal control ID by querying the pager anchors rather than guessing. - "All Years" returns the full back-catalog (~50,000 events from 2000 onward). Don't accidentally pick it for a single-year extraction — both the browser path (500 pages) and the API path (50k records over N $skip pages) get expensive. Always pass an explicit year filter unless you actually want everything.
- "Joint Committees" event names are long. Examples: "Joint Committees on Public Health & Human Services and Public Safety", "Joint Special Committee on Gun Violence Prevention & Committee on Children and Youth". Display-width truncation in the rendered table is a CSS concern only — the underlying
EventBodyName(API) ortd[0].innerText(browser) contains the full string. EventBodyNameis the authoritative meeting-body field. The browser path renders the same string intotd[0]; both are consistent. Do not derive body fromEventBodyId— body IDs are stable but not human-readable.- Location cell has trailing emphasis notes. For CITY COUNCIL rows,
td[4]readsRoom 400, City Hallfollowed by<br>+ an italicized "PLEASE USE THE AGENDA PDF to select an item for PUBLIC COMMENT, not the MEETING DETAILS." When extracting a clean location, take the first line of the innerText only. Budget hearings on Committee of the Whole append_BUDGET_similarly. - No anti-bot / no stealth. The site is bare ASP.NET WebForms on Microsoft-IIS/10.0 served via Granicus.
--proxies/--verifiedare not required — they cost extra and offer no benefit. Default to a bare cloud session. - iCalendar export per event. Each row has an
View.ashx?M=IC&ID=<EventId>&GUID=<EventGuid>link that returns a standalone.icsfile. Useful as a stable per-event permalink alongsideMeetingDetail.aspx.
Expected Output
A flat array of event objects, Zod-validated. One outcome shape:
[
{
"name": "CITY COUNCIL",
"date": "2025-12-11",
"time": "10:00 AM",
"body": "CITY COUNCIL",
"location": "Room 400, City Hall",
"eventId": 6288,
"eventGuid": "F8B07668-09DD-443A-B770-8C38F335AA88",
"meetingDetailUrl": "https://phila.legistar.com/MeetingDetail.aspx?LEGID=6288&GID=30&G=A5947DFE-5A17-435B-A57D-5F0923C2343D",
"icsUrl": "https://phila.legistar.com/View.ashx?M=IC&ID=6288&GUID=F8B07668-09DD-443A-B770-8C38F335AA88"
},
{
"name": "Committee on Public Property and Public Works",
"date": "2025-12-11",
"time": "9:15 AM",
"body": "Committee on Public Property and Public Works",
"location": "Room 400, City Hall",
"eventId": 6283,
"eventGuid": "1EA4C2FB-060C-4EF3-9AC1-E19A1510067C",
"meetingDetailUrl": "https://phila.legistar.com/MeetingDetail.aspx?ID=6283&GUID=1EA4C2FB-060C-4EF3-9AC1-E19A1510067C",
"icsUrl": "https://phila.legistar.com/View.ashx?M=IC&ID=6283&GUID=1EA4C2FB-060C-4EF3-9AC1-E19A1510067C"
}
]
Zod schema (also emitted to output_schema.ts alongside this SKILL.md):
import { z } from "zod";
const EventSchema = z.object({
name: z.string().min(1),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
time: z.string(), // may be empty for "No Calendar" placeholders
body: z.string().min(1), // same as name in this dataset, kept distinct for downstream filters
location: z.string(),
eventId: z.number().int().positive(),
eventGuid: z.string().regex(/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/),
meetingDetailUrl: z.string().url(),
icsUrl: z.string().url().optional(),
});
export const OutputSchema = z.array(EventSchema);