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 Day |
+ Style |
+ Registration |
+ Filing |
+ Close |
+ Final Certification |
+
+
+ | April 7, 2026 |
+ General Municipal Election Day |
+ |
+ |
+ |
+ January 27, 2026 |
+
+
+ | August 4, 2026 |
+ Primary Election |
+ |
+ |
+ |
+ May 26, 2026 |
+
+
+ | November 3, 2026 |
+ General Election |
+ |
+ |
+ |
+ August 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 @@
+
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
+
+
+ | Name |
+ Mailing Address |
+
+
+ | Alice First |
+ 101 Secret Street |
+
+
+ | Bob Second |
+ 202 Private Avenue |
+
+
+
+
+ Democratic
+
+
+ | Name |
+ Mailing Address |
+
+
+ | Carol Third |
+ 303 Hidden Road |
+
+
+
State Representative - District 11
+
+
+ Libertarian
+
+
+ | Name |
+ Mailing Address |
+
+
+ | Dana Fourth |
+ 404 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
+
+
+ | Candidate |
+ Party |
+ Total Votes |
+ Percent |
+
+
+ | Example Candidate A |
+ Republican |
+ 1,250 |
+ 52.1% |
+
+
+ | Example Candidate B |
+ Democratic |
+ 1,149 |
+ 47.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 @@
+
+
+
+ | Office |
+ Candidate |
+ Reason |
+ Removal Date |
+
+
+ | State Representative - District 11 |
+ Kim Miller (Republican) 1816 Private Street |
+ Withdrawn |
+ 6/9/2026 1:02 PM |
+
+
+ | State Senator - District 2 |
+ Robin Removed (Democratic) PO Box 999 |
+ Disqualified |
+ 5/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(
+ '',
+ 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",