Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/scraper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ These data sources are registered and run by `all`:
| ------------------------ | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| `federalregister` | Federal Register API presidential documents, then each document's body HTML | `government_content`; AI article/summary and feed-image enrichment |
| `durham-bocc` | Durham County's official Legistar API (current election cycle only) | Provider-neutral meetings, agenda items, actions, votes, and official document links; no AI required |
| `kansas-city-council` | Kansas City's official Legistar API (current Council term and body ID 138 only) | Provider-neutral meetings, revisions, documents, legislation, actions, and named votes |
| `congress` | Congress.gov API bill list, detail, CRS summaries, formatted text, and legislative actions | `bill`; powers federal bill content and AI/feed enrichment |
| `scotus` | CourtListener opinion clusters, dockets, and sub-opinion text for the Supreme Court | `court_case`; powers court content and AI/feed enrichment |
| `scc-cvig` | Hand-configured Santa Clara County voter-guide PDFs | Candidate statements in `CivicApiCache`; the API matches statements to candidates |
Expand Down Expand Up @@ -139,6 +140,7 @@ CONGRESS_MAX_ITEMS=10 pnpm --filter @acme/scraper run start congress
| `TX_SOS_MAX_ITEMS` | 12 | Current-cycle Texas SOS election payloads |
| `CEDAR_PARK_COUNCIL_MAX_ITEMS` | 100 | Council meetings (after the 12-month cutoff) |
| `DURHAM_BOCC_MAX_ITEMS` | 100 | Current-cycle Durham County BOCC meetings |
| `KANSAS_CITY_COUNCIL_MAX_ITEMS` | 250 | Current-term Kansas City Council meetings |
| `SCRAPER_MAX_NEW_ITEMS_PER_RUN` | 10 | New records receiving expensive AI/image enrichment |

These are per-run limits, not durable calendar-day quotas. Schedule one run per
Expand All @@ -153,6 +155,25 @@ history plus candidate contact/address fields. See
[`docs/ncsbe-election-data.md`](../../docs/ncsbe-election-data.md) for discovery,
idempotency, provenance, API, and deterministic Civic-matching details.

## Kansas City Council (`kansas-city-council.ts`)

This keyless adapter uses `webapi.legistar.com/v1/kansascity`, restricted to
Council body ID `138` and the active term beginning August 1, 2023. It stores
stable Event/EventItem/Vote IDs, Central Time/DST, cancellations and revisions,
locations, official agenda/minutes/attachment links, video media IDs,
legislation file numbers, actions/outcomes, and named votes. It also checks
actioned items because Kansas City can publish a vote while leaving the
Legistar roll-call flag unset. Hidden test events are excluded.

Meetings are serialized and vote lookups are capped at two concurrent requests.
Reruns upsert natural keys, mark replaced documents non-current, replace a
successfully refreshed roll call atomically, and preserve last-known votes when
the vote endpoint is temporarily unavailable.

```bash
pnpm --filter @acme/scraper run start kansas-city-council --max-items 5
```

## Cedar Park City Council (`civicengage.ts`)

Cedar Park's public site is CivicEngage, but the City Council records page now
Expand Down
2 changes: 2 additions & 0 deletions apps/scraper/src/scraper-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { congressConfig } from "./scrapers/congress.config.js";
import { durhamBoccConfig } from "./scrapers/durham-bocc.config.js";
import { durhamOnBaseConfig } from "./scrapers/durham-onbase.config.js";
import { federalregisterConfig } from "./scrapers/federalregister.config.js";
import { kansasCityCouncilConfig } from "./scrapers/kansas-city-council.config.js";
import { ncsbeConfig } from "./scrapers/ncsbe.config.js";
import { sccCvigConfig } from "./scrapers/scc-cvig.config.js";
import { scotusConfig } from "./scrapers/scotus.config.js";
Expand All @@ -14,6 +15,7 @@ import { texasLegislatureConfig } from "./scrapers/texas-legislature.config.js";

export const scraperContracts: readonly ScraperEnvContract[] = [
federalregisterConfig,
kansasCityCouncilConfig,
durhamBoccConfig,
congressConfig,
scotusConfig,
Expand Down
2 changes: 2 additions & 0 deletions apps/scraper/src/scrapers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { congress } from "./scrapers/congress.js";
import { durhamBocc } from "./scrapers/durham-bocc.js";
import { durhamOnBase } from "./scrapers/durham-onbase.js";
import { federalregister } from "./scrapers/federalregister.js";
import { kansasCityCouncil } from "./scrapers/kansas-city-council.js";
import { ncsbe } from "./scrapers/ncsbe.js";
import { sccCvig } from "./scrapers/scc-cvig.js";
import { scotus } from "./scrapers/scotus.js";
Expand All @@ -13,6 +14,7 @@ import { texasLegislature } from "./scrapers/texas-legislature.js";

export const scrapers: readonly Scraper[] = [
federalregister,
kansasCityCouncil,
durhamBocc,
congress,
scotus,
Expand Down
86 changes: 86 additions & 0 deletions apps/scraper/src/scrapers/fixtures/kansas-city-council/events.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
[
{
"EventId": 19001,
"EventGuid": "11111111-1111-4111-8111-111111111111",
"EventLastModifiedUtc": "2026-01-09T22:00:00.000",
"EventRowVersion": "kc-event-v1",
"EventBodyId": 138,
"EventBodyName": "Council",
"EventDate": "2026-01-08T00:00:00",
"EventTime": "2:00 PM",
"EventAgendaStatusName": "Final",
"EventMinutesStatusName": "Final",
"EventLocation": "Council Chambers",
"EventAgendaFile": "https://kansascity.legistar1.com/meetings/19001-agenda.pdf",
"EventMinutesFile": "https://kansascity.legistar1.com/meetings/19001-minutes.pdf",
"EventAgendaLastPublishedUTC": "2026-01-08T17:00:00.000",
"EventMinutesLastPublishedUTC": "2026-01-09T22:00:00.000",
"EventComment": "Webinar Link: https://example.test/council",
"EventVideoPath": null,
"EventMedia": "14001",
"EventInSiteURL": "https://kansascity.legistar.com/MeetingDetail.aspx?LEGID=19001"
},
{
"EventId": 19002,
"EventGuid": "22222222-2222-4222-8222-222222222222",
"EventLastModifiedUtc": "2026-07-03T20:00:00.000",
"EventRowVersion": "kc-special-v1",
"EventBodyId": 138,
"EventBodyName": "Council",
"EventDate": "2026-07-02T00:00:00",
"EventTime": "12:30 PM",
"EventAgendaStatusName": "Final",
"EventMinutesStatusName": "Draft",
"EventLocation": "KCPD Headquarters - Community Room",
"EventAgendaFile": "https://kansascity.legistar1.com/meetings/19002-agenda.pdf",
"EventMinutesFile": null,
"EventAgendaLastPublishedUTC": "2026-07-02T16:00:00.000",
"EventMinutesLastPublishedUTC": null,
"EventComment": "Special Council Meeting",
"EventVideoPath": "https://videos.kcmo.gov/19002",
"EventMedia": null,
"EventInSiteURL": "https://kansascity.legistar.com/MeetingDetail.aspx?LEGID=19002"
},
{
"EventId": 19003,
"EventGuid": "33333333-3333-4333-8333-333333333333",
"EventLastModifiedUtc": "2026-02-12T18:00:00.000",
"EventRowVersion": "kc-cancelled-v1",
"EventBodyId": 138,
"EventBodyName": "Council",
"EventDate": "2026-02-12T00:00:00",
"EventTime": "2:00 PM",
"EventAgendaStatusName": "Cancelled",
"EventMinutesStatusName": null,
"EventLocation": "Council Chambers",
"EventAgendaFile": null,
"EventMinutesFile": null,
"EventAgendaLastPublishedUTC": null,
"EventMinutesLastPublishedUTC": null,
"EventComment": "Meeting cancelled",
"EventVideoPath": null,
"EventMedia": null,
"EventInSiteURL": "https://kansascity.legistar.com/MeetingDetail.aspx?LEGID=19003"
},
{
"EventId": 19004,
"EventGuid": "44444444-4444-4444-8444-444444444444",
"EventLastModifiedUtc": "2026-04-16T21:30:00.000",
"EventRowVersion": "kc-revised-v2",
"EventBodyId": 138,
"EventBodyName": "Council",
"EventDate": "2026-04-16T00:00:00",
"EventTime": "2:00 PM",
"EventAgendaStatusName": "Final-Revised",
"EventMinutesStatusName": "Final-Revised",
"EventLocation": "26th Floor, Council Chambers",
"EventAgendaFile": "https://kansascity.legistar1.com/meetings/19004-agenda-revised.pdf",
"EventMinutesFile": "https://kansascity.legistar1.com/meetings/19004-minutes-revised.pdf",
"EventAgendaLastPublishedUTC": "2026-04-16T20:30:00.000",
"EventMinutesLastPublishedUTC": "2026-04-16T21:30:00.000",
"EventComment": "Webinar Link: https://example.test/council",
"EventVideoPath": null,
"EventMedia": null,
"EventInSiteURL": "https://kansascity.legistar.com/MeetingDetail.aspx?LEGID=19004"
}
]
75 changes: 75 additions & 0 deletions apps/scraper/src/scrapers/fixtures/kansas-city-council/items.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
[
{
"EventItemId": 106909,
"EventItemLastModifiedUtc": "2026-07-16T21:12:40.197",
"EventItemRowVersion": "kc-item-v2",
"EventItemEventId": 19004,
"EventItemAgendaSequence": 15,
"EventItemAgendaNumber": null,
"EventItemVersion": "2",
"EventItemAgendaNote": "Committee substitute",
"EventItemMinutesNote": "The motion carried.",
"EventItemActionId": 409,
"EventItemActionName": "Passed as Substituted",
"EventItemActionText": "A motion was made that this Ordinance be Passed as Substituted.",
"EventItemPassedFlagName": "Pass",
"EventItemRollCallFlag": 0,
"EventItemTitle": "Approving funding for the 1815 Paseo Project.",
"EventItemTally": null,
"EventItemConsent": 0,
"EventItemMover": "Councilmember A",
"EventItemSeconder": "Councilmember B",
"EventItemMatterId": 50729,
"EventItemMatterGuid": "B41F898F-F92D-4A4E-B3B1-33F53A59EA56",
"EventItemMatterFile": "260582",
"EventItemMatterName": null,
"EventItemMatterType": "Ordinance",
"EventItemMatterStatus": "Passed",
"EventItemMatterAttachments": [
{
"MatterAttachmentId": 104574,
"MatterAttachmentLastModifiedUtc": "2026-07-06T17:39:27.393",
"MatterAttachmentRowVersion": "attachment-v1",
"MatterAttachmentName": "Council Agenda Packet",
"MatterAttachmentHyperlink": "https://kansascity.legistar1.com/attachments/packet.pdf",
"MatterAttachmentShowOnInternetPage": true
},
{
"MatterAttachmentId": 104575,
"MatterAttachmentLastModifiedUtc": "2026-07-08T15:58:22.030",
"MatterAttachmentRowVersion": "attachment-v1",
"MatterAttachmentName": "Docket Memo",
"MatterAttachmentHyperlink": "https://kansascity.legistar1.com/attachments/docket-memo.pdf",
"MatterAttachmentShowOnInternetPage": true
}
]
},
{
"EventItemId": 106906,
"EventItemLastModifiedUtc": "2026-07-16T20:07:18.743",
"EventItemRowVersion": "kc-roll-call-v1",
"EventItemEventId": 19004,
"EventItemAgendaSequence": 4,
"EventItemAgendaNumber": null,
"EventItemVersion": null,
"EventItemAgendaNote": null,
"EventItemMinutesNote": null,
"EventItemActionId": null,
"EventItemActionName": null,
"EventItemActionText": null,
"EventItemPassedFlagName": null,
"EventItemRollCallFlag": 1,
"EventItemTitle": "ROLL CALL:",
"EventItemTally": null,
"EventItemConsent": 0,
"EventItemMover": null,
"EventItemSeconder": null,
"EventItemMatterId": null,
"EventItemMatterGuid": null,
"EventItemMatterFile": null,
"EventItemMatterName": null,
"EventItemMatterType": null,
"EventItemMatterStatus": null,
"EventItemMatterAttachments": []
}
]
20 changes: 20 additions & 0 deletions apps/scraper/src/scrapers/fixtures/kansas-city-council/votes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[
{
"VoteId": 89823,
"VoteLastModifiedUtc": "2026-07-16T21:05:22.227",
"VotePersonId": 194,
"VotePersonName": "Quinton Lucas",
"VoteValueName": "Nay",
"VoteSort": 63,
"VoteEventItemId": 106909
},
{
"VoteId": 89824,
"VoteLastModifiedUtc": "2026-07-16T21:05:22.237",
"VotePersonId": 196,
"VotePersonName": "Kevin O'Neill",
"VoteValueName": "Aye",
"VoteSort": 64,
"VoteEventItemId": 106909
}
]
11 changes: 11 additions & 0 deletions apps/scraper/src/scrapers/kansas-city-council.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { ScraperEnvContract } from "@acme/env";

export const kansasCityCouncilConfig = {
id: "kansas-city-council",
name: "Kansas City Council",
source: "Kansas City Legistar Web API — Council meetings and roll-call votes",
environment: {
required: ["POSTGRES_URL"],
optional: ["KANSAS_CITY_COUNCIL_MAX_ITEMS"],
},
} as const satisfies ScraperEnvContract;
112 changes: 112 additions & 0 deletions apps/scraper/src/scrapers/kansas-city-council.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";

import {
adaptKansasCityItem,
adaptKansasCityMeeting,
adaptKansasCityVote,
currentKansasCityCouncilCycleStart,
isDiscoverableKansasCityEvent,
parseKansasCityStart,
} from "./kansas-city-council.js";

async function fixture(name: string): Promise<unknown> {
const url = new URL(
`./fixtures/kansas-city-council/${name}.json`,
import.meta.url,
);
return JSON.parse(await readFile(url, "utf8")) as unknown;
}

void test("maps regular, special, cancelled, and revised Council meetings", async () => {
const events = (await fixture("events")) as unknown[];
const [regular, special, cancelled, revised] = events.map(
adaptKansasCityMeeting,
);

assert.equal(regular?.meetingType, "Regular Meeting");
assert.match(regular?.videoUrl ?? "", /ID1=14001/);
assert.equal(special?.meetingType, "Special Meeting");
assert.equal(special?.location, "KCPD Headquarters - Community Room");
assert.equal(cancelled?.isCancelled, true);
assert.equal(cancelled?.status, "cancelled");
assert.equal(revised?.isAmended, true);
assert.equal(revised?.documents[0]?.title, "Revised Agenda");
assert.equal(revised?.documents[1]?.title, "Revised Minutes");
});

void test("maps legislation references, actions, packets, and named votes", async () => {
const [rawItem, rawRollCall] = (await fixture("items")) as unknown[];
const [rawNay, rawAye] = (await fixture("votes")) as unknown[];
const item = adaptKansasCityItem(rawItem);
const rollCall = adaptKansasCityItem(rawRollCall);
const nay = adaptKansasCityVote(rawNay);
const aye = adaptKansasCityVote(rawAye);

assert.equal(item.itemNumber, "260582");
assert.equal(item.itemType, "Ordinance");
assert.equal(item.action, "Passed as Substituted");
assert.equal(item.outcome, "Pass");
assert.equal(item.shouldFetchVotes, true);
assert.equal(item.documents[0]?.type, "packet");
assert.equal(item.documents[1]?.type, "attachment");
assert.equal(rollCall.section, "ROLL CALL");
assert.equal(rollCall.shouldFetchVotes, true);
assert.deepEqual(
[nay.voterName, nay.value, aye.voterName, aye.value],
["Quinton Lucas", "Nay", "Kevin O'Neill", "Aye"],
);
});

void test("keeps stable IDs while revisions and document replacements change hashes", async () => {
const [raw] = (await fixture("events")) as Record<string, unknown>[];
const original = adaptKansasCityMeeting(raw);
const replacement = adaptKansasCityMeeting({
...raw,
EventAgendaFile:
"https://kansascity.legistar1.com/meetings/19001-agenda-v2.pdf",
EventAgendaLastPublishedUTC: "2026-01-08T19:00:00.000",
EventLastModifiedUtc: "2026-01-08T19:01:00.000",
EventRowVersion: "kc-event-v2",
});

assert.equal(original.externalId, replacement.externalId);
assert.notEqual(original.sourceVersion, replacement.sourceVersion);
assert.notEqual(original.contentHash, replacement.contentHash);
assert.notEqual(
original.documents[0]?.checksum,
replacement.documents[0]?.checksum,
);
});

void test("uses Central Time DST and the active four-year Council term", () => {
assert.equal(
parseKansasCityStart("2026-01-08T00:00:00", "2:00 PM").toISOString(),
"2026-01-08T20:00:00.000Z",
);
assert.equal(
parseKansasCityStart("2026-07-02T00:00:00", "2:00 PM").toISOString(),
"2026-07-02T19:00:00.000Z",
);
assert.equal(
currentKansasCityCouncilCycleStart(
new Date("2026-07-22T00:00:00Z"),
).toISOString(),
"2023-08-01T00:00:00.000Z",
);
});

void test("excludes hidden test meetings from primary-body discovery", async () => {
const [raw] = (await fixture("events")) as Record<string, unknown>[];
assert.equal(isDiscoverableKansasCityEvent(raw), true);
assert.equal(
isDiscoverableKansasCityEvent({
...raw,
EventId: 99999,
EventAgendaStatusName: "Hidden",
EventComment: "Test Meeting",
}),
false,
);
});
Loading