diff --git a/.env.example b/.env.example index ee61d50..33d42e9 100644 --- a/.env.example +++ b/.env.example @@ -159,6 +159,9 @@ CONGRESS_API_KEY= # California SOS office pages per run. # CA_SOS_MAX_ITEMS=9 +# Certified current-cycle Missouri candidates per run. +# MISSOURI_SOS_MAX_ITEMS=1000 + # Scraper cost reporting # Estimated DeepSeek V4 Flash input price. # LLM_INPUT_PRICE=0.14 diff --git a/apps/scraper/README.md b/apps/scraper/README.md index aca4ed6..14c2e53 100644 --- a/apps/scraper/README.md +++ b/apps/scraper/README.md @@ -15,6 +15,7 @@ These data sources are registered and run by `all`: | `scc-cvig` | Hand-configured Santa Clara County voter-guide PDFs | Candidate statements in `CivicApiCache`; the API matches statements to candidates | | `ca-sos-statements` | California SOS statewide-office candidate-statement pages | Candidate statements in `CivicApiCache`; the API reads the cache and can fall back to the live source | | `ncsbe` | Current-cycle NCSBE candidate CSV, referendum PDFs, and result ZIPs | Provider-neutral election tables; powers `civic.getNcElectionData` with exact file provenance | +| `missouri-sos` | Missouri SOS 2026 certified candidates, measures, and active results | Provider-neutral current-cycle snapshot; powers `civic.getMissouriCurrentElection` | | `texas-legislature` | Texas Legislative Council anonymous FTP: current-session history XML and bulk documents | State-aware `bill` rows; read through `content.texasBills` and `content.getById` | | `texas-current-election` | Texas SOS structured election feed and TLC amendment analyses | Current-cycle snapshots; powers `civic.getTexasCurrentElection` and measure enrichment | | `cedar-park-council` | Cedar Park's CivicEngage City Council page and its official Municode Meetings embed | Provider-neutral local meetings, documents, agenda items, motions, outcomes, and votes | @@ -135,6 +136,7 @@ CONGRESS_MAX_ITEMS=10 pnpm --filter @acme/scraper run start congress | `SCC_CVIG_MAX_ITEMS` | 10 | Voter-guide PDF documents | | `CA_SOS_MAX_ITEMS` | 9 | Statewide-office candidate-statement pages | | `NCSBE_MAX_ITEMS` | 4 | Current-cycle candidate/referendum/result files | +| `MISSOURI_SOS_MAX_ITEMS` | 1000 | Certified current-cycle Missouri candidates | | `TEXAS_LEGISLATURE_MAX_ITEMS` | 100 | Bills from the latest Texas bulk session | | `TX_SOS_MAX_ITEMS` | 12 | Current-cycle Texas SOS election payloads | | `CEDAR_PARK_COUNCIL_MAX_ITEMS` | 100 | Council meetings (after the 12-month cutoff) | @@ -153,6 +155,12 @@ 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. +The Missouri integration is also current-cycle only. It follows the dynamic +2026 SOS election code, parses the cumulative certified list without candidate +address/contact cells, persists certified measures and result availability, and +has no voter-level or historical reader. See +[`docs/missouri-sos-election-data.md`](../../docs/missouri-sos-election-data.md). + ## Cedar Park City Council (`civicengage.ts`) Cedar Park's public site is CivicEngage, but the City Council records page now diff --git a/apps/scraper/src/fixtures/missouri-sos/calendar.html b/apps/scraper/src/fixtures/missouri-sos/calendar.html new file mode 100644 index 0000000..fb7cc28 --- /dev/null +++ b/apps/scraper/src/fixtures/missouri-sos/calendar.html @@ -0,0 +1,36 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Official Election DayStyleRegistrationFilingCloseFinal Certification
April 7, 2026General Municipal Election DayJanuary 27, 2026
August 4, 2026Primary ElectionMay 26, 2026
November 3, 2026General ElectionAugust 25, 2026
+
diff --git a/apps/scraper/src/fixtures/missouri-sos/candidate-index.html b/apps/scraper/src/fixtures/missouri-sos/candidate-index.html new file mode 100644 index 0000000..fc3aa45 --- /dev/null +++ b/apps/scraper/src/fixtures/missouri-sos/candidate-index.html @@ -0,0 +1,11 @@ +
+

2026 Primary Election

+ State Senator - District 2 + State Representative - District 11 +
diff --git a/apps/scraper/src/fixtures/missouri-sos/candidates.html b/apps/scraper/src/fixtures/missouri-sos/candidates.html new file mode 100644 index 0000000..d53180d --- /dev/null +++ b/apps/scraper/src/fixtures/missouri-sos/candidates.html @@ -0,0 +1,49 @@ +
+

Certified Candidate List

+

2026 Primary Election

+

State Senator - District 2

+ + + + + + + + + + + + + + +
+ Republican +
NameMailing Address
Alice First101 Secret Street
Bob Second202 Private Avenue
+ + + + + + + + + + +
+ Democratic +
NameMailing Address
Carol Third303 Hidden Road
+

State Representative - District 11

+ + + + + + + + + + +
+ Libertarian +
NameMailing Address
Dana Fourth404 Confidential Lane
+
diff --git a/apps/scraper/src/fixtures/missouri-sos/historical-results-layout.html b/apps/scraper/src/fixtures/missouri-sos/historical-results-layout.html new file mode 100644 index 0000000..3f6250f --- /dev/null +++ b/apps/scraper/src/fixtures/missouri-sos/historical-results-layout.html @@ -0,0 +1,27 @@ + +
+

Last Updated: November 4, 2024 11:45 PM | Unofficial Results

+ + + + + + + + + + + + + + + + + + + + +
+ Governor +
CandidatePartyTotal VotesPercent
Example Candidate ARepublican1,25052.1%
Example Candidate BDemocratic1,14947.9%
+
diff --git a/apps/scraper/src/fixtures/missouri-sos/measures.html b/apps/scraper/src/fixtures/missouri-sos/measures.html new file mode 100644 index 0000000..832eecb --- /dev/null +++ b/apps/scraper/src/fixtures/missouri-sos/measures.html @@ -0,0 +1,56 @@ +
+

+ The following ballot measures have been certified for the + August 4, 2026 primary election. +

+

+ Official Ballot Title
Amendment 1 +

+

+ full text +

+

+ certificate +

+

Official Ballot Title:

+
+

Shall the Missouri Constitution be amended for this fixture?

+

State entities estimate no costs or savings.

+
+

Fair Ballot Language:

+
+

A “yes” vote will amend the Constitution.

+

A “no” vote will not amend it.

+
+

+ The following ballot measures have been certified for the + November 3, 2026 general election. +

+

+ Official Ballot Title
Amendment 3 +

+

+ full text +

+

+ certificate +

+

Official Ballot Title:

+
+

Shall another certified proposal be adopted?

+

The fiscal impact is unknown.

+
+

Fair Ballot Language:

+
+

A “yes” vote adopts the proposal.

+

A “no” vote rejects the proposal.

+
+
diff --git a/apps/scraper/src/fixtures/missouri-sos/showmo-unavailable.html b/apps/scraper/src/fixtures/missouri-sos/showmo-unavailable.html new file mode 100644 index 0000000..7e3e995 --- /dev/null +++ b/apps/scraper/src/fixtures/missouri-sos/showmo-unavailable.html @@ -0,0 +1,5 @@ +
+

ShowMO Votes

+

Election resources and voter information.

+ Previous Election Results +
diff --git a/apps/scraper/src/fixtures/missouri-sos/withdrawals.html b/apps/scraper/src/fixtures/missouri-sos/withdrawals.html new file mode 100644 index 0000000..3f2e270 --- /dev/null +++ b/apps/scraper/src/fixtures/missouri-sos/withdrawals.html @@ -0,0 +1,22 @@ +
+ + + + + + + + + + + + + + + + + + + +
OfficeCandidateReasonRemoval Date
State Representative - District 11Kim Miller (Republican)
1816 Private Street
Withdrawn6/9/2026
1:02 PM
State Senator - District 2Robin Removed (Democratic)
PO Box 999
Disqualified5/19/2026
+
diff --git a/apps/scraper/src/scraper-contracts.ts b/apps/scraper/src/scraper-contracts.ts index 5e78aa8..a6bc86c 100644 --- a/apps/scraper/src/scraper-contracts.ts +++ b/apps/scraper/src/scraper-contracts.ts @@ -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 { missouriSosConfig } from "./scrapers/missouri-sos.config.js"; import { ncsbeConfig } from "./scrapers/ncsbe.config.js"; import { sccCvigConfig } from "./scrapers/scc-cvig.config.js"; import { scotusConfig } from "./scrapers/scotus.config.js"; @@ -20,6 +21,7 @@ export const scraperContracts: readonly ScraperEnvContract[] = [ sccCvigConfig, caSosStatementsConfig, ncsbeConfig, + missouriSosConfig, texasCurrentElectionConfig, texasLegislatureConfig, cedarParkCouncilConfig, diff --git a/apps/scraper/src/scrapers.ts b/apps/scraper/src/scrapers.ts index 2e3ad49..cbc3a39 100644 --- a/apps/scraper/src/scrapers.ts +++ b/apps/scraper/src/scrapers.ts @@ -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 { missouriSos } from "./scrapers/missouri-sos.js"; import { ncsbe } from "./scrapers/ncsbe.js"; import { sccCvig } from "./scrapers/scc-cvig.js"; import { scotus } from "./scrapers/scotus.js"; @@ -19,6 +20,7 @@ export const scrapers: readonly Scraper[] = [ sccCvig, caSosStatements, ncsbe, + missouriSos, texasCurrentElection, texasLegislature, cedarParkCouncil, diff --git a/apps/scraper/src/scrapers/missouri-sos-parsers.test.ts b/apps/scraper/src/scrapers/missouri-sos-parsers.test.ts new file mode 100644 index 0000000..d62f6a5 --- /dev/null +++ b/apps/scraper/src/scrapers/missouri-sos-parsers.test.ts @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { + discoverMissouriResultsUrl, + parseMissouriBallotMeasures, + parseMissouriCalendar, + parseMissouriCandidateDiscovery, + parseMissouriCandidateOffice, + parseMissouriResults, + parseMissouriWithdrawals, +} from "./missouri-sos-parsers.js"; + +const fixture = (name: string) => + readFile( + new URL(`../fixtures/missouri-sos/${name}`, import.meta.url), + "utf8", + ); + +void test("discovers the active primary and dynamic certified candidate endpoints", async () => { + const active = parseMissouriCalendar( + await fixture("calendar.html"), + new Date("2026-07-22T12:00:00Z"), + ); + assert.equal(active.type, "primary"); + assert.equal(active.electionDate, "2026-08-04"); + assert.equal(active.finalCertificationDate, "2026-05-26"); + assert.equal( + parseMissouriCalendar( + await fixture("calendar.html"), + new Date("2026-08-10T12:00:00Z"), + undefined, + "primary", + ).type, + "primary", + ); + + const discovery = parseMissouriCandidateDiscovery( + await fixture("candidate-index.html"), + ); + assert.equal(discovery.electionCode, "750006905"); + assert.equal(discovery.offices.length, 2); + assert.match(discovery.candidatesUrl, /ElectionCode=750006905/); + assert.match(discovery.withdrawalsUrl, /CandidatesRemoved/); +}); + +void test("parses ballot order, parties, districts, and excludes candidate addresses", async () => { + const candidates = parseMissouriCandidateOffice( + await fixture("candidates.html"), + "https://s1.sos.mo.gov/candidatesonweb/cumulative", + ); + assert.deepEqual( + candidates.map(({ name, party, office, district, ballotOrder }) => ({ + name, + party, + office, + district, + ballotOrder, + })), + [ + { + name: "Alice First", + party: "Republican", + office: "State Senator", + district: "2", + ballotOrder: 1, + }, + { + name: "Bob Second", + party: "Republican", + office: "State Senator", + district: "2", + ballotOrder: 2, + }, + { + name: "Carol Third", + party: "Democratic", + office: "State Senator", + district: "2", + ballotOrder: 1, + }, + { + name: "Dana Fourth", + party: "Libertarian", + office: "State Representative", + district: "11", + ballotOrder: 1, + }, + ], + ); + assert.doesNotMatch( + JSON.stringify(candidates), + /Secret|Private|Hidden|Confidential/, + ); +}); + +void test("parses withdrawals without retaining their address nodes", async () => { + const candidates = parseMissouriWithdrawals( + await fixture("withdrawals.html"), + "https://s1.sos.mo.gov/candidatesonweb/withdrawals", + ); + assert.equal(candidates[0]?.status, "withdrawn"); + assert.equal(candidates[0]?.withdrawalDate, "2026-06-09"); + assert.equal(candidates[1]?.status, "removed"); + assert.doesNotMatch(JSON.stringify(candidates), /Private Street|PO Box/); +}); + +void test("parses only certified 2026 measures with language, fiscal text, and source files", async () => { + const measures = parseMissouriBallotMeasures(await fixture("measures.html")); + assert.equal(measures.length, 2); + assert.deepEqual( + measures.map((measure) => [measure.officialTitle, measure.electionDate]), + [ + ["Amendment 1", "2026-08-04"], + ["Amendment 3", "2026-11-03"], + ], + ); + assert.match(measures[0]?.officialBallotLanguage ?? "", /Constitution/); + assert.match(measures[0]?.fairBallotLanguage ?? "", /yes/); + assert.match(measures[0]?.fiscalStatement ?? "", /costs or savings/); + assert.match(measures[0]?.certificateUrl ?? "", /certificate-amendment-1/); +}); + +void test("fails soft when current results are absent and detects deterministic updates", async () => { + const active = parseMissouriCalendar( + await fixture("calendar.html"), + new Date("2026-07-22T12:00:00Z"), + ); + assert.equal( + discoverMissouriResultsUrl( + await fixture("showmo-unavailable.html"), + active, + ), + null, + ); + assert.equal( + discoverMissouriResultsUrl( + '
August 4, 2026 Primary Election Results
', + active, + ), + "https://www.sos.mo.gov/elections/2026-primary-results", + ); + + const layout = await fixture("historical-results-layout.html"); + const first = parseMissouriResults( + layout, + "https://example.test/fixture-only", + ); + const updated = parseMissouriResults( + layout.replace("1,250", "1,300").replace("11:45 PM", "11:55 PM"), + "https://example.test/fixture-only", + ); + assert.equal(first.contests[0]?.totalVotes, 2399); + assert.equal(updated.contests[0]?.totalVotes, 2449); + assert.notEqual(first.updatedAt, updated.updatedAt); +}); diff --git a/apps/scraper/src/scrapers/missouri-sos-parsers.ts b/apps/scraper/src/scrapers/missouri-sos-parsers.ts new file mode 100644 index 0000000..716bfbb --- /dev/null +++ b/apps/scraper/src/scrapers/missouri-sos-parsers.ts @@ -0,0 +1,456 @@ +import * as cheerio from "cheerio"; + +import type { + MissouriBallotMeasure, + MissouriCandidate, + MissouriCitation, + MissouriElection, + MissouriResultContest, +} from "@acme/api/lib/missouri-election-data"; + +export const MISSOURI_STRUCTURE_VERSION = "mo-sos-current-election-v1"; +export const MISSOURI_CANDIDATES_URL = "https://s1.sos.mo.gov/candidatesonweb/"; +export const MISSOURI_MEASURES_URL = + "https://www.sos.mo.gov/petitions/2026BallotMeasures"; +export const MISSOURI_RESULTS_URL = + "https://www.sos.mo.gov/elections/showmovotes"; +export const MISSOURI_CALENDAR_URL = + "https://www.sos.mo.gov/elections/calendar/2026cal"; + +export interface MissouriOfficeReference { + office: string; + url: string; +} + +export interface MissouriCandidateDiscovery { + electionCode: string; + electionName: string; + candidatesUrl: string; + offices: MissouriOfficeReference[]; + withdrawalsUrl: string; +} + +function compact(value: string): string { + return value + .replace(/\u00a0/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function absoluteUrl(href: string, baseUrl: string): string { + return new URL(href, baseUrl).toString(); +} + +function isoDate(value: string): string | null { + const match = + /\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2}),\s*(2026)\b/i.exec( + value, + ) ?? /\b(\d{1,2})\/(\d{1,2})\/(2026)\b/.exec(value); + if (!match) return null; + if (/^\d/.test(match[1] ?? "")) { + return `${match[3]}-${String(match[1]).padStart(2, "0")}-${String( + match[2], + ).padStart(2, "0")}`; + } + const month = + [ + "january", + "february", + "march", + "april", + "may", + "june", + "july", + "august", + "september", + "october", + "november", + "december", + ].indexOf((match[1] ?? "").toLowerCase()) + 1; + return `${match[3]}-${String(month).padStart(2, "0")}-${String( + match[2], + ).padStart(2, "0")}`; +} + +function citation(label: string, sourceUrl: string): MissouriCitation { + return { label, sourceUrl }; +} + +export function parseMissouriCalendar( + html: string, + asOf = new Date(), + sourceUrl = MISSOURI_CALENDAR_URL, + preferredType?: MissouriElection["type"], +): MissouriElection { + const $ = cheerio.load(html); + const elections: MissouriElection[] = []; + $("table.cal tr").each((_index, row) => { + const cells = $(row) + .find("td") + .map((_cellIndex, cell) => compact($(cell).text())) + .get(); + if (cells.length < 6) return; + const electionDate = isoDate(cells[0] ?? ""); + const finalCertificationDate = isoDate(cells[5] ?? "") ?? undefined; + const style = cells[1] ?? ""; + const type = /primary election/i.test(style) + ? "primary" + : /general election/i.test(style) && !/municipal/i.test(style) + ? "general" + : null; + if (!electionDate || !type) return; + elections.push({ + name: `2026 ${type === "primary" ? "Primary" : "General"} Election`, + type, + electionDate, + finalCertificationDate, + citation: citation("2026 Missouri election calendar", sourceUrl), + }); + }); + if (!elections.length) { + throw new Error( + "Missouri calendar contained no 2026 primary/general election", + ); + } + const preferred = preferredType + ? elections.find((election) => election.type === preferredType) + : undefined; + if (preferred) return preferred; + const today = asOf.toISOString().slice(0, 10); + return ( + elections.find((election) => election.electionDate >= today) ?? + elections.at(-1)! + ); +} + +export function parseMissouriCandidateDiscovery( + html: string, + baseUrl = MISSOURI_CANDIDATES_URL, +): MissouriCandidateDiscovery { + const $ = cheerio.load(html); + const electionName = compact($("#main h2, #right h2").first().text()); + const references = new Map(); + let electionCode = ""; + $("a[href*='DisplayCandidatesPlacement.aspx']").each((_index, anchor) => { + const href = $(anchor).attr("href"); + const office = compact($(anchor).text()); + if (!href || !office) return; + const url = new URL(href, baseUrl); + const code = url.searchParams.get("ElectionCode"); + const officeCode = url.searchParams.get("OfficeCode"); + if (!code || !officeCode) return; + electionCode ||= code; + references.set(url.toString(), { office, url: url.toString() }); + }); + if (!electionCode || !references.size || !/2026/i.test(electionName)) { + throw new Error( + "Candidate filing page did not expose a certified 2026 election", + ); + } + return { + electionCode, + electionName, + candidatesUrl: absoluteUrl( + `DisplayCandidatesPlacement.aspx?ElectionCode=${encodeURIComponent(electionCode)}`, + baseUrl, + ), + offices: [...references.values()], + withdrawalsUrl: absoluteUrl( + `CandidatesRemoved.aspx?ElectionCode=${encodeURIComponent(electionCode)}`, + baseUrl, + ), + }; +} + +function officeParts(value: string): { office: string; district?: string } { + const normalized = compact(value); + const match = /^(.*?)\s*-?\s+District\s+(\d+)$/i.exec(normalized); + return match + ? { office: compact(match[1] ?? normalized), district: match[2] } + : { office: normalized }; +} + +export function parseMissouriCandidateOffice( + html: string, + sourceUrl: string, +): MissouriCandidate[] { + const $ = cheerio.load(html); + if (!/Certified Candidate List/i.test($("#main h1").text())) { + throw new Error("Candidate office page was not a certified candidate list"); + } + const candidates: MissouriCandidate[] = []; + $("#main table").each((_tableIndex, table) => { + const party = compact($(table).find("caption").text()); + const parsedOffice = officeParts($(table).prevAll("h3").first().text()); + if (!parsedOffice.office) return; + $(table) + .find("tr") + .slice(1) + .each((rowIndex, row) => { + // Deliberately select only the public name cell. Address, filing date, + // and every other candidate contact/location field are ignored. + const name = compact($(row).find("td.NameCol").text()); + if (!name || !party) return; + candidates.push({ + ...parsedOffice, + party, + name, + ballotOrder: rowIndex + 1, + status: "certified", + citation: citation( + "Certified candidate list in ballot order", + sourceUrl, + ), + }); + }); + }); + return candidates; +} + +export function parseMissouriWithdrawals( + html: string, + sourceUrl: string, +): MissouriCandidate[] { + const $ = cheerio.load(html); + const candidates: MissouriCandidate[] = []; + $("table[title='Removed Candidates'] tr") + .slice(1) + .each((_index, row) => { + const cells = $(row).find("td"); + if (cells.length < 4) return; + const parsedOffice = officeParts($(cells[0]).text()); + // The bold span contains only name + party; the following address node is + // intentionally never read. + const identity = compact($(cells[1]).find("span").first().text()); + const match = /^(.*?)\s*\(([^()]+)\)$/.exec(identity); + const reason = compact($(cells[2]).text()); + const dateCell = $(cells[3]).clone(); + dateCell.find("br").replaceWith(" "); + const withdrawalDate = isoDate(compact(dateCell.text())) ?? undefined; + if (!match?.[1] || !match[2] || !reason) return; + candidates.push({ + ...parsedOffice, + name: compact(match[1]), + party: compact(match[2]), + ballotOrder: candidates.length + 1, + status: /withdraw/i.test(reason) ? "withdrawn" : "removed", + withdrawalReason: reason, + withdrawalDate, + citation: citation("Certified withdrawn/removed candidates", sourceUrl), + }); + }); + return candidates; +} + +interface MeasureDraft { + officialTitle: string; + electionDate: string; + fullTextUrl?: string; + certificateUrl?: string; + officialParagraphs: string[]; + fairParagraphs: string[]; +} + +export function parseMissouriBallotMeasures( + html: string, + sourceUrl = MISSOURI_MEASURES_URL, +): MissouriBallotMeasure[] { + const $ = cheerio.load(html); + let electionDate = ""; + let mode: "official" | "fair" | null = null; + let draft: MeasureDraft | null = null; + const drafts: MeasureDraft[] = []; + + $("#main") + .find("h2, p") + .each((_index, element) => { + const tag = element.tagName.toLowerCase(); + const text = compact($(element).text()); + if (tag === "h2" && /certified/i.test(text)) { + electionDate = isoDate(text) ?? electionDate; + return; + } + if (tag !== "p" || !text) return; + const title = + /^Official Ballot Title\s*(Amendment\s+\d+|Proposition\s+\w+)/i.exec( + text, + ); + if (title?.[1]) { + draft = { + officialTitle: compact(title[1]), + electionDate, + officialParagraphs: [], + fairParagraphs: [], + }; + drafts.push(draft); + mode = null; + return; + } + if (!draft) return; + const full = $(element).find("a[title*='full text' i]").attr("href"); + if (full) draft.fullTextUrl = absoluteUrl(full, sourceUrl); + const certificate = $(element) + .find("a[title*='Certificate of Official Ballot Title' i]") + .attr("href"); + if (certificate) + draft.certificateUrl = absoluteUrl(certificate, sourceUrl); + if (/^Official Ballot Title:?$/i.test(text)) { + mode = "official"; + return; + } + if (/^Fair Ballot Language:?$/i.test(text)) { + mode = "fair"; + return; + } + if (mode === "official") draft.officialParagraphs.push(text); + if (mode === "fair") draft.fairParagraphs.push(text); + }); + + return drafts.flatMap((item) => { + const fiscalStatement = item.officialParagraphs + .filter((paragraph) => + /(?:cost|saving|revenue|fiscal|tax|financial impact)/i.test(paragraph), + ) + .at(-1); + if ( + !item.electionDate || + !item.fullTextUrl || + !item.certificateUrl || + !item.officialParagraphs.length || + !item.fairParagraphs.length || + !fiscalStatement + ) { + return []; + } + return [ + { + officialTitle: item.officialTitle, + officialBallotLanguage: item.officialParagraphs.join("\n\n"), + fairBallotLanguage: item.fairParagraphs.join("\n\n"), + fiscalStatement, + electionDate: item.electionDate, + fullTextUrl: item.fullTextUrl, + certificateUrl: item.certificateUrl, + certificationStatus: "certified" as const, + citation: citation("Certified 2026 ballot measure", sourceUrl), + }, + ]; + }); +} + +export function discoverMissouriResultsUrl( + html: string, + activeElection: MissouriElection, + baseUrl = MISSOURI_RESULTS_URL, +): string | null { + const $ = cheerio.load(html); + const dateWords = new Date( + `${activeElection.electionDate}T12:00:00Z`, + ).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }); + let found: string | null = null; + $("a[href]").each((_index, anchor) => { + if (found) return; + const href = $(anchor).attr("href"); + const text = compact($(anchor).text()); + const imageAlt = compact($(anchor).find("img").attr("alt") ?? ""); + const context = compact($(anchor).parent().text()); + const signal = `${text} ${imageAlt} ${context} ${href}`; + if (!href || !/result/i.test(signal)) return; + if (!/2026/.test(signal)) return; + if ( + !new RegExp(activeElection.type, "i").test(signal) && + !signal.includes(dateWords) + ) + return; + found = absoluteUrl(href, baseUrl); + }); + return found; +} + +function numeric(value: string): number { + const parsed = Number.parseInt(value.replace(/[^\d-]/g, ""), 10); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function parseMissouriResults( + html: string, + sourceUrl: string, +): { contests: MissouriResultContest[]; updatedAt?: string } { + const $ = cheerio.load(html); + const contests: MissouriResultContest[] = []; + $("table").each((_tableIndex, table) => { + const headers = $(table) + .find("tr") + .first() + .find("th") + .map((_index, header) => compact($(header).text()).toLowerCase()) + .get(); + const nameIndex = headers.findIndex((header) => + /candidate|choice|option/.test(header), + ); + const votesIndex = headers.findIndex((header) => + /votes|total/.test(header), + ); + if (nameIndex < 0 || votesIndex < 0) return; + const partyIndex = headers.findIndex((header) => /party/.test(header)); + const percentIndex = headers.findIndex((header) => + /percent|%/.test(header), + ); + const contest = + compact($(table).find("caption").text()) || + compact($(table).prevAll("h2, h3, h4").first().text()); + if (!contest) return; + const choices: MissouriResultContest["choices"] = []; + $(table) + .find("tr") + .slice(1) + .each((_rowIndex, row) => { + const cells = $(row).find("td"); + const name = compact($(cells[nameIndex]).text()); + if (!name) return; + const percentText = + percentIndex >= 0 ? compact($(cells[percentIndex]).text()) : ""; + const parsedPercent = percentText + ? Number.parseFloat(percentText) + : undefined; + choices.push({ + name, + party: + partyIndex >= 0 + ? compact($(cells[partyIndex]).text()) || undefined + : undefined, + votes: numeric(compact($(cells[votesIndex]).text())), + percent: + parsedPercent !== undefined && Number.isFinite(parsedPercent) + ? parsedPercent + : undefined, + }); + }); + if (!choices.length) return; + const district = /District\s+(\d+)/i.exec(contest)?.[1]; + contests.push({ + contest, + district, + choices, + totalVotes: choices.reduce((sum, choice) => sum + choice.votes, 0), + reportingStatus: + compact($(table).attr("data-reporting-status") ?? "") || undefined, + citation: citation("Official Missouri SOS election results", sourceUrl), + }); + }); + if (!contests.length) { + throw new Error( + "Official results page contained no parseable contest tables", + ); + } + const pageText = compact($("body").text()); + const updatedAt = /(?:Last Updated|Updated)\s*:?\s*([^|]+?)(?:\||$)/i.exec( + pageText, + )?.[1]; + return { contests, updatedAt: updatedAt ? compact(updatedAt) : undefined }; +} diff --git a/apps/scraper/src/scrapers/missouri-sos.config.ts b/apps/scraper/src/scrapers/missouri-sos.config.ts new file mode 100644 index 0000000..27bff38 --- /dev/null +++ b/apps/scraper/src/scrapers/missouri-sos.config.ts @@ -0,0 +1,11 @@ +import type { ScraperEnvContract } from "@acme/env"; + +export const missouriSosConfig = { + id: "missouri-sos", + name: "Missouri SOS current election cycle", + source: "Missouri Secretary of State", + environment: { + required: ["POSTGRES_URL"], + optional: ["MISSOURI_SOS_MAX_ITEMS"], + }, +} as const satisfies ScraperEnvContract; diff --git a/apps/scraper/src/scrapers/missouri-sos.ts b/apps/scraper/src/scrapers/missouri-sos.ts new file mode 100644 index 0000000..0233194 --- /dev/null +++ b/apps/scraper/src/scrapers/missouri-sos.ts @@ -0,0 +1,250 @@ +/** Current-cycle-only Missouri SOS candidates, measures, and results. */ + +import { createHash } from "node:crypto"; +import { and, eq, ne } from "drizzle-orm"; + +import type { + MissouriResults, + MissouriSnapshotData, +} from "@acme/api/lib/missouri-election-data"; +import { + MISSOURI_CURRENT_SCOPE, + MISSOURI_CYCLE_YEAR, + MISSOURI_SOS_PROVIDER, +} from "@acme/api/lib/missouri-election-data"; +import { db } from "@acme/db/client"; +import { ElectionSourceSnapshot } from "@acme/db/schema"; + +import type { Scraper } from "../utils/types.js"; +import { getItemLimit } from "../utils/concurrency.js"; +import { fetchWithRetry } from "../utils/fetch.js"; +import { createLogger } from "../utils/log.js"; +import { + discoverMissouriResultsUrl, + MISSOURI_CALENDAR_URL, + MISSOURI_CANDIDATES_URL, + MISSOURI_MEASURES_URL, + MISSOURI_RESULTS_URL, + MISSOURI_STRUCTURE_VERSION, + parseMissouriBallotMeasures, + parseMissouriCalendar, + parseMissouriCandidateDiscovery, + parseMissouriCandidateOffice, + parseMissouriResults, + parseMissouriWithdrawals, +} from "./missouri-sos-parsers.js"; +import { missouriSosConfig } from "./missouri-sos.config.js"; + +const logger = createLogger("missouri-sos"); +const USER_AGENT = + "Mozilla/5.0 (compatible; BillionCivicBot/1.0; +https://billion.app)"; + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +async function fetchOfficialHtml(url: string): Promise { + const response = await fetchWithRetry(url, { + headers: { Accept: "text/html", "User-Agent": USER_AGENT }, + timeoutMs: 45_000, + }); + return response.text(); +} + +async function upsertSnapshot( + data: MissouriSnapshotData, + diagnostics: string[], + sourceUrls: string[], + sourceVersion: string, +): Promise { + const canonicalData = JSON.stringify(data); + const contentHash = sha256(canonicalData); + const now = new Date(); + await db + .insert(ElectionSourceSnapshot) + .values({ + jurisdiction: "MO", + cycleYear: MISSOURI_CYCLE_YEAR, + provider: MISSOURI_SOS_PROVIDER, + scope: MISSOURI_CURRENT_SCOPE, + sourceVersion, + contentHash, + data: data as unknown as Record, + diagnostics, + sourceUrls, + fetchedAt: now, + }) + .onConflictDoUpdate({ + target: [ + ElectionSourceSnapshot.jurisdiction, + ElectionSourceSnapshot.cycleYear, + ElectionSourceSnapshot.provider, + ElectionSourceSnapshot.scope, + ], + set: { + sourceVersion, + contentHash, + data: data as unknown as Record, + diagnostics, + sourceUrls, + fetchedAt: now, + }, + }); + + // This invariant prevents any stale scope introduced outside this scraper + // from being mistaken for the supported current-cycle reader. + await db + .delete(ElectionSourceSnapshot) + .where( + and( + eq(ElectionSourceSnapshot.jurisdiction, "MO"), + eq(ElectionSourceSnapshot.provider, MISSOURI_SOS_PROVIDER), + eq(ElectionSourceSnapshot.scope, MISSOURI_CURRENT_SCOPE), + ne(ElectionSourceSnapshot.cycleYear, MISSOURI_CYCLE_YEAR), + ), + ); +} + +export async function scrapeMissouriSos(maxItems = 1000): Promise { + logger.info("Discovering official 2026 Missouri election sources…"); + const limit = getItemLimit(); + const [calendarHtml, candidateIndexHtml, measuresHtml, resultsEntryHtml] = + await Promise.all([ + limit(() => fetchOfficialHtml(MISSOURI_CALENDAR_URL)), + limit(() => fetchOfficialHtml(MISSOURI_CANDIDATES_URL)), + limit(() => fetchOfficialHtml(MISSOURI_MEASURES_URL)), + limit(() => fetchOfficialHtml(MISSOURI_RESULTS_URL)), + ]); + + const discovery = parseMissouriCandidateDiscovery(candidateIndexHtml); + const candidateElectionType = /general/i.test(discovery.electionName) + ? "general" + : /primary/i.test(discovery.electionName) + ? "primary" + : undefined; + const activeElection = parseMissouriCalendar( + calendarHtml, + new Date(), + MISSOURI_CALENDAR_URL, + candidateElectionType, + ); + if (!new RegExp(activeElection.type, "i").test(discovery.electionName)) { + throw new Error( + `Candidate system exposes ${discovery.electionName}, not active ${activeElection.name}`, + ); + } + + // The official cumulative endpoint contains every office and party in ballot + // order. It avoids a high-volume crawl of the individual office links. + const [candidateHtml, withdrawalHtml] = await Promise.all([ + limit(() => fetchOfficialHtml(discovery.candidatesUrl)), + limit(() => fetchOfficialHtml(discovery.withdrawalsUrl)), + ]); + const allCandidates = [ + ...parseMissouriCandidateOffice(candidateHtml, discovery.candidatesUrl), + ...parseMissouriWithdrawals(withdrawalHtml, discovery.withdrawalsUrl), + ]; + const candidates = allCandidates.slice(0, maxItems); + const ballotMeasures = parseMissouriBallotMeasures(measuresHtml); + const diagnostics: string[] = []; + if (candidates.length < allCandidates.length) { + diagnostics.push( + `MISSOURI_SOS_MAX_ITEMS limited candidates to ${candidates.length} of ${allCandidates.length}`, + ); + } + if (!ballotMeasures.length) { + throw new Error("Certified 2026 ballot-measure page contained no measures"); + } + + const resultUrl = discoverMissouriResultsUrl( + resultsEntryHtml, + activeElection, + ); + let results: MissouriResults; + if (!resultUrl) { + const diagnostic = `Official ${activeElection.name} results are not yet available from ShowMO Votes`; + diagnostics.push(diagnostic); + results = { + availability: "unavailable", + diagnostic, + citation: { + label: "ShowMO Votes results entry point", + sourceUrl: MISSOURI_RESULTS_URL, + }, + }; + logger.info(diagnostic); + } else { + try { + const resultHtml = await limit(() => fetchOfficialHtml(resultUrl)); + const parsed = parseMissouriResults(resultHtml, resultUrl); + results = { + availability: "available", + electionDate: activeElection.electionDate, + ...parsed, + citation: { + label: "Official Missouri SOS election results", + sourceUrl: resultUrl, + }, + }; + } catch (error) { + const diagnostic = `Official results link was discovered but results are not parseable yet: ${ + error instanceof Error ? error.message : String(error) + }`; + diagnostics.push(diagnostic); + results = { + availability: "unavailable", + diagnostic, + citation: { + label: "ShowMO Votes results entry point", + sourceUrl: MISSOURI_RESULTS_URL, + }, + }; + logger.warn(diagnostic); + } + } + + const sourceUrls = [ + MISSOURI_CANDIDATES_URL, + discovery.candidatesUrl, + discovery.withdrawalsUrl, + MISSOURI_MEASURES_URL, + MISSOURI_RESULTS_URL, + MISSOURI_CALENDAR_URL, + ...(resultUrl ? [resultUrl] : []), + ]; + const data: MissouriSnapshotData = { + cycleYear: MISSOURI_CYCLE_YEAR, + activeElection, + candidates, + ballotMeasures, + results, + citations: sourceUrls.map((sourceUrl) => ({ + label: "Missouri Secretary of State official source", + sourceUrl, + })), + }; + const sourceVersion = `${MISSOURI_STRUCTURE_VERSION}:${sha256( + JSON.stringify(data), + ).slice(0, 24)}`; + await upsertSnapshot( + data, + diagnostics, + [...new Set(sourceUrls)], + sourceVersion, + ); + logger.success( + `Missouri SOS: persisted ${candidates.length} candidates, ${ballotMeasures.length} measures, and ${ + results.availability === "available" + ? `${results.contests.length} result contests` + : "an unavailable-results diagnostic" + }.`, + ); +} + +export const missouriSos: Scraper = { + ...missouriSosConfig, + scrape: (options) => + scrapeMissouriSos( + (options?.maxItems ?? Number(process.env.MISSOURI_SOS_MAX_ITEMS)) || 1000, + ), +}; diff --git a/docs/README.md b/docs/README.md index e59da39..bbe3f02 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,16 +4,17 @@ Start with [CONTRIBUTING.md](../CONTRIBUTING.md) for dev setup. These docs go de ## How the system works -| Doc | What it covers | -| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| [Architecture overview](./architecture.md) | The big picture: system diagram, monorepo layout, build tooling, and why the key decisions were made | -| [Data layer](./data-layer.md) | Drizzle + Supabase Postgres, the schema (~20 tables), migrations | -| [API layer](./api.md) | The tRPC router, civic data integrations, request path, LLM provider | -| [Ballot-measure enrichment](./measure-enrichment.md) | How measure summaries are cross-validated across official sources, adapter by adapter | -| [Candidate enrichment](./candidate-enrichment.md) | The same cross-validation pattern applied to candidate bios/photos/contact info | -| [Texas current-election data](./texas-current-election.md) | Current-cycle SOS results/candidates and separately cited TLC amendment analyses | -| [Scraper pipeline](./scraper.md) | The standalone content scraper: sources, change detection, AI generation | -| [Frontend apps](./frontend.md) | Expo mobile app, Next.js web, shared UI, cross-platform auth | +| Doc | What it covers | +| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| [Architecture overview](./architecture.md) | The big picture: system diagram, monorepo layout, build tooling, and why the key decisions were made | +| [Data layer](./data-layer.md) | Drizzle + Supabase Postgres, the schema (~20 tables), migrations | +| [API layer](./api.md) | The tRPC router, civic data integrations, request path, LLM provider | +| [Ballot-measure enrichment](./measure-enrichment.md) | How measure summaries are cross-validated across official sources, adapter by adapter | +| [Candidate enrichment](./candidate-enrichment.md) | The same cross-validation pattern applied to candidate bios/photos/contact info | +| [Texas current-election data](./texas-current-election.md) | Current-cycle SOS results/candidates and separately cited TLC amendment analyses | +| [Missouri SOS election data](./missouri-sos-election-data.md) | Current-cycle certified candidates, measures, and result availability from official SOS pages | +| [Scraper pipeline](./scraper.md) | The standalone content scraper: sources, change detection, AI generation | +| [Frontend apps](./frontend.md) | Expo mobile app, Next.js web, shared UI, cross-platform auth | ## How to do things diff --git a/docs/api.md b/docs/api.md index 0025ebb..64b3409 100644 --- a/docs/api.md +++ b/docs/api.md @@ -13,17 +13,17 @@ The API is a [tRPC v11](https://trpc.io/) router in `packages/api/`, served by N The root router (`packages/api/src/root.ts`) composes **nine** sub-routers: -| Router | Procedures (Q = query, M = mutation, 🔒 = protected) | -| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `auth` | `getSession` (Q), `getSecretMessage` (Q 🔒) | -| `civic` | `getElections`, `getVoterInfo`, `getTexasCurrentElection`, `getRepresentatives`, `getRepresentativesEnriched` (all Q) — Google Civic + official current-cycle Texas data + enrichment | -| `places` | `autocomplete` (Q), `details` (M) — Google Places address autocomplete for the ballot lookup | -| `legistar` | `getLocalBills`, `getMeetings`, `getAgenda`, `getVotes`, `getBodies`, `getMeetingVotes` (all Q) — local councils | -| `openStates` | `searchBills`, `getBillDetails`, `getLegislators`, `getBillVotes` (all Q) — CA state legislature (Open States v3) | -| `content` | `getAll`, `getByType`, `getById` (all Q) — aggregates bill / government_content / court_case | -| `video` | `getInfinite` (Q) — cursor-paginated feed; converts `bytea` images to data URIs | -| `post` | `all`, `byId` (Q); `create`, `delete` (M 🔒) | -| `user` | preferences, blocked content, settings, profile, and saved-article CRUD (all 🔒) | +| Router | Procedures (Q = query, M = mutation, 🔒 = protected) | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auth` | `getSession` (Q), `getSecretMessage` (Q 🔒) | +| `civic` | `getElections`, `getVoterInfo`, `getTexasCurrentElection`, `getMissouriCurrentElection`, `getRepresentatives`, `getRepresentativesEnriched` (all Q) — Google Civic + official current-cycle state data + enrichment | +| `places` | `autocomplete` (Q), `details` (M) — Google Places address autocomplete for the ballot lookup | +| `legistar` | `getLocalBills`, `getMeetings`, `getAgenda`, `getVotes`, `getBodies`, `getMeetingVotes` (all Q) — local councils | +| `openStates` | `searchBills`, `getBillDetails`, `getLegislators`, `getBillVotes` (all Q) — CA state legislature (Open States v3) | +| `content` | `getAll`, `getByType`, `getById` (all Q) — aggregates bill / government_content / court_case | +| `video` | `getInfinite` (Q) — cursor-paginated feed; converts `bytea` images to data URIs | +| `post` | `all`, `byId` (Q); `create`, `delete` (M 🔒) | +| `user` | preferences, blocked content, settings, profile, and saved-article CRUD (all 🔒) | ## Civic Data & External Sources @@ -45,6 +45,12 @@ facts and TLC explanation are separately cited. Historical election browsing is intentionally not part of this procedure; see [Texas current-election data](./texas-current-election.md). +`civic.getMissouriCurrentElection` is an input-free reader over the validated +2026 Missouri SOS snapshot. It returns certified candidates and measures plus +either official active-election result contests or a clear unavailable +diagnostic. It accepts no historical-cycle parameter. See +[Missouri SOS election data](./missouri-sos-election-data.md). + ## LLM Provider `packages/api/src/lib/ai-provider.ts` exports a single swappable `llm` via the Vercel AI SDK: **Groq** when `GROQ_API_KEY` is set, then **OpenRouter** when `OPENROUTER_API_KEY` is present (using `OPENROUTER_MODEL`, default `deepseek/deepseek-v4-flash`), then **OpenAI `gpt-4o-mini`**, then deprecated direct **DeepSeek `deepseek-v4-flash`** during migration, and `null` otherwise. Callers treat `null` as "AI unavailable" and skip generation rather than throw. diff --git a/docs/launch.md b/docs/launch.md index 643b265..2cb14e6 100644 --- a/docs/launch.md +++ b/docs/launch.md @@ -223,6 +223,7 @@ validation consume those declarations. An `all` run validates their union. | `scotus` | `POSTGRES_URL`; one of the two AI keys | `COURTLISTENER_API_KEY`, `BFL_API_KEY`; Google image-search pair | Runs anonymously at lower CourtListener rate limits when its token is absent. | | `scc-cvig` | `POSTGRES_URL` | `GOOGLE_GENERATIVE_AI_API_KEY` | Text-layer PDF extraction still runs; Gemini is only a fallback. | | `ca-sos-statements` | `POSTGRES_URL` | None | Uses public California SOS voter-guide pages. | +| `missouri-sos` | `POSTGRES_URL` | `MISSOURI_SOS_MAX_ITEMS` | Uses keyless official SOS pages; no voter-level data. | ### Per-run source limits @@ -237,6 +238,7 @@ limit; retries and additional invocations each receive a fresh allowance. | `SCOTUS_MAX_ITEMS` | 50 | CourtListener opinion clusters | | `SCC_CVIG_MAX_ITEMS` | 10 | Santa Clara voter-guide PDFs | | `CA_SOS_MAX_ITEMS` | 9 | California SOS office pages | +| `MISSOURI_SOS_MAX_ITEMS` | 1000 | Certified 2026 Missouri candidates | | `SCRAPER_MAX_NEW_ITEMS_PER_RUN` | 10 | New records receiving AI/image work | The last setting is an enrichment budget, not a source-fetch limit. Raw records diff --git a/docs/missouri-sos-election-data.md b/docs/missouri-sos-election-data.md new file mode 100644 index 0000000..dd9d75b --- /dev/null +++ b/docs/missouri-sos-election-data.md @@ -0,0 +1,55 @@ +# Missouri SOS current-cycle election data + +`missouri-sos` ingests only the 2026 Missouri election cycle from the four +official Secretary of State entry points named in issue #186. It does not offer +historical browsing and never requests or persists voter-level data. + +```bash +pnpm --filter @acme/scraper run start missouri-sos +``` + +## Discovery and source boundaries + +- The [2026 election calendar](https://www.sos.mo.gov/elections/calendar/2026cal) + determines the active statewide primary or general election and its final + certification date. No archival result URL is constructed. +- The [certified candidate system](https://s1.sos.mo.gov/candidatesonweb/) + supplies the current election code. The scraper follows its cumulative + certified-list and withdrawn/removed-list links, which avoids crawling every + office page. Only office, district, party, candidate name, ballot order, and + withdrawal status are parsed. Address, filing-contact, and other location + cells are deliberately ignored. +- The [2026 certified ballot-measure page](https://www.sos.mo.gov/petitions/2026BallotMeasures) + supplies the election date, official title/language, fair ballot language, + fiscal statement, and links to the full text and signed certificate. Only + sections explicitly labeled certified on that page are accepted. +- The [ShowMO Votes entry point](https://www.sos.mo.gov/elections/showmovotes) + is searched for a link matching the active 2026 election. Before publication, + the snapshot is still stored with an explicit `availability: "unavailable"` + diagnostic. A historical-looking results fixture exists only to lock the + table parser; no historical URL appears in production code. + +All network calls use the shared retry/backoff client and shared runner +concurrency. The candidate system is read through its single cumulative page, +keeping request volume low. `MISSOURI_SOS_MAX_ITEMS` defaults to `1000` and may +be lowered for development runs; a truncation diagnostic is persisted whenever +the cap is reached. + +## Persistence and API + +Missouri reuses PR #181's provider-neutral `election_source_snapshot` table: + +- jurisdiction: `MO` +- cycle: `2026` +- provider: `mo-sos` +- scope: `current-2026` + +The normalized snapshot is SHA-256 hashed without fetch timestamps. Its natural +key is upserted, so unchanged runs remain one row while official corrections or +result updates replace the payload and checksum. Each record carries an +official-source citation, and the reader validates the JSON contract before +returning it. + +Consumers call the input-free public tRPC query +`civic.getMissouriCurrentElection`. The API has no year, election ID, or history +parameter, so it cannot expose a historical cycle accidentally. diff --git a/docs/scraper.md b/docs/scraper.md index 7111888..862d076 100644 --- a/docs/scraper.md +++ b/docs/scraper.md @@ -26,6 +26,7 @@ process environment at runtime, not embedded during the build. | `ca-lao-fiscal.ts` | CA LAO ballot analyses | `civic_api_cache` | proposition fiscal analyses via HTML parse | | `ca-vig-archive.ts` | CA SOS voter-guide archive | `civic_api_cache` | historical proposition guide pages via HTML parse | | `texas-current-election.ts` | Texas SOS + TLC | `election_source_snapshot` | current-cycle JSON + deterministic PDF text parsing | +| `missouri-sos.ts` | Missouri Secretary of State | `election_source_snapshot` | current-2026 certified HTML + deterministic result parsing | | `texas-legislature.ts` | Texas Legislative Council FTP | `bill` | current-session XML + bulk HTML; no site mining | | `civicengage.ts` | Cedar Park official council page | local-government tables | CivicEngage entry page + Municode embed; deterministic HTML/PDF parsing | | `durham-onbase.ts` | Durham OnBase Agenda Online | local-government tables | current-cycle meetings, items, attachments, and official actions | @@ -39,6 +40,10 @@ The Texas scraper is intentionally current-cycle only. SOS election facts and TLC explanatory text remain separate provider snapshots and separate citations; see [Texas current-election data](./texas-current-election.md). +The Missouri scraper is likewise current-cycle only and excludes voter-level +data plus candidate address/contact fields. See +[Missouri SOS election data](./missouri-sos-election-data.md). + The Cedar Park pilot is deliberately limited to City Council meetings from the latest 12 months. It stores meetings, versioned documents, agenda items, motions/outcomes, and named roll-call votes in provider-neutral tables. It does diff --git a/packages/api/package.json b/packages/api/package.json index 2da3412..0fa3748 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -55,6 +55,10 @@ "types": "./dist/lib/texas-election-data.d.ts", "default": "./src/lib/texas-election-data.ts" }, + "./lib/missouri-election-data": { + "types": "./dist/lib/missouri-election-data.d.ts", + "default": "./src/lib/missouri-election-data.ts" + }, "./lib/measure-sources/disabled/vig-archive": { "types": "./dist/lib/measure-sources/disabled/vig-archive.d.ts", "default": "./src/lib/measure-sources/disabled/vig-archive.ts" diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 9797016..10a2773 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -48,6 +48,18 @@ export { matchNcsbeName, } from "./lib/ncsbe-election-data"; +export type { + MissouriBallotMeasure, + MissouriCandidate, + MissouriCitation, + MissouriCurrentElectionData, + MissouriElection, + MissouriResultContest, + MissouriResults, + MissouriSnapshotData, +} from "./lib/missouri-election-data"; +export { getMissouriCurrentElectionData } from "./lib/missouri-election-data"; + // California Secretary of State live election-results feed export type { ElectionContestResult, diff --git a/packages/api/src/lib/missouri-election-data.test.ts b/packages/api/src/lib/missouri-election-data.test.ts new file mode 100644 index 0000000..2eddabc --- /dev/null +++ b/packages/api/src/lib/missouri-election-data.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + MISSOURI_CYCLE_YEAR, + missouriSnapshotDataSchema, +} from "./missouri-election-data"; + +const currentSnapshot = { + cycleYear: MISSOURI_CYCLE_YEAR, + activeElection: { + name: "2026 Primary Election", + type: "primary", + electionDate: "2026-08-04", + citation: { + label: "Official calendar", + sourceUrl: "https://www.sos.mo.gov/elections/calendar/2026cal", + }, + }, + candidates: [], + ballotMeasures: [], + results: { + availability: "unavailable", + diagnostic: "Official results are not yet available", + citation: { + label: "ShowMO Votes", + sourceUrl: "https://www.sos.mo.gov/elections/showmovotes", + }, + }, + citations: [], +} as const; + +void test("accepts the current-cycle unavailable-results handoff", () => { + const parsed = missouriSnapshotDataSchema.parse(currentSnapshot); + assert.equal(parsed.cycleYear, 2026); + assert.equal(parsed.results.availability, "unavailable"); +}); + +void test("rejects a historical cycle at the API boundary", () => { + assert.throws(() => + missouriSnapshotDataSchema.parse({ ...currentSnapshot, cycleYear: 2024 }), + ); +}); diff --git a/packages/api/src/lib/missouri-election-data.ts b/packages/api/src/lib/missouri-election-data.ts new file mode 100644 index 0000000..467405a --- /dev/null +++ b/packages/api/src/lib/missouri-election-data.ts @@ -0,0 +1,128 @@ +import { z } from "zod/v4"; + +import { and, desc, eq } from "@acme/db"; +import { db } from "@acme/db/client"; +import { ElectionSourceSnapshot } from "@acme/db/schema"; + +export const MISSOURI_CYCLE_YEAR = 2026; +export const MISSOURI_CURRENT_SCOPE = "current-2026"; +export const MISSOURI_SOS_PROVIDER = "mo-sos"; + +const citationSchema = z.object({ + label: z.string(), + sourceUrl: z.url(), +}); + +const electionSchema = z.object({ + name: z.string(), + type: z.enum(["primary", "general"]), + electionDate: z.iso.date(), + finalCertificationDate: z.iso.date().optional(), + citation: citationSchema, +}); + +const candidateSchema = z.object({ + office: z.string(), + district: z.string().optional(), + party: z.string(), + name: z.string(), + ballotOrder: z.int().positive(), + status: z.enum(["certified", "withdrawn", "removed"]), + withdrawalReason: z.string().optional(), + withdrawalDate: z.iso.date().optional(), + citation: citationSchema, +}); + +const measureSchema = z.object({ + officialTitle: z.string(), + officialBallotLanguage: z.string(), + fairBallotLanguage: z.string(), + fiscalStatement: z.string(), + electionDate: z.iso.date(), + fullTextUrl: z.url(), + certificateUrl: z.url(), + certificationStatus: z.literal("certified"), + citation: citationSchema, +}); + +const resultChoiceSchema = z.object({ + name: z.string(), + party: z.string().optional(), + votes: z.int().nonnegative(), + percent: z.number().min(0).max(100).optional(), +}); + +const resultContestSchema = z.object({ + contest: z.string(), + district: z.string().optional(), + choices: z.array(resultChoiceSchema), + totalVotes: z.int().nonnegative(), + reportingStatus: z.string().optional(), + citation: citationSchema, +}); + +const resultsSchema = z.discriminatedUnion("availability", [ + z.object({ + availability: z.literal("unavailable"), + diagnostic: z.string(), + citation: citationSchema, + }), + z.object({ + availability: z.literal("available"), + electionDate: z.iso.date(), + updatedAt: z.string().optional(), + contests: z.array(resultContestSchema), + citation: citationSchema, + }), +]); + +export const missouriSnapshotDataSchema = z.object({ + cycleYear: z.literal(MISSOURI_CYCLE_YEAR), + activeElection: electionSchema, + candidates: z.array(candidateSchema), + ballotMeasures: z.array(measureSchema), + results: resultsSchema, + citations: z.array(citationSchema), +}); + +export type MissouriCitation = z.infer; +export type MissouriElection = z.infer; +export type MissouriCandidate = z.infer; +export type MissouriBallotMeasure = z.infer; +export type MissouriResultContest = z.infer; +export type MissouriResults = z.infer; +export type MissouriSnapshotData = z.infer; + +export interface MissouriCurrentElectionData extends MissouriSnapshotData { + diagnostics: string[]; + sourceVersion: string; + contentHash: string; + fetchedAt: string; +} + +/** Read the sole public 2026 Missouri snapshot. No historical selector exists. */ +export async function getMissouriCurrentElectionData(): Promise { + const [row] = await db + .select() + .from(ElectionSourceSnapshot) + .where( + and( + eq(ElectionSourceSnapshot.jurisdiction, "MO"), + eq(ElectionSourceSnapshot.cycleYear, MISSOURI_CYCLE_YEAR), + eq(ElectionSourceSnapshot.provider, MISSOURI_SOS_PROVIDER), + eq(ElectionSourceSnapshot.scope, MISSOURI_CURRENT_SCOPE), + ), + ) + .orderBy(desc(ElectionSourceSnapshot.fetchedAt)) + .limit(1); + if (!row) return null; + + const parsed = missouriSnapshotDataSchema.parse(row.data); + return { + ...parsed, + diagnostics: row.diagnostics, + sourceVersion: row.sourceVersion, + contentHash: row.contentHash, + fetchedAt: row.fetchedAt.toISOString(), + }; +} diff --git a/packages/api/src/router/civic.ts b/packages/api/src/router/civic.ts index 39ffa34..9f13c64 100644 --- a/packages/api/src/router/civic.ts +++ b/packages/api/src/router/civic.ts @@ -10,6 +10,7 @@ import { getVoterInfo, } from "../lib/civic"; import { getElectedOfficials } from "../lib/elected-officials"; +import { getMissouriCurrentElectionData } from "../lib/missouri-election-data"; import { getCurrentNcElectionData } from "../lib/ncsbe-election-data"; import { getTexasCurrentElectionData } from "../lib/texas-election-data"; import { publicProcedure } from "../trpc"; @@ -63,6 +64,22 @@ export const civicRouter = { } }), + /** Official, current-cycle-only Missouri SOS election data. */ + getMissouriCurrentElection: publicProcedure.query(async () => { + try { + return await getMissouriCurrentElectionData(); + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + error instanceof Error + ? error.message + : "Failed to read current Missouri election data", + cause: error, + }); + } + }), + /** * Get live California statewide election results (Secretary of State feed). * Defaults to the marquee races (governor + secretary of state) when no diff --git a/packages/env/src/registry.ts b/packages/env/src/registry.ts index 472daf5..5af14d6 100644 --- a/packages/env/src/registry.ts +++ b/packages/env/src/registry.ts @@ -86,6 +86,11 @@ const scraperSourceLimitDefinitions = [ ["SCC_CVIG_MAX_ITEMS", "Santa Clara voter-guide PDFs per run.", "10"], ["CA_SOS_MAX_ITEMS", "California SOS office pages per run.", "9"], ["NCSBE_MAX_ITEMS", "Current-cycle NCSBE source files per run.", "4"], + [ + "MISSOURI_SOS_MAX_ITEMS", + "Certified current-cycle Missouri candidates per run.", + "1000", + ], ["TX_SOS_MAX_ITEMS", "Texas SOS election payloads per run.", "12"], [ "TEXAS_LEGISLATURE_MAX_ITEMS",