From 34b31577275b7e283ab94f579cf57f16c6694042 Mon Sep 17 00:00:00 2001 From: slarson Date: Tue, 24 Mar 2026 20:24:08 -0400 Subject: [PATCH 001/106] fix: redirect logged-out users to login when clicking Follow button When a logged-out user clicks the Follow button on the Testimony Detail page, the action silently fails because uid is undefined and Firestore rejects the operation with "Missing or insufficient permissions." This adds an auth guard that redirects to /login?redirect=currentPath, matching the existing auth redirect pattern used elsewhere in the app. Closes #2059 Co-authored-with: Claude Code --- components/shared/FollowButton.tsx | 7 +++++++ components/testimony/TestimonyDetailPage/PolicyActions.tsx | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/components/shared/FollowButton.tsx b/components/shared/FollowButton.tsx index 87fa5ec81..0d227d1a9 100644 --- a/components/shared/FollowButton.tsx +++ b/components/shared/FollowButton.tsx @@ -1,5 +1,6 @@ import { StyledImage } from "components/ProfilePage/StyledProfileComponents" import { useTranslation } from "next-i18next" +import { useRouter } from "next/router" import { useEffect, useContext } from "react" import { Button } from "react-bootstrap" import { useAuth } from "../auth" @@ -22,6 +23,7 @@ export const BaseFollowButton = ({ const { user } = useAuth() const uid = user?.uid + const router = useRouter() const { followStatus, setFollowStatus } = useContext(FollowContext) @@ -52,6 +54,11 @@ export const BaseFollowButton = ({ ) : null const handleClick = (event: React.FormEvent) => { event.preventDefault() + if (!uid) { + const currentPath = router.asPath + router.push(`/login?redirect=${encodeURIComponent(currentPath)}`) + return + } isFollowing ? UnfollowClick() : FollowClick() } diff --git a/components/testimony/TestimonyDetailPage/PolicyActions.tsx b/components/testimony/TestimonyDetailPage/PolicyActions.tsx index 90d8c4d52..bd979c465 100644 --- a/components/testimony/TestimonyDetailPage/PolicyActions.tsx +++ b/components/testimony/TestimonyDetailPage/PolicyActions.tsx @@ -5,6 +5,7 @@ import { formUrl } from "components/publish" import { FC, ReactElement, useContext, useEffect } from "react" import { useCurrentTestimonyDetails } from "./testimonyDetailSlice" import { useTranslation } from "next-i18next" +import { useRouter } from "next/router" import { useAuth } from "components/auth" import { TopicQuery } from "components/shared/FollowingQueries" import { StyledImage } from "components/ProfilePage/StyledProfileComponents" @@ -39,6 +40,7 @@ export const PolicyActions: FC> = ({ const { user } = useAuth() const uid = user?.uid + const router = useRouter() const { followStatus, setFollowStatus } = useContext(FollowContext) @@ -69,6 +71,11 @@ export const PolicyActions: FC> = ({ ) : null const handleClick = (event: React.MouseEvent) => { event.preventDefault() + if (!uid) { + const currentPath = router.asPath + router.push(`/login?redirect=${encodeURIComponent(currentPath)}`) + return + } isFollowing ? UnfollowClick() : FollowClick() } From 0348b2933073b8e8940640dede8b4119a9dee130 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 4 Jun 2026 07:01:07 -0400 Subject: [PATCH 002/106] initial plan --- .gitignore | 7 + docs/lobbying-disclosure-ingestion.md | 363 ++++++++++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 docs/lobbying-disclosure-ingestion.md diff --git a/.gitignore b/.gitignore index 571150641..7301e0ec2 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,10 @@ cert.txt # local MCP server config (contains auth tokens) .mcp.json mcp-server/create-agent-key.ts + +# Claude +CLAUDE.md + +#gcloud +.gcloudignore + diff --git a/docs/lobbying-disclosure-ingestion.md b/docs/lobbying-disclosure-ingestion.md new file mode 100644 index 000000000..646cc4415 --- /dev/null +++ b/docs/lobbying-disclosure-ingestion.md @@ -0,0 +1,363 @@ +# Lobbying Disclosure Ingestion Pipeline + +## Overview + +The MA Secretary of State lobbying portal +([sec.state.ma.us/LobbyistPublicSearch](https://www.sec.state.ma.us/LobbyistPublicSearch/)) +publishes semi-annual disclosure filings for all registered lobbyists and +lobbying entities. This document describes the plan for scraping that data and +storing it in Firestore in a way that allows joining to MAPLE bill data. + +The portal has three levels of pages: + +1. **Search page** → one row per registrant per year +2. **Summary page** → registrant metadata + links to semi-annual disclosure + filings +3. **CompleteDisclosure page** → per-client compensation table + per-client bill + activity tables + +Historical data goes back to 2005. MAPLE has bill data only from ~2020 onward, +so bill joins will only resolve for filings from the 192nd General Court (2021) +and later. All historical filings are ingested regardless. + +--- + +## Terminology + +The portal has two registrant types: + +- **Lobbyist** — an individual person who lobbies directly on behalf of clients. +- **Employer** — a lobbying firm that employs individual lobbyists and is + retained by clients. Called "Lobbyist Entity" on the portal. + +In both cases, the registrant reports compensation received from each **client** +(the organization that hired them) and which bills they lobbied for that client. + +--- + +## Firestore Data Model + +Two top-level collections, normalized by registrant and by lobbying activity +record. + +### `/lobbyingRegistrants/{registrantId}` + +`registrantId` is a slugified `{entityName}_{year}` (stable, dedup-safe). + +One model covers both individual lobbyists and lobbying firms. A separate model +is not needed because the portal search returns both under the same schema, and +per-filing detail pages do not expose which individual lobbyists within a firm +worked on which bill. + +```typescript +interface LobbyingRegistrant { + registrantId: string // "{entityName}_{year}" slugified + entityName: string // firm name or individual lobbyist name (raw portal value) + entityNameNorm: string // normalized form; see Normalization section + year: number + generalCourt: number // computed from year + regType: "Lobbyist" | "Employer" + clients: LobbyingClient[] + disclosureUrls: string[] // source portal URLs, for audit trail + fetchedAt: Timestamp +} + +interface LobbyingClient { + clientName: string + clientNameNorm: string // normalized form + compensation: number | null +} +``` + +### `/lobbyingFilings/{filingId}` + +`filingId` is a slugified +`{entityName}_{clientName}_{chamber}_{activityRef}_{generalCourt}`. + +```typescript +type LobbyingChamber = + | "House Bill" + | "Senate Bill" + | "House Docket" + | "Senate Docket" + | "Executive" // lobbying of executive branch agencies + | "Other" // catch-all for rare legacy codes (FY, CMR, etc.) + +interface LobbyingFiling { + filingId: string + entityName: string // raw portal value + entityNameNorm: string // normalized form + clientName: string // raw portal value; "_total_salary_" sentinel for pre-2013 + clientNameNorm: string // normalized form + year: number + generalCourt: number + chamber: LobbyingChamber + // For legislative chambers: the bill number string (e.g. "H1234", "HD56"). + // For Executive: the agency name. Not a bill reference. + billId: string | null + activityTitle: string // bill title (legislative) or meeting description (executive) + position: string // "Support" | "Oppose" | "Neutral" | etc.; empty for executive + amount: number | null // compensation allocated to this activity + fetchedAt: Timestamp +} +``` + +### Constructing `billId` from Raw Portal Data + +The portal stores bill numbers as bare integers and records the chamber +separately. The `billId` field — which maps to `Bill.id` in MAPLE — is +constructed during ingest by combining chamber prefix and integer: + +| `chamber` | Prefix | Example raw | `billId` | +| --------------- | ------ | ----------- | -------- | +| `House Bill` | `H` | `1234` | `H1234` | +| `Senate Bill` | `S` | `1234` | `S1234` | +| `House Docket` | `HD` | `56` | `HD56` | +| `Senate Docket` | `SD` | `56` | `SD56` | +| `Executive` | — | agency name | `null` | +| `Other` | — | varies | `null` | + +Note: `H1234` and `S1234` are distinct bills even though they share the same +integer. The prefix is required to disambiguate. `billId` is `null` for +non-legislative chambers. + +#### Legacy chamber code normalization + +The portal uses short-form codes in older filings, normalized during ingest: + +| Raw value | Stored as | +| --------- | ------------- | +| `HB` | `House Bill` | +| `SB` | `Senate Bill` | + +Rare codes (`FY`, `C`, `CMR`, `HR`, etc.) are stored as `Other`. + +### Joining to Bill Data + +**The join only applies to legislative chambers** (`House Bill`, `Senate Bill`, +`House Docket`, `Senate Docket`) where `billId` is non-null. For `Executive` +and `Other`, no join should be attempted. + +```typescript +// Only valid when filing.billId !== null +db.collection(`/generalCourts/${filing.generalCourt}/bills`).doc(filing.billId) +``` + +--- + +## Entity Name Normalization + +The portal does not enforce consistent name formatting. The same client or +registrant may appear as "Acme Corp.", "ACME CORPORATION", "Acme, Inc. d/b/a +Acme Consulting", etc. across filings and years. Without normalization, +grouping by entity is unreliable. + +Both `entityName` and `clientName` are normalized using the following pipeline, +applied in order. The raw portal value is always preserved alongside the +normalized form. + +### Normalization pipeline + +1. **Uppercase** — convert the entire string to upper case. +2. **Strip d/b/a suffix** — remove everything from the first occurrence of + `D/B/A`, `D/B/A`, `DBA` (and spacing variants) onward, so the registered + name is used rather than a trade name. +3. **Hyphen → space** — replace `-` with ` ` so `LAN-TEL` and `LAN TEL` + collapse to the same key. +4. **Punctuation → space** — replace `,`, `.`, `'`, `'`, `'`, `(`, `)` with + space. Replacement with space (not empty string) prevents adjacent tokens + from concatenating (e.g. `,INC` becomes ` INC`, which is then caught by step + 5). +5. **Remove legal entity type words** — whole-word removal of: `LLC`, `LLP`, + `INC`, `INCORPORATED`, `CORPORATION`, `CORP`, `LTD`, `LIMITED`, `PC`, + `PLLC`. +6. **Remove "THE"** — whole-word removal anywhere in the string (not just as a + leading prefix). +7. **Ampersand → AND** — replace `&` with `AND`. +8. **Fix known typo** — replace `ASSICIATES` with `ASSOCIATES` (legacy portal + data). +9. **Remove professional suffix phrases** — whole-phrase removal of: `LAW +OFFICE OF`, `AND ASSOCIATES`, `& ASSOCIATES`, `AND ASSOC`, `ATTORNEY AT +LAW`, `ATTORNEY@LAW`, `ATTORNET AT LAW`, `AND PARTNERS`, `PUBLIC POLICY +GROUP`, `LEGISLATIVE SERVICES`, `POLICY GROUP`, `ASSOCIATES`, `COUNSELLORS +AT LAW`. +10. **Collapse whitespace** — replace runs of whitespace with a single space and + strip leading/trailing whitespace. + +### Usage + +`entityNameNorm` and `clientNameNorm` are stored on every document and filing. +They should be used for grouping, deduplication, and display-level matching. +Raw names are preserved for provenance and audit. + +--- + +## Deduplication and Amount Aggregation + +### Does lobbying the same bill multiple times mean we should sum amounts? + +The portal collects two semi-annual disclosure filings per registrant per year +(one for each 6-month period). In theory, a registrant could report the same +bill in both H1 and H2 filings with separate compensation amounts that should +be summed. Analysis of the actual data shows this does not occur: after +processing, zero rows share the same `(entityName, clientName, year, +generalCourt, billId, position)` — each (registrant, client, bill, year) +combination appears exactly once. The semi-annual periods report different +activity, not the same activity twice. + +The same registrant can lobby the same bill across multiple General Courts +(observed up to 6 times across years). These are stored as separate documents +per `generalCourt` and should not be summed — each court is a distinct +legislative session. + +### Null-bill row deduplication + +The one real duplication artifact in the portal data is **null-bill rows** — +entries filed when a registrant had no specific bills to report for a client in +a period. These appear in both the H1 and H2 disclosures as identical rows and +should be collapsed. During ingest, if the same `(entityName, clientName, year, +generalCourt, chamber, position)` with a null `billId` is encountered more than +once, keep the row with the highest `amount` so no spend is lost if the two +copies carry different values (in practice amounts are usually both zero). + +### Ingest strategy + +When processing multiple disclosure URLs for the same registrant+year, write +`lobbyingFilings` documents using the logical key as the document ID. A +subsequent disclosure URL that produces the same document ID will naturally +upsert (overwrite) rather than duplicate. For null-bill rows, since `billId` is +null, include `chamber` in the document ID to avoid false merges between +executive and legislative null rows. + +--- + +## Scraper Architecture + +The lobbying portal is an HTML scraper, not a REST API. It does not fit the +`createScraper` factory (which assumes list-IDs → fetch-per-ID against the MA +Legislature API). Instead, we use a custom scheduled function following the +`scrapeEvents` pattern. + +### Cloud Function: `scrapeLobbying` + +**File:** `functions/src/lobbying/scrapeLobbying.ts` + +- Schedule: `every 24 hours` +- Scrapes the current year and prior year (new filers arrive semi-annually) +- Persists a cursor in `/scrapers/lobbying`: + - `lastFetchedAt: Timestamp` + - `processedDiscUrls: string[]` — already-fetched disclosure URLs (skipped on + re-runs) +- For each new disclosure URL: + - Parse registrant + client compensation rows → upsert `lobbyingRegistrants` + doc + - Parse bill activity rows → batch-write `lobbyingFilings` docs +- Uses `axios` (existing dependency) with an iPad `User-Agent` header to match + portal expectations +- Uses `jsdom` for HTML table parsing (already a dependency; used by events scraper) +- 1s delay between requests; exponential backoff on failure (matching existing + scraper retry pattern) +- Function timeout: 540s + +### Incremental Strategy + +Processed disclosure URLs are stored in `/scrapers/lobbying.processedDiscUrls`. +At ~2 disclosure URLs per registrant × ~500 registrants per year, the +current+prior year window stays well within Firestore document limits. +Historical years beyond current-1 are stable (filings are frozen after year +closes) and are handled by the backfill script only. + +The backfill script uses a separate Firestore document +(`/scrapers/lobbyingBackfill`) for its own cursor so it does not interfere with +the live scraper. + +### Legacy Format (pre-2013) + +The portal uses a different HTML layout for filings before ~2013: total salary +is not broken down by client, and all bill activity is in a single table. These +are stored with `clientName: "_total_salary_"` so callers can detect and filter +them. No bill-level compensation amount is available for these years. + +--- + +## New Files + +``` +functions/src/lobbying/ + types.ts — Runtypes definitions for LobbyingRegistrant, LobbyingFiling + scrapeLobbying.ts — Scheduled Cloud Function + shared parsing/normalization logic + index.ts — Re-exports +``` + +--- + +## Firebase Admin Script + +**File:** `scripts/firebase-admin/backfillLobbying.ts` + +Ingests all historical filings from 2005 to the present. This is the primary +path for all data before the current and prior year. Accepts `--year` and +`--limit` CLI args for targeted re-runs or testing. Calls the same parsing +logic exported from `functions/src/lobbying/scrapeLobbying.ts` and writes +directly to Firestore via the firebase-admin SDK. + +```bash +GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ + yarn firebase-admin run-script backfillLobbying --env dev +``` + +--- + +## Firestore Rules + +Add read-only public rules alongside the existing `generalCourts` rule: + +``` +match /lobbyingRegistrants/{doc} { allow read: if true; } +match /lobbyingFilings/{doc} { allow read: if true; } +``` + +--- + +## Firestore Indexes + +Add composite indexes for common query patterns: + +| Collection | Fields | Use case | +| ----------------- | -------------------------------------- | ---------------------------------------- | +| `lobbyingFilings` | `generalCourt ASC, billId ASC` | Fetch all legislative filings for a bill | +| `lobbyingFilings` | `generalCourt ASC, chamber ASC` | Filter by chamber within a court | +| `lobbyingFilings` | `generalCourt ASC, entityNameNorm ASC` | Fetch all filings for a registrant | +| `lobbyingFilings` | `generalCourt ASC, clientNameNorm ASC` | Fetch all filings for a client | + +Note: bill-join queries should always filter on `chamber` (or check +`billId !== null`) to exclude `Executive` and `Other` rows before treating +`billId` as a MAPLE bill reference. + +--- + +## Function Export + +Add to `functions/src/index.ts`: + +```typescript +export { scrapeLobbying } from "./lobbying" +``` + +--- + +## Design Decisions + +| Decision | Choice | Rationale | +| --------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Collection placement | Top-level `/lobbyingRegistrants`, `/lobbyingFilings` | Lobbying data spans multiple General Courts and is not scoped to a single court like bills/members | +| Single registrant model | One type, `regType: "Lobbyist" \| "Employer"` | Individual lobbyists and firms share the same portal schema; per-bill individual attribution is not available | +| `billId` construction | `{chamberPrefix}{billNumber}` at ingest time | Raw portal data stores chamber and integer separately; the composite is what matches MAPLE's `Bill.id` | +| `billId` null for Executive | `null` instead of agency name | Prevents accidental bill lookups; makes join guard explicit at the type level | +| Normalized name fields | Store both raw and `*Norm` fields | Raw names preserved for provenance; normalized names used for grouping and matching | +| HTML parser | `jsdom` | Already in `functions/package.json` (used by events scraper); no need to add cheerio | +| Live scraper cursor | Array in `/scrapers/lobbying` doc | ~1,000 URLs/year fits well within the 1 MB Firestore doc limit; simple and atomic with other scraper state | +| Backfill cursor | Firestore subcollection `/scrapers/lobbyingBackfill/processedUrls/{urlHash}` | Full 2005-present history (~50,000 URLs) would exceed the 1 MB doc limit; subcollection scales without bound and is durable, inspectable, and resumable from any machine | +| Incremental strategy | Skip already-processed disclosure URLs; write docs by logical key (upsert) | Survives function restarts and re-runs without re-fetching already-scraped pages; natural upsert prevents duplicates without an explicit dedup pass | +| Legacy format (pre-2013) | Store with `clientName: "_total_salary_"` sentinel | Preserves data completeness; callers can filter on this value | +| Historical data | Admin backfill script (2005 → present) | Full history is ingested once; Cloud Function maintains current+prior year going forward | From 774568e3371a20e09abf58a3a87ff8d588052b61 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 4 Jun 2026 08:38:21 -0400 Subject: [PATCH 003/106] feat: add lobbying disclosure ingestion pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrapes the MA Secretary of State lobbying portal (sec.state.ma.us/LobbyistPublicSearch) and writes structured data to Firestore for joining with MAPLE bill data. New collections: - /lobbyingRegistrants — one doc per (registrant, year), regType Lobbyist|Employer - /lobbyingFilings — one doc per (registrant, client, bill, court), with billId null for Executive/Other chambers so the join guard is type-level Key design points: - billId is constructed as {chamberPrefix}{integer} (e.g. H1234, SD56) to match Bill.id in the existing bills collection; raw integer + chamber stored separately - Entity name normalization pipeline ported from reference implementation (10 steps: d/b/a stripping, legal entity words, punctuation, THE, ampersand, typo fix, etc.) - Both raw and *Norm name fields stored for provenance and grouping - Live Cloud Function scrapes current+prior year on a 24h schedule with a summaryDiscCache to avoid re-fetching summary pages in steady state - Backfill admin script handles full 2005-present history with a Firestore subcollection cursor (/scrapers/lobbyingBackfill/processedUrls) that scales to ~50k URLs and is safely resumable Files: - functions/src/lobbying/{types,normalize,portal,scrapeLobbying,index}.ts - scripts/firebase-admin/backfillLobbying.ts - firestore.rules + firestore.indexes.json updated - docs/lobbying-disclosure-ingestion.md: full plan, test plan, future work Co-Authored-By: Claude Sonnet 4.6 --- docs/lobbying-disclosure-ingestion.md | 240 ++++++++++ firestore.indexes.json | 91 +++- firestore.rules | 8 + functions/src/index.ts | 2 + functions/src/lobbying/index.ts | 12 + functions/src/lobbying/normalize.ts | 73 +++ functions/src/lobbying/portal.ts | 491 +++++++++++++++++++++ functions/src/lobbying/scrapeLobbying.ts | 274 ++++++++++++ functions/src/lobbying/types.ts | 101 +++++ scripts/firebase-admin/backfillLobbying.ts | 156 +++++++ 10 files changed, 1441 insertions(+), 7 deletions(-) create mode 100644 functions/src/lobbying/index.ts create mode 100644 functions/src/lobbying/normalize.ts create mode 100644 functions/src/lobbying/portal.ts create mode 100644 functions/src/lobbying/scrapeLobbying.ts create mode 100644 functions/src/lobbying/types.ts create mode 100644 scripts/firebase-admin/backfillLobbying.ts diff --git a/docs/lobbying-disclosure-ingestion.md b/docs/lobbying-disclosure-ingestion.md index 646cc4415..ad67fe397 100644 --- a/docs/lobbying-disclosure-ingestion.md +++ b/docs/lobbying-disclosure-ingestion.md @@ -346,6 +346,246 @@ export { scrapeLobbying } from "./lobbying" --- +## Implementation Status + +| File | Status | +| -------------------------------------------- | ------- | +| `functions/src/lobbying/types.ts` | ✅ Done | +| `functions/src/lobbying/normalize.ts` | ✅ Done | +| `functions/src/lobbying/portal.ts` | ✅ Done | +| `functions/src/lobbying/scrapeLobbying.ts` | ✅ Done | +| `functions/src/lobbying/index.ts` | ✅ Done | +| `scripts/firebase-admin/backfillLobbying.ts` | ✅ Done | +| `functions/src/index.ts` (export) | ✅ Done | +| `firestore.rules` | ✅ Done | +| `firestore.indexes.json` | ✅ Done | + +### Document ID scheme + +Both `registrantId` and `filingId` are SHA-256 hashes (first 40 hex chars) of +their respective logical keys. Hashes are used rather than slugified strings +because entity names and client names contain arbitrary Unicode and punctuation +that would require aggressive sanitization to fit Firestore ID constraints. The +hash is stable across runs for the same logical record. + +--- + +## Future Work (Subsequent PRs) + +### Frontend + +- **Dedicated lobbying pages** + + - `/lobbyists` index: searchable list of registrants with total compensation, + client count, and year filter + - `/lobbyists/{registrantId}` profile: full client list, all bills lobbied, + compensation over time + - `/clients/{clientNameNorm}` profile: registrants hired, bills lobbied, + total spend per year + +- **Bill page integration** (`/bills/{court}/{billId}`) + + - "Lobbying activity" section listing registrants + clients that lobbied this + bill, with position (Support / Oppose / Neutral) and compensation where + available + - Link to registrant profile pages + +- **Organization profile page integration** + - If an organization's normalized name matches a `clientNameNorm` in + `lobbyingFilings`, surface a "Lobbying history" panel showing which bills + they lobbied and which registrants they hired + +### MCP Tools + +Expose lobbying data via the MAPLE MCP server so that AI agents and Claude can +answer questions like "who lobbied bill H1234?" or "what did Acme Corp lobby +for in 2024?". + +- **`get_lobbying_filings_for_bill`** — given `generalCourt` + `billId`, return + all `lobbyingFilings` for that bill with registrant, client, position, and + amount +- **`get_lobbying_registrant`** — given `registrantId`, return the registrant + document with client list and disclosure URLs +- **`search_lobbying_by_client`** — given a client name (raw or normalized), + return matching filings across all courts +- **`get_lobbying_summary_for_bill`** — aggregate view: unique registrant count, + unique client count, total compensation (where non-null), position breakdown + +--- + +## Incremental Test Plan + +Testing proceeds from the inside out: unit logic first, then live portal +fetches against the real site, then a small Firestore write, then a full +backfill year, then steady-state function operation. + +### Step 1 — Unit test: normalization + +Run the normalization pipeline against known inputs and verify the outputs match +the reference implementation. + +```bash +# In a Node REPL or ts-node session: +conda run -n maple-2025 ts-node -P tsconfig.script.json -e " +const { normalizeEntityName } = require('./functions/src/lobbying/normalize') +console.log(normalizeEntityName('Acme Corp., Inc. d/b/a Acme Consulting')) +// Expected: 'ACME' +console.log(normalizeEntityName('LAN-TEL COMMUNICATIONS, INC.')) +// Expected: 'LAN TEL COMMUNICATIONS' +console.log(normalizeEntityName('Law Office of Jane Smith, LLC')) +// Expected: 'JANE SMITH' +" +``` + +### Step 2 — Unit test: chamber normalization and billId construction + +```bash +conda run -n maple-2025 ts-node -P tsconfig.script.json -e " +const { normalizeChamber, constructBillId } = require('./functions/src/lobbying/portal') +console.log(normalizeChamber('HB')) // House Bill +console.log(normalizeChamber('SB')) // Senate Bill +console.log(normalizeChamber('Executive')) // Executive +console.log(normalizeChamber('FY2024')) // Other +console.log(constructBillId('House Bill', '1234')) // H1234 +console.log(constructBillId('Senate Bill', '567')) // S567 +console.log(constructBillId('House Docket', '89')) // HD89 +console.log(constructBillId('Executive', 'EOEEA')) // null +" +``` + +### Step 3 — Live portal fetch: summary links + +Verify the portal is reachable and returns results for the current year. Use +`--limit 1` to minimize requests. + +```bash +conda run -n maple-2025 ts-node -P tsconfig.script.json -e " +const { makePortalClient, fetchSummaryLinks } = require('./functions/src/lobbying/portal') +const client = makePortalClient() +fetchSummaryLinks(client, 2024).then(urls => { + console.log('Summary links for 2024:', urls.length) + console.log('First URL:', urls[0]) +}).catch(console.error) +" +``` + +Expected: ~400–600 URLs, each containing `Summary.aspx`. + +### Step 4 — Live portal fetch: summary meta + one disclosure + +Pick the first summary URL from Step 3 and fetch its meta and first disclosure. + +```bash +conda run -n maple-2025 ts-node -P tsconfig.script.json -e " +const { makePortalClient, fetchSummaryLinks, fetchDisclosureMeta, fetchDisclosureDetail } = require('./functions/src/lobbying/portal') +async function main() { + const client = makePortalClient() + const [summaryUrl] = await fetchSummaryLinks(client, 2024) + const meta = await fetchDisclosureMeta(client, summaryUrl) + console.log('Meta:', JSON.stringify(meta, null, 2)) + if (meta.disclosureUrls[0]) { + const detail = await fetchDisclosureDetail(client, meta.disclosureUrls[0], 2024) + console.log('Compensation rows:', detail.compensation.length) + console.log('Bill rows:', detail.bills.length) + console.log('First bill:', detail.bills[0]) + } +} +main().catch(console.error) +" +``` + +Verify: `meta.entityName` is non-empty, `meta.regType` is `"Lobbyist"` or +`"Employer"`, bill rows have `billId` set correctly for legislative chambers. + +### Step 5 — Backfill: single year, small limit against dev Firestore + +Write a small batch to the dev Firestore emulator or dev project. + +```bash +# Against local emulator: +conda run -n maple-2025 yarn firebase-admin run-script backfillLobbying \ + --env local -- --year 2024 --limit 3 + +# Against dev project (writes real Firestore): +GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ + conda run -n maple-2025 yarn firebase-admin run-script backfillLobbying \ + --env dev -- --year 2024 --limit 3 +``` + +Verify in Firestore console or emulator UI: + +- `lobbyingRegistrants` has 3 documents with `entityName`, `entityNameNorm`, + `regType`, `clients`, `generalCourt` +- `lobbyingFilings` has documents with `billId` non-null for legislative rows + and null for Executive rows +- `/scrapers/lobbyingBackfill/processedUrls` has entries with `url` and + `processedAt` fields +- Re-running the same command skips already-processed URLs (output shows 0 new + disclosures) + +### Step 6 — Spot-check: bill join + +Pick a `lobbyingFiling` document with a non-null `billId` and a `generalCourt` +≥ 192. Verify the bill exists in MAPLE: + +``` +/generalCourts/{filing.generalCourt}/bills/{filing.billId} +``` + +If the bill is found, the join key is correct. If not found, check: (a) whether +MAPLE has data for that court, (b) whether the bill number format matches +(prefix + integer, no leading zeros). + +### Step 7 — Backfill: full current year + +Once Step 5 passes, run without `--limit` for the current year: + +```bash +GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ + conda run -n maple-2025 yarn firebase-admin run-script backfillLobbying \ + --env dev -- --year 2024 +``` + +Monitor progress via console output. Expected: ~500–600 registrants, ~1,000 +disclosure pages, several thousand filing documents written. + +### Step 8 — Backfill: full history (2005–present) + +Run without `--year` to process all years. Can be interrupted and resumed: + +```bash +GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ + conda run -n maple-2025 yarn firebase-admin run-script backfillLobbying \ + --env dev +``` + +Expected runtime: several hours at 1s/request. The subcollection cursor at +`/scrapers/lobbyingBackfill/processedUrls` allows safe interruption and +resumption. + +### Step 9 — Deploy and verify Cloud Function + +Deploy the function to the dev project: + +```bash +conda run -n maple-2025 firebase deploy \ + --only functions:maple:scrapeLobbying \ + --project digital-testimony-dev +``` + +Trigger a manual run via the Firebase console or: + +```bash +conda run -n maple-2025 yarn firebase-admin run-script runScrapers \ + --env local --targets scrapeLobbying +``` + +Verify: Cloud Function logs show the expected number of new disclosures (should +be near zero if backfill completed, since current+prior year are already +processed). + +--- + ## Design Decisions | Decision | Choice | Rationale | diff --git a/firestore.indexes.json b/firestore.indexes.json index 83cb3fa6d..c267a6868 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -788,25 +788,46 @@ "collectionGroup": "ballotQuestions", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "electionYear", "order": "ASCENDING" }, - { "fieldPath": "ballotStatus", "order": "ASCENDING" } + { + "fieldPath": "electionYear", + "order": "ASCENDING" + }, + { + "fieldPath": "ballotStatus", + "order": "ASCENDING" + } ] }, { "collectionGroup": "publishedTestimony", "queryScope": "COLLECTION_GROUP", "fields": [ - { "fieldPath": "ballotQuestionId", "order": "ASCENDING" }, - { "fieldPath": "publishedAt", "order": "DESCENDING" } + { + "fieldPath": "ballotQuestionId", + "order": "ASCENDING" + }, + { + "fieldPath": "publishedAt", + "order": "DESCENDING" + } ] }, { "collectionGroup": "publishedTestimony", "queryScope": "COLLECTION", "fields": [ - { "fieldPath": "billId", "order": "ASCENDING" }, - { "fieldPath": "court", "order": "ASCENDING" }, - { "fieldPath": "ballotQuestionId", "order": "ASCENDING" } + { + "fieldPath": "billId", + "order": "ASCENDING" + }, + { + "fieldPath": "court", + "order": "ASCENDING" + }, + { + "fieldPath": "ballotQuestionId", + "order": "ASCENDING" + } ] }, { @@ -898,6 +919,62 @@ } } ] + }, + { + "collectionGroup": "lobbyingFilings", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "generalCourt", + "order": "ASCENDING" + }, + { + "fieldPath": "billId", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "lobbyingFilings", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "generalCourt", + "order": "ASCENDING" + }, + { + "fieldPath": "chamber", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "lobbyingFilings", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "generalCourt", + "order": "ASCENDING" + }, + { + "fieldPath": "entityNameNorm", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "lobbyingFilings", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "generalCourt", + "order": "ASCENDING" + }, + { + "fieldPath": "clientNameNorm", + "order": "ASCENDING" + } + ] } ], "fieldOverrides": [ diff --git a/firestore.rules b/firestore.rules index a95586279..42db67276 100644 --- a/firestore.rules +++ b/firestore.rules @@ -103,6 +103,14 @@ service cloud.firestore { allow read: if true; allow write: if false; } + match /lobbyingRegistrants/{id} { + allow read: if true; + allow write: if false; + } + match /lobbyingFilings/{id} { + allow read: if true; + allow write: if false; + } match /transcriptions/{tid} { // public, read-only allow read: if true diff --git a/functions/src/index.ts b/functions/src/index.ts index 641255bf4..6c52b78c1 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -60,6 +60,8 @@ export { export { transcription } from "./webhooks" +export { scrapeLobbying } from "./lobbying" + export * from "./triggerPubsubFunction" // Export the health check last so it is loaded last. diff --git a/functions/src/lobbying/index.ts b/functions/src/lobbying/index.ts new file mode 100644 index 000000000..5e594cb34 --- /dev/null +++ b/functions/src/lobbying/index.ts @@ -0,0 +1,12 @@ +export { scrapeLobbying } from "./scrapeLobbying" +export * from "./types" +export { normalizeEntityName } from "./normalize" +export { + constructBillId, + fetchDisclosureDetail, + fetchDisclosureMeta, + fetchSummaryLinks, + makePortalClient, + normalizeChamber, + yearToGeneralCourt +} from "./portal" diff --git a/functions/src/lobbying/normalize.ts b/functions/src/lobbying/normalize.ts new file mode 100644 index 000000000..8d3d0a0ba --- /dev/null +++ b/functions/src/lobbying/normalize.ts @@ -0,0 +1,73 @@ +/** + * Entity name normalization pipeline. + * + * The SoS portal does not enforce consistent name formatting. The same client or + * registrant may appear as "Acme Corp.", "ACME CORPORATION", "Acme, Inc. d/b/a + * Acme Consulting", etc. across filings and years. + * + * This pipeline is a direct port of the reference implementation used in the + * companion data analysis project. The steps must be applied in the exact order + * listed here; changing the order produces different (incorrect) output. + */ + +// Step 2: strip d/b/a trade-name suffix before any other transforms so the +// trade name doesn't bleed into the canonical form. +const DBA_RE = /\s+D\s*\/+B\s*\/+A?\s+.*|\s+DBA\s+.*/i + +// Step 5: remove legal entity type words with whole-word matching so +// "INCORPORATED" and "CORP" are caught in addition to "LLC"/"INC". +const LEGAL_ENTITY_RE = + /\b(LLC|LLP|INC|INCORPORATED|CORPORATION|CORP|LTD|LIMITED|PC|PLLC)\b/g + +// Step 6: remove "THE" as a whole word anywhere (not just as a leading prefix). +const THE_RE = /\bTHE\b/g + +// Step 9: professional suffix phrases to remove wholesale. +const MISC_PHRASES = [ + "LAW OFFICE OF", + "AND ASSOCIATES", + "& ASSOCIATES", + "AND ASSOC", + "ATTORNEY AT LAW", + "ATTORNEY@LAW", + "ATTORNET AT LAW", // known portal typo + "AND PARTNERS", + "PUBLIC POLICY GROUP", + "LEGISLATIVE SERVICES", + "POLICY GROUP", + "ASSOCIATES", + "COUNSELLORS AT LAW" +] + +export function normalizeEntityName(raw: string | null | undefined): string { + if (!raw) return "" + + let x = raw.toUpperCase() // Step 1: uppercase + + x = x.replace(DBA_RE, "") // Step 2: strip d/b/a suffix + + x = x.replace(/-/g, " ") // Step 3: hyphen → space + + // Step 4: punctuation → space (not empty string, so ",INC" → " INC" → caught + // by step 5's whole-word removal). + for (const ch of [",", ".", "'", "‘", "’", "(", ")"]) { + x = x.split(ch).join(" ") + } + + x = x.replace(LEGAL_ENTITY_RE, " ") // Step 5: remove legal entity type words + + x = x.replace(THE_RE, " ") // Step 6: remove THE anywhere + + x = x.replace(/&/g, "AND") // Step 7: ampersand → AND + + x = x.replace("ASSICIATES", "ASSOCIATES") // Step 8: fix known portal typo + + // Step 9: remove professional suffix phrases + for (const phrase of MISC_PHRASES) { + x = x.split(phrase).join(" ") + } + + x = x.replace(/\s+/g, " ").trim() // Step 10: collapse whitespace + + return x +} diff --git a/functions/src/lobbying/portal.ts b/functions/src/lobbying/portal.ts new file mode 100644 index 000000000..e441522b8 --- /dev/null +++ b/functions/src/lobbying/portal.ts @@ -0,0 +1,491 @@ +/** + * HTTP client and HTML parser for the MA Secretary of State lobbying portal. + * + * Portal: https://www.sec.state.ma.us/LobbyistPublicSearch/ + * + * Page flow: + * 1. Search POST → grdvSearchResultByTypeAndCategory table + * One row per registrant; each row has a Summary.aspx link. + * 2. Summary.aspx → registrant name/year/type + CompleteDisclosure links + * 3. CompleteDisclosure.aspx → per-client compensation + per-client bill activity + * + * Two disclosure HTML formats exist: + * Modern (≥~2013): per-client compensation in grdvClientPaidToEntity; + * per-client bill tables as grdvActivitiesNew{year}_{n}. + * Legacy (<~2013): total salary in grdvSalaryPaid (no client breakdown); + * all bill activity in a single grdvActivities table. + */ + +import axios, { AxiosInstance } from "axios" +import { JSDOM } from "jsdom" +import { sha256 } from "js-sha256" +import { + CHAMBER_PREFIXES, + LEGACY_CHAMBER_MAP, + LEGACY_TOTAL_CLIENT, + LobbyingChamber +} from "./types" + +// ─── Constants ────────────────────────────────────────────────────────────── + +const BASE_URL = "https://www.sec.state.ma.us/LobbyistPublicSearch/" +const SEARCH_URL = BASE_URL + "Default.aspx" +const REQUEST_DELAY_MS = 1000 +const MAX_RETRIES = 5 + +const IPAD_UA = + "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) " + + "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148" + +const FIRST_GC = 183 +const FIRST_GC_START_YEAR = 2003 + +// ─── Public types ─────────────────────────────────────────────────────────── + +export interface RawCompensation { + clientName: string + amount: number | null +} + +export interface RawBillActivity { + clientName: string + chamber: LobbyingChamber + rawBillNumber: string + billId: string | null // pre-computed from chamber + rawBillNumber + activityTitle: string + position: string + amount: number | null +} + +export interface DisclosureMeta { + entityName: string + year: number | null + /** Portal reg_type mapped to our vocabulary */ + regType: "Lobbyist" | "Employer" + disclosureUrls: string[] +} + +export interface DisclosureDetail { + compensation: RawCompensation[] + bills: RawBillActivity[] +} + +// ─── HTTP helpers ──────────────────────────────────────────────────────────── + +export function makePortalClient(): AxiosInstance { + return axios.create({ + headers: { "User-Agent": IPAD_UA }, + timeout: 60_000 + }) +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +async function getHtml( + client: AxiosInstance, + url: string, + retries = MAX_RETRIES +): Promise { + for (let attempt = 0; attempt < retries; attempt++) { + await sleep( + attempt === 0 ? REQUEST_DELAY_MS : REQUEST_DELAY_MS * 2 ** attempt + ) + try { + const res = await client.get(url, { + responseType: "text", + headers: { Accept: "text/html" } + }) + return new JSDOM(res.data).window.document + } catch (e) { + if (attempt === retries - 1) throw e + if (axios.isAxiosError(e)) continue + throw e + } + } + throw new Error("unreachable") +} + +async function postHtml( + client: AxiosInstance, + url: string, + data: Record, + retries = MAX_RETRIES +): Promise { + const body = new URLSearchParams(data).toString() + for (let attempt = 0; attempt < retries; attempt++) { + await sleep( + attempt === 0 ? REQUEST_DELAY_MS : REQUEST_DELAY_MS * 2 ** attempt + ) + try { + const res = await client.post(url, body, { + responseType: "text", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "text/html" + }, + timeout: 180_000 + }) + return new JSDOM(res.data).window.document + } catch (e) { + if (attempt === retries - 1) throw e + if (axios.isAxiosError(e)) continue + throw e + } + } + throw new Error("unreachable") +} + +// ─── Year / General Court helpers ──────────────────────────────────────────── + +export function yearToGeneralCourt(year: number): number { + return FIRST_GC + Math.floor((year - FIRST_GC_START_YEAR) / 2) +} + +// ─── Chamber normalization ──────────────────────────────────────────────────── + +/** Normalize raw portal chamber string to a canonical LobbyingChamber value. */ +export function normalizeChamber(raw: string): LobbyingChamber { + const trimmed = raw.trim() + if (LEGACY_CHAMBER_MAP[trimmed]) return LEGACY_CHAMBER_MAP[trimmed] + const known: LobbyingChamber[] = [ + "House Bill", + "Senate Bill", + "House Docket", + "Senate Docket", + "Executive" + ] + if (known.includes(trimmed as LobbyingChamber)) + return trimmed as LobbyingChamber + return "Other" +} + +/** + * Construct the MAPLE-compatible billId from the portal's chamber + raw integer. + * + * The portal stores bill numbers as bare integers; the chamber prefix is what + * distinguishes H1234 from S1234. Returns null for Executive and Other chambers + * where no bill join is possible. + */ +export function constructBillId( + chamber: LobbyingChamber, + rawBillNumber: string +): string | null { + const prefix = CHAMBER_PREFIXES[chamber] + if (!prefix) return null + const n = parseInt(rawBillNumber, 10) + if (isNaN(n)) return null + return `${prefix}${n}` +} + +// ─── Document ID generation ─────────────────────────────────────────────────── + +/** Stable Firestore document ID for a registrant (entity + year). */ +export function registrantId(entityName: string, year: number): string { + return sha256(`${year}|${entityName}`).slice(0, 40) +} + +/** + * Stable Firestore document ID for a filing. + * + * Uses a hash of the logical deduplication key. For null-bill rows (billId is + * null) the chamber is included in the key to avoid merging executive null rows + * with legislative null rows. + */ +export function filingId( + entityName: string, + clientName: string, + chamber: LobbyingChamber, + billId: string | null, + generalCourt: number, + position: string +): string { + const key = [ + entityName, + clientName, + chamber, + billId ?? "__null__", + generalCourt, + position + ].join("|") + return sha256(key).slice(0, 40) +} + +// ─── Amount parsing ─────────────────────────────────────────────────────────── + +function parseAmount(text: string): number | null { + const cleaned = text.replace(/[$,]/g, "").trim() + const n = parseFloat(cleaned) + return isNaN(n) ? null : n +} + +// ─── Portal scraping functions ──────────────────────────────────────────────── + +/** Extract ASP.NET WebForms ViewState hidden inputs from a page. */ +function extractViewState(doc: Document): Record { + const fields: Record = {} + doc.querySelectorAll('input[type="hidden"]').forEach(el => { + const input = el as HTMLInputElement + if (input.name) fields[input.name] = input.value ?? "" + }) + return fields +} + +/** + * Fetch all Summary.aspx URLs for a given year. + * Sends a single search POST with page size 20000 to get all registrants at once. + */ +export async function fetchSummaryLinks( + client: AxiosInstance, + year: number +): Promise { + const searchPage = await getHtml(client, SEARCH_URL) + const vs = extractViewState(searchPage) + + const postData: Record = { + ...vs, + __EVENTTARGET: "", + __EVENTARGUMENT: "", + ctl00$ContentPlaceHolder1$Search: "rdbSearchByType", + ctl00$ContentPlaceHolder1$ucSearchCriteriaByType$ddlYear: String(year), + ctl00$ContentPlaceHolder1$ucSearchCriteriaByType$txtN_ame: "", + ctl00$ContentPlaceHolder1$ucSearchCriteriaByType$lddSearchType$DropDown: + "3", + ctl00$ContentPlaceHolder1$ucSearchCriteriaByType$drpType: "L", + ctl00$ContentPlaceHolder1$drpPageSize: "20000", + ctl00$ContentPlaceHolder1$btnSearch: "Search" + } + + const resultsPage = await postHtml(client, SEARCH_URL, postData) + + const table = resultsPage.querySelector( + '[id*="grdvSearchResultByTypeAndCategory"]' + ) + if (!table) return [] + + const links: string[] = [] + table.querySelectorAll("a[href]").forEach(el => { + const href = (el as HTMLAnchorElement).href + if (href && href.includes("Summary.aspx")) { + // href from JSDOM is already absolute when base is set; handle both cases + const url = href.startsWith("http") ? href : BASE_URL + href + links.push(url) + } + }) + return links +} + +/** + * Fetch a Summary.aspx page and return the registrant metadata + disclosure URLs. + */ +export async function fetchDisclosureMeta( + client: AxiosInstance, + summaryUrl: string +): Promise { + const doc = await getHtml(client, summaryUrl) + + const text = (id: string) => { + const el = doc.getElementById(id) + return el?.textContent?.trim() ?? "" + } + + const entityName = text("ContentPlaceHolder1_lblRegistrantName") + const yearText = text("ContentPlaceHolder1_lblYear") + const regTypeRaw = text("ContentPlaceHolder1_lblRegType") + + const year = parseInt(yearText, 10) + const regType: "Lobbyist" | "Employer" = regTypeRaw.includes("Entity") + ? "Employer" + : "Lobbyist" + + const disclosureUrls: string[] = [] + doc.querySelectorAll("a[href]").forEach(el => { + const raw = (el as HTMLAnchorElement).getAttribute("href") ?? "" + if (raw.includes("CompleteDisclosure")) { + const url = raw.startsWith("http") ? raw : BASE_URL + raw + disclosureUrls.push(url) + } + }) + + return { + entityName, + year: isNaN(year) ? null : year, + regType, + disclosureUrls + } +} + +/** + * Parse a CompleteDisclosure.aspx page. + * + * Handles both modern (≥~2013) and legacy (<~2013) HTML layouts. + */ +export async function fetchDisclosureDetail( + client: AxiosInstance, + discUrl: string, + year: number +): Promise { + const doc = await getHtml(client, discUrl) + const compensation: RawCompensation[] = [] + const bills: RawBillActivity[] = [] + + // ── Modern format ────────────────────────────────────────────────────────── + const compTable = doc.querySelector('[id*="grdvClientPaidToEntity"]') + if (compTable) { + compTable + .querySelectorAll("tr.GridRow, tr.GridAlternatingRow") + .forEach(row => { + const cells = Array.from(row.querySelectorAll("td")).map( + td => td.textContent?.trim() ?? "" + ) + if (cells.length >= 2) { + compensation.push({ + clientName: cells[0], + amount: parseAmount(cells[1]) + }) + } + }) + } + + // Bill activity tables — one per client per reporting period. Two ID patterns: + // 2014–2018: …rptActivityNew_grdvActivitiesNew_0 (no year suffix) + // 2019+: …rptActivityNew2020_grdvActivitiesNew2020_0 (year suffix) + doc.querySelectorAll('[id*="grdvActivitiesNew"]').forEach(actTable => { + // The client name lives in the nearest preceding span with lblClientName + let clientName = "" + let node: Element | null = actTable + while ((node = node.previousElementSibling ?? node.parentElement)) { + const span = node.id?.includes("lblClientName") + ? node + : node.querySelector?.('[id*="lblClientName"]') + if (span) { + clientName = span.textContent?.trim() ?? "" + break + } + if (node === node.parentElement) break + } + + actTable + .querySelectorAll("tr.GridRow, tr.GridAlternatingRow") + .forEach(row => { + const cells = Array.from(row.querySelectorAll("td")).map( + td => td.textContent?.trim() ?? "" + ) + // Columns: House/Senate, Bill Number, Bill title, Position, Amount, Direct business + if (cells.length < 4) return + const chamber = normalizeChamber(cells[0]) + const rawBillNumber = cells[1] + const billId = constructBillId(chamber, rawBillNumber) + bills.push({ + clientName, + chamber, + rawBillNumber, + billId, + activityTitle: cells[2] ?? "", + position: cells[3] ?? "", + amount: cells.length > 4 ? parseAmount(cells[4]) : null + }) + }) + }) + + if (compTable || bills.length > 0) { + return { compensation, bills } + } + + // ── Legacy format (<~2013) ───────────────────────────────────────────────── + const salaryTable = doc.querySelector('[id*="grdvSalaryPaid"]') + if (salaryTable) { + let total = 0 + salaryTable.querySelectorAll("tr").forEach(row => { + const cells = Array.from(row.querySelectorAll("td")).map( + td => td.textContent?.trim() ?? "" + ) + if (cells.length >= 2 && !cells[0].includes("Total")) { + const amt = parseAmount(cells[1]) + if (amt !== null) total += amt + } + }) + if (total > 0) { + compensation.push({ clientName: LEGACY_TOTAL_CLIENT, amount: total }) + } + } + + // Legacy bill activity: single grdvActivities table. Three known column layouts: + // 2009 4-col: Date | Bill+Title | Lobbyist | Client + // 2010+ individual 5-col: Activity | Position | DirectBiz | Client | Compensation + // 2010+ entity 6-col: Activity | Lobbyist | Position | DirectBiz | Client | Compensation + const actTable = doc.querySelector('[id$="grdvActivities"]') + if (actTable) { + const allRows = Array.from(actTable.querySelectorAll("tr")) + const headerCells = Array.from( + allRows[0]?.querySelectorAll("th, td") ?? [] + ).map(el => el.textContent?.trim() ?? "") + + let billCol = 1 + let positionCol: number | null = null + let clientCol = 3 + + if (headerCells[0]?.includes("Activity")) { + if (headerCells[1]?.includes("Lobbyist")) { + // 6-col entity layout + billCol = 0 + positionCol = 2 + clientCol = 4 + } else { + // 5-col individual layout + billCol = 0 + positionCol = 1 + clientCol = 3 + } + } + + const chamberMap: Record = { + H: "House Bill", + S: "Senate Bill", + HD: "House Docket", + SD: "Senate Docket" + } + + allRows.slice(1).forEach(row => { + const cells = Array.from(row.querySelectorAll("td")).map( + td => td.textContent?.trim() ?? "" + ) + if (cells.length <= Math.max(billCol, clientCol)) return + + const billCell = cells[billCol] + const skipValues = new Set([ + "Activity or Bill No and Title", + "N/A", + "None", + "", + "Total amount" + ]) + if (!billCell || skipValues.has(billCell)) return + + const parts = billCell.split(/\s+/) + const billNo = parts[0] + const activityTitle = parts.slice(1).join(" ") + const match = billNo.match(/^([A-Z]+)(\d+)$/) + if (!match) return + + const [, prefix, number] = match + const chamber: LobbyingChamber = chamberMap[prefix] ?? "Other" + const billId = constructBillId(chamber, number) + const position = positionCol !== null ? cells[positionCol] ?? "" : "" + const clientName = cells[clientCol] ?? "" + + bills.push({ + clientName, + chamber, + rawBillNumber: number, + billId, + activityTitle, + position, + amount: null + }) + }) + } + + return { compensation, bills } +} diff --git a/functions/src/lobbying/scrapeLobbying.ts b/functions/src/lobbying/scrapeLobbying.ts new file mode 100644 index 000000000..7a6140e8e --- /dev/null +++ b/functions/src/lobbying/scrapeLobbying.ts @@ -0,0 +1,274 @@ +import { logger } from "firebase-functions" +import { runWith } from "firebase-functions/v1" +import { db, Timestamp } from "../firebase" +import type { Database } from "../types" +import { normalizeEntityName } from "./normalize" +import { + fetchDisclosureDetail, + fetchDisclosureMeta, + fetchSummaryLinks, + filingId, + makePortalClient, + registrantId, + yearToGeneralCourt +} from "./portal" +import { + FILINGS_COLLECTION, + FIRST_LOBBYING_YEAR, + LobbyingFiling, + LobbyingRegistrant, + REGISTRANTS_COLLECTION, + SCRAPER_DOC +} from "./types" + +/** + * Scraper state stored in Firestore at /scrapers/lobbying. + * + * processedDiscUrls: disc URLs already fetched; skip on re-runs. + * summaryDiscCache: maps summaryUrl → its known disc URLs so we can skip + * summary page GETs for registrants with no new filings. + */ +interface ScraperState { + processedDiscUrls: string[] + summaryDiscCache: Record +} + +/** + * Maximum number of new disclosure pages to fetch per function invocation. + * Each page takes ~1s; this keeps the run well within the 540s timeout. + * Remaining work is picked up on the next scheduled run. + */ +const MAX_DISCLOSURES_PER_RUN = 200 + +/** + * Scrape lobbying disclosure data for the current and prior calendar year. + * + * Runs every 24 hours. New filers arrive semi-annually so daily polling is + * more than sufficient for steady-state freshness. For initial historical + * ingestion (2005-present) use the backfillLobbying admin script instead. + * + * Progress is checkpointed to Firestore after every disclosure page so the + * function is fully resumable if it times out or is interrupted. + */ +export const scrapeLobbying = runWith({ timeoutSeconds: 540, maxInstances: 1 }) + .pubsub.schedule("every 24 hours") + .onRun(async () => { + const currentYear = new Date().getFullYear() + const years = [currentYear, currentYear - 1] + + const scraperRef = db.doc(SCRAPER_DOC) + const scraperDoc = await scraperRef.get() + const state: ScraperState = { + processedDiscUrls: scraperDoc.data()?.processedDiscUrls ?? [], + summaryDiscCache: scraperDoc.data()?.summaryDiscCache ?? {} + } + const processedSet = new Set(state.processedDiscUrls) + const summaryCache: Record = state.summaryDiscCache + + const client = makePortalClient() + let newDiscCount = 0 + + for (const year of years) { + if (newDiscCount >= MAX_DISCLOSURES_PER_RUN) break + + logger.info(`scrapeLobbying: fetching summary links for ${year}`) + let summaryUrls: string[] + try { + summaryUrls = await fetchSummaryLinks(client, year) + } catch (e) { + logger.error( + `scrapeLobbying: failed to fetch summary links for ${year}`, + e + ) + continue + } + logger.info( + `scrapeLobbying: ${summaryUrls.length} registrants for ${year}` + ) + + for (const summaryUrl of summaryUrls) { + if (newDiscCount >= MAX_DISCLOSURES_PER_RUN) break + + // Use cached disc URLs when available to avoid re-fetching summary pages. + // For current year we always re-check (new filings arrive mid-year). + let discUrls = summaryCache[summaryUrl] + if (!discUrls || year === currentYear) { + try { + const meta = await fetchDisclosureMeta(client, summaryUrl) + discUrls = meta.disclosureUrls + + // Write registrant doc (upsert); don't wait for individual writes to + // finish — use a bulkWriter for the doc contents but checkpoint the + // scraper state separately so interruptions are recoverable. + if (meta.entityName && meta.year) { + await writeRegistrant( + db, + meta.entityName, + meta.year, + meta.regType, + discUrls + ) + } + + summaryCache[summaryUrl] = discUrls + await scraperRef.set( + { summaryDiscCache: summaryCache }, + { merge: true } + ) + } catch (e) { + logger.warn( + `scrapeLobbying: failed to fetch summary ${summaryUrl}`, + e + ) + continue + } + } + + const newDiscUrls = discUrls.filter(u => !processedSet.has(u)) + if (newDiscUrls.length === 0) continue + + for (const discUrl of newDiscUrls) { + if (newDiscCount >= MAX_DISCLOSURES_PER_RUN) break + try { + await processDisclosure(db, client, summaryUrl, discUrl, year) + processedSet.add(discUrl) + newDiscCount++ + + // Checkpoint after every disclosure so restarts lose at most one page + await scraperRef.set( + { processedDiscUrls: Array.from(processedSet) }, + { merge: true } + ) + } catch (e) { + logger.warn( + `scrapeLobbying: failed to process disclosure ${discUrl}`, + e + ) + } + } + } + } + + logger.info(`scrapeLobbying: processed ${newDiscCount} new disclosures`) + }) + +// ─── Shared write helpers (also used by backfillLobbying) ──────────────────── + +/** + * Write or update a LobbyingRegistrant document. Client list is assembled from + * the disclosure meta; filing documents are written separately per-bill. + */ +export async function writeRegistrant( + database: Database, + entityName: string, + year: number, + regType: "Lobbyist" | "Employer", + disclosureUrls: string[] +): Promise { + const id = registrantId(entityName, year) + const ref = database.collection(REGISTRANTS_COLLECTION).doc(id) + const partial: Omit & { + fetchedAt: FirebaseFirestore.Timestamp + } = { + registrantId: id, + entityName, + entityNameNorm: normalizeEntityName(entityName), + year, + generalCourt: yearToGeneralCourt(year), + regType, + disclosureUrls, + fetchedAt: Timestamp.now() + } + // Merge so repeated runs don't wipe clients accumulated from multiple disclosures + await ref.set(partial, { merge: true }) +} + +/** + * Fetch one CompleteDisclosure page and write LobbyingFiling documents. + * Also updates the registrant's client list. + */ +export async function processDisclosure( + database: Database, + client: ReturnType, + summaryUrl: string, + discUrl: string, + year: number +): Promise { + const meta = await fetchDisclosureMeta(client, summaryUrl) + const detail = await fetchDisclosureDetail(client, discUrl, year) + + const { entityName, regType } = meta + const gc = yearToGeneralCourt(year) + const entityNameNorm = normalizeEntityName(entityName) + const now = Timestamp.now() + + // Update registrant's client list + if (entityName && year) { + const regRef = database + .collection(REGISTRANTS_COLLECTION) + .doc(registrantId(entityName, year)) + + const clients = detail.compensation.map(c => ({ + clientName: c.clientName, + clientNameNorm: normalizeEntityName(c.clientName), + compensation: c.amount + })) + + await regRef.set( + { + registrantId: registrantId(entityName, year), + entityName, + entityNameNorm, + year, + generalCourt: gc, + regType: regType ?? "Lobbyist", + clients, + disclosureUrls: [discUrl], + fetchedAt: now + }, + { merge: true } + ) + } + + // Write one LobbyingFiling doc per bill row + if (detail.bills.length === 0) return + + const writer = database.bulkWriter() + for (const bill of detail.bills) { + const fid = filingId( + entityName, + bill.clientName, + bill.chamber, + bill.billId, + gc, + bill.position + ) + const doc: LobbyingFiling = { + filingId: fid, + entityName, + entityNameNorm, + clientName: bill.clientName, + clientNameNorm: normalizeEntityName(bill.clientName), + year, + generalCourt: gc, + chamber: bill.chamber, + billId: bill.billId, + activityTitle: bill.activityTitle, + position: bill.position, + amount: bill.amount, + fetchedAt: now + } + writer.set(database.collection(FILINGS_COLLECTION).doc(fid), doc, { + merge: false + }) + } + await writer.close() +} + +/** All years to scrape, for use by the backfill script. */ +export function allLobbyingYears(): number[] { + const current = new Date().getFullYear() + const years: number[] = [] + for (let y = FIRST_LOBBYING_YEAR; y <= current; y++) years.push(y) + return years +} diff --git a/functions/src/lobbying/types.ts b/functions/src/lobbying/types.ts new file mode 100644 index 000000000..83eaab761 --- /dev/null +++ b/functions/src/lobbying/types.ts @@ -0,0 +1,101 @@ +import { + Array, + InstanceOf, + Literal, + Number, + Null, + Record, + Static, + String, + Union +} from "runtypes" +import { Timestamp } from "../firebase" + +export type LobbyingChamber = Static +export const LobbyingChamber = Union( + Literal("House Bill"), + Literal("Senate Bill"), + Literal("House Docket"), + Literal("Senate Docket"), + Literal("Executive"), + Literal("Other") +) + +export type LobbyingClient = Static +export const LobbyingClient = Record({ + clientName: String, + clientNameNorm: String, + compensation: Null.Or(Number) +}) + +export type LobbyingRegistrant = Static +export const LobbyingRegistrant = Record({ + registrantId: String, + entityName: String, + entityNameNorm: String, + year: Number, + generalCourt: Number, + regType: Union(Literal("Lobbyist"), Literal("Employer")), + clients: Array(LobbyingClient), + disclosureUrls: Array(String), + fetchedAt: InstanceOf(Timestamp) +}) + +export type LobbyingFiling = Static +export const LobbyingFiling = Record({ + filingId: String, + entityName: String, + entityNameNorm: String, + clientName: String, + clientNameNorm: String, + year: Number, + generalCourt: Number, + chamber: LobbyingChamber, + // Non-null only for legislative chambers (House Bill, Senate Bill, House Docket, + // Senate Docket). For Executive and Other, no bill join should be attempted. + billId: Null.Or(String), + activityTitle: String, + position: String, + amount: Null.Or(Number), + fetchedAt: InstanceOf(Timestamp) +}) + +/** Firestore path for lobbying registrant documents */ +export const REGISTRANTS_COLLECTION = "lobbyingRegistrants" + +/** Firestore path for lobbying filing documents */ +export const FILINGS_COLLECTION = "lobbyingFilings" + +/** Firestore path for the live scraper cursor document */ +export const SCRAPER_DOC = "/scrapers/lobbying" + +/** Firestore path for the backfill cursor subcollection */ +export const BACKFILL_DOC = "/scrapers/lobbyingBackfill" +export const BACKFILL_URLS_COLLECTION = "processedUrls" + +/** Earliest year with portal data */ +export const FIRST_LOBBYING_YEAR = 2005 + +/** + * Sentinel clientName used for pre-2013 legacy filings where compensation is + * reported as a single total rather than broken down per client. + */ +export const LEGACY_TOTAL_CLIENT = "_total_salary_" + +/** + * Chamber prefix map for constructing billId values that match MAPLE's Bill.id. + * Typed as a plain index signature so portal.ts can look up any LobbyingChamber + * without triggering "Property X does not exist" on the Partial. + */ +export const CHAMBER_PREFIXES: { [chamber: string]: string | undefined } = { + "House Bill": "H", + "Senate Bill": "S", + "House Docket": "HD", + "Senate Docket": "SD" +} + +/** Canonical chamber values for legacy short-form codes found in older filings */ +export const LEGACY_CHAMBER_MAP: { [raw: string]: LobbyingChamber } = { + HB: "House Bill", + SB: "Senate Bill" +} diff --git a/scripts/firebase-admin/backfillLobbying.ts b/scripts/firebase-admin/backfillLobbying.ts new file mode 100644 index 000000000..f7914dd84 --- /dev/null +++ b/scripts/firebase-admin/backfillLobbying.ts @@ -0,0 +1,156 @@ +/** + * Backfill lobbying disclosure data from 2005 to the present. + * + * This script is the primary ingestion path for all historical data. The live + * Cloud Function (scrapeLobbying) only handles the current and prior year in + * steady state. Run this once to populate the full history, and re-run with + * --year to refresh specific years. + * + * Usage: + * GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ + * yarn firebase-admin run-script backfillLobbying --env dev + * + * Options: + * --year NUMBER Only process this year (useful for testing or re-runs) + * --limit NUMBER Max registrants to process per year (for testing) + * + * Cursor storage: + * Processed disclosure URLs are stored as documents in the Firestore + * subcollection /scrapers/lobbyingBackfill/processedUrls/{urlHash}. + * This scales to the full historical URL set (~50,000+) without hitting the + * 1MB Firestore document size limit. Restart the script at any time; it will + * resume from where it left off. + */ + +import { createHash } from "crypto" +import { z } from "zod" +import { + allLobbyingYears, + processDisclosure, + writeRegistrant +} from "../../functions/src/lobbying/scrapeLobbying" +import { + fetchDisclosureMeta, + fetchSummaryLinks, + makePortalClient +} from "../../functions/src/lobbying/portal" +import { + BACKFILL_DOC, + BACKFILL_URLS_COLLECTION, + FIRST_LOBBYING_YEAR +} from "../../functions/src/lobbying/types" +import { Script } from "./types" + +const Args = z + .object({ + year: z.number().int().min(FIRST_LOBBYING_YEAR).optional(), + limit: z.number().int().positive().optional() + }) + .passthrough() + +export const script: Script = async ({ db, args }) => { + const { year: onlyYear, limit } = Args.parse(args) + + const years = onlyYear ? [onlyYear] : allLobbyingYears() + console.log( + `backfillLobbying: processing years ${years[0]}–${years[years.length - 1]}` + ) + + // Load already-processed disc URLs from the subcollection cursor. + const backfillRef = db.doc(BACKFILL_DOC) + const processedSnap = await backfillRef + .collection(BACKFILL_URLS_COLLECTION) + .select() // fetch only doc IDs (the URL hash), no field data needed + .get() + const processedHashes = new Set(processedSnap.docs.map(d => d.id)) + console.log( + `backfillLobbying: ${processedHashes.size} disc URLs already processed` + ) + + const client = makePortalClient() + let totalNew = 0 + + for (const year of years) { + console.log(`\n── ${year} ──`) + + let summaryUrls: string[] + try { + summaryUrls = await fetchSummaryLinks(client, year) + } catch (e) { + console.error(` Failed to fetch summary links for ${year}:`, e) + continue + } + + if (limit) summaryUrls = summaryUrls.slice(0, limit) + console.log(` ${summaryUrls.length} registrants on portal`) + + let yearNew = 0 + + for (let i = 0; i < summaryUrls.length; i++) { + const summaryUrl = summaryUrls[i] + let meta: Awaited> + + try { + meta = await fetchDisclosureMeta(client, summaryUrl) + } catch (e) { + console.warn( + ` [${i + 1}/${ + summaryUrls.length + }] Failed to fetch summary: ${summaryUrl}`, + e + ) + continue + } + + if (meta.entityName && meta.year) { + try { + await writeRegistrant( + db, + meta.entityName, + meta.year, + meta.regType, + meta.disclosureUrls + ) + } catch (e) { + console.warn(` Failed to write registrant ${meta.entityName}:`, e) + } + } + + for (const discUrl of meta.disclosureUrls) { + const urlHash = createHash("sha256") + .update(discUrl) + .digest("hex") + .slice(0, 40) + if (processedHashes.has(urlHash)) continue + + try { + await processDisclosure(db, client, summaryUrl, discUrl, year) + + // Mark as processed in the subcollection cursor + await backfillRef + .collection(BACKFILL_URLS_COLLECTION) + .doc(urlHash) + .set({ url: discUrl, processedAt: new Date().toISOString() }) + + processedHashes.add(urlHash) + totalNew++ + yearNew++ + } catch (e) { + console.warn(` Failed to process disclosure ${discUrl}:`, e) + } + } + + if ((i + 1) % 50 === 0 || i + 1 === summaryUrls.length) { + console.log( + ` [${i + 1}/${ + summaryUrls.length + }] ${yearNew} new disclosures this year` + ) + } + } + + console.log(` ${year} complete: ${yearNew} new disclosures`) + } + + console.log(`\nbackfillLobbying complete: ${totalNew} new disclosures total`) +} From 0074294861aece13daea9c90be3af634afd407fa Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 5 Jun 2026 16:34:07 -0400 Subject: [PATCH 004/106] feat: add Python Cloud Run scraper for lobbying disclosures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MA SoS portal is protected by Imperva WAF, which uses TLS fingerprinting to classify HTTP clients before examining headers. Python's requests library produces a fingerprint that Imperva allows through; Node.js does not. A standalone Cloud Run container (Python 3.12) is therefore used for the scheduled ingestion instead of a Cloud Function. lobbying-scraper/ — Cloud Run container (3 pip deps: requests, beautifulsoup4, google-cloud-firestore): - scrape.py: entry point with --mode weekly (incremental, fast exit if nothing new) and --mode backfill (full 2005-present history, resumable subcollection cursor). Weekly mode caches summary URL→disc URL mappings so prior-year registrants with no new filings require zero additional HTTP requests. - portal.py: HTTP session management + HTML parsing for all three portal page levels (search POST, summary GET, disclosure GET). Handles both modern (>=2013) and legacy (<2013) disclosure formats. - normalize.py: port of functions/src/lobbying/normalize.ts — 10-step entity name normalization pipeline, must match the TypeScript version exactly. - writer.py: Firestore document construction and batch writes. Schema matches types.ts (lobbyingRegistrants, lobbyingFilings collections). scripts/firebase-admin/backfillLobbying.ts — simplified to spawn scrape.py as a subprocess; all HTTP and Firestore logic moved to Python. functions/src/lobbying/http/ — thin Python HTTP helper kept for reference; not used in the current architecture. Note: server-side IP reputation behavior with Imperva untested. Build and run the container on Cloud Run with --dry-run to validate before full deploy. Co-Authored-By: Claude Sonnet 4.6 --- docs/lobbying-disclosure-ingestion.md | 167 +++++--- functions/src/lobbying/http/.gitignore | 3 + functions/src/lobbying/http/fetch.py | 81 ++++ functions/src/lobbying/http/requirements.txt | 1 + functions/src/lobbying/normalize.ts | 3 +- functions/src/lobbying/portal.ts | 96 ++++- lobbying-scraper/.dockerignore | 4 + lobbying-scraper/Dockerfile | 14 + .../__pycache__/normalize.cpython-37.pyc | Bin 0 -> 1412 bytes .../__pycache__/portal.cpython-37.pyc | Bin 0 -> 11941 bytes lobbying-scraper/normalize.py | 50 +++ lobbying-scraper/portal.py | 376 ++++++++++++++++++ lobbying-scraper/requirements.txt | 3 + lobbying-scraper/scrape.py | 269 +++++++++++++ lobbying-scraper/writer.py | 126 ++++++ scripts/firebase-admin/backfillLobbying.ts | 168 ++------ 16 files changed, 1157 insertions(+), 204 deletions(-) create mode 100644 functions/src/lobbying/http/.gitignore create mode 100644 functions/src/lobbying/http/fetch.py create mode 100644 functions/src/lobbying/http/requirements.txt create mode 100644 lobbying-scraper/.dockerignore create mode 100644 lobbying-scraper/Dockerfile create mode 100644 lobbying-scraper/__pycache__/normalize.cpython-37.pyc create mode 100644 lobbying-scraper/__pycache__/portal.cpython-37.pyc create mode 100644 lobbying-scraper/normalize.py create mode 100644 lobbying-scraper/portal.py create mode 100644 lobbying-scraper/requirements.txt create mode 100644 lobbying-scraper/scrape.py create mode 100644 lobbying-scraper/writer.py diff --git a/docs/lobbying-disclosure-ingestion.md b/docs/lobbying-disclosure-ingestion.md index ad67fe397..264c77c52 100644 --- a/docs/lobbying-disclosure-ingestion.md +++ b/docs/lobbying-disclosure-ingestion.md @@ -233,43 +233,57 @@ executive and legislative null rows. ## Scraper Architecture -The lobbying portal is an HTML scraper, not a REST API. It does not fit the -`createScraper` factory (which assumes list-IDs → fetch-per-ID against the MA -Legislature API). Instead, we use a custom scheduled function following the -`scrapeEvents` pattern. - -### Cloud Function: `scrapeLobbying` - -**File:** `functions/src/lobbying/scrapeLobbying.ts` - -- Schedule: `every 24 hours` -- Scrapes the current year and prior year (new filers arrive semi-annually) +### Why a standalone Cloud Run container + +The MA SoS portal is protected by Imperva WAF, which uses TLS fingerprinting to +classify HTTP clients at the network layer before examining any headers. Node.js +produces a TLS fingerprint that Imperva challenges with a JavaScript +verification page; Python's `requests` library produces a fingerprint that +Imperva allows through without challenge. This is a runtime-level constraint +that cannot be addressed by header configuration or cipher reordering alone. + +The scraper therefore runs as a standalone **Cloud Run container** written in +Python, deployed alongside the existing MCP server container. All data modeling, +Firestore collection/field names, and normalization logic are documented here and +kept consistent between the Python container and the TypeScript type definitions +in `functions/src/lobbying/types.ts`. + +### Cloud Run container: `lobbying-scraper/` + +**Files:** `lobbying-scraper/{scrape,portal,normalize,writer}.py` + +- Scheduled weekly by Cloud Scheduler +- Runs an incremental check: fetches the current and prior year's summary links + (one POST), compares disc URLs against the Firestore cursor, and **exits + immediately if nothing is new** (fast path, typically seconds) +- When new or updated disclosures are found, fetches and processes them - Persists a cursor in `/scrapers/lobbying`: - - `lastFetchedAt: Timestamp` - - `processedDiscUrls: string[]` — already-fetched disclosure URLs (skipped on - re-runs) + - `processedDiscUrls: string[]` — disc URLs already written; skipped on + re-runs + - `summaryDiscCache: {[summaryUrl]: string[]}` — maps summary page URLs to + their disc URLs so summary page GETs are skipped for prior-year registrants + whose disclosures are all already processed - For each new disclosure URL: - Parse registrant + client compensation rows → upsert `lobbyingRegistrants` - doc - - Parse bill activity rows → batch-write `lobbyingFilings` docs -- Uses `axios` (existing dependency) with an iPad `User-Agent` header to match - portal expectations -- Uses `jsdom` for HTML table parsing (already a dependency; used by events scraper) -- 1s delay between requests; exponential backoff on failure (matching existing - scraper retry pattern) -- Function timeout: 540s - -### Incremental Strategy - -Processed disclosure URLs are stored in `/scrapers/lobbying.processedDiscUrls`. -At ~2 disclosure URLs per registrant × ~500 registrants per year, the -current+prior year window stays well within Firestore document limits. -Historical years beyond current-1 are stable (filings are frozen after year -closes) and are handled by the backfill script only. - -The backfill script uses a separate Firestore document -(`/scrapers/lobbyingBackfill`) for its own cursor so it does not interfere with -the live scraper. + - Parse bill activity rows → batch-write `lobbyingFilings` +- 1s delay between requests; exponential backoff on transient failures + +### Incremental strategy + +In steady state (after the initial backfill), each weekly run: + +1. One POST to fetch all summary links for current + prior year +2. For prior-year registrants with all disc URLs in the cursor: zero GETs +3. For current-year registrants: one GET per summary page to check for new + disclosure periods +4. For any new disc URLs: one GET per disclosure page + +New filings arrive twice a year (semi-annual reporting periods). Between +periods, the run completes in under a minute. + +The backfill script (`--mode backfill`) uses a separate subcollection cursor at +`/scrapers/lobbyingBackfill/processedUrls/{urlHash}` so it does not interfere +with the live scraper state. ### Legacy Format (pre-2013) @@ -284,26 +298,64 @@ them. No bill-level compensation amount is available for these years. ``` functions/src/lobbying/ - types.ts — Runtypes definitions for LobbyingRegistrant, LobbyingFiling - scrapeLobbying.ts — Scheduled Cloud Function + shared parsing/normalization logic - index.ts — Re-exports + types.ts — Runtypes definitions for LobbyingRegistrant, LobbyingFiling + normalize.ts — Entity name normalization pipeline + portal.ts — Reference implementation (HTTP layer not used in production) + scrapeLobbying.ts — Reference implementation (superseded by Cloud Run container) + index.ts — Re-exports + +lobbying-scraper/ + scrape.py — Entry point: --mode weekly (incremental) | --mode backfill + portal.py — HTTP + HTML parsing + normalize.py — Port of normalize.ts + writer.py — Firestore document construction + writes + requirements.txt — requests, beautifulsoup4, google-cloud-firestore + Dockerfile — Python 3.12-slim image ``` --- -## Firebase Admin Script +## Deploying the Cloud Run Container + +Follows the same pattern as the MCP server. Requires the +`maple-lobbying-scraper` Artifact Registry repository to exist. + +```bash +cd lobbying-scraper +IMAGE=us-central1-docker.pkg.dev/digital-testimony-dev/maple-lobbying/scraper:latest +docker build -t $IMAGE . && docker push $IMAGE + +gcloud run jobs create maple-lobbying-scraper \ + --image=$IMAGE \ + --project=digital-testimony-dev \ + --region=us-central1 \ + --service-account=@digital-testimony-dev.iam.gserviceaccount.com + +# Schedule weekly via Cloud Scheduler +gcloud scheduler jobs create http maple-lobbying-weekly \ + --schedule="0 6 * * 1" \ + --uri="https://us-central1-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/digital-testimony-dev/jobs/maple-lobbying-scraper:run" \ + --http-method=POST \ + --oauth-service-account-email=@digital-testimony-dev.iam.gserviceaccount.com \ + --location=us-central1 +``` -**File:** `scripts/firebase-admin/backfillLobbying.ts` +## Historical Backfill (Admin Script) -Ingests all historical filings from 2005 to the present. This is the primary -path for all data before the current and prior year. Accepts `--year` and -`--limit` CLI args for targeted re-runs or testing. Calls the same parsing -logic exported from `functions/src/lobbying/scrapeLobbying.ts` and writes -directly to Firestore via the firebase-admin SDK. +Ingests all historical filings from 2005 to the present. Delegates to +`scrape.py --mode backfill` via subprocess. Resumable — the subcollection +cursor at `/scrapers/lobbyingBackfill/processedUrls` tracks what has been +processed. Run directly on the machine (requires `lobbying-scraper/` deps +installed or the `maple-2025` conda environment). ```bash GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ yarn firebase-admin run-script backfillLobbying --env dev + +# Or call scrape.py directly for more control: +cd lobbying-scraper +python3 scrape.py --mode backfill --year 2024 --limit 3 --dry-run +python3 scrape.py --mode backfill --year 2024 ``` --- @@ -348,17 +400,22 @@ export { scrapeLobbying } from "./lobbying" ## Implementation Status -| File | Status | -| -------------------------------------------- | ------- | -| `functions/src/lobbying/types.ts` | ✅ Done | -| `functions/src/lobbying/normalize.ts` | ✅ Done | -| `functions/src/lobbying/portal.ts` | ✅ Done | -| `functions/src/lobbying/scrapeLobbying.ts` | ✅ Done | -| `functions/src/lobbying/index.ts` | ✅ Done | -| `scripts/firebase-admin/backfillLobbying.ts` | ✅ Done | -| `functions/src/index.ts` (export) | ✅ Done | -| `firestore.rules` | ✅ Done | -| `firestore.indexes.json` | ✅ Done | +| File | Status | Notes | +| -------------------------------------------- | ------- | ---------------------------------------------------------- | +| `functions/src/lobbying/types.ts` | ✅ Done | TypeScript type definitions; source of truth for schema | +| `functions/src/lobbying/normalize.ts` | ✅ Done | Normalization pipeline (also ported to `normalize.py`) | +| `functions/src/lobbying/portal.ts` | ✅ Done | Kept for reference; HTTP layer not used (see architecture) | +| `functions/src/lobbying/scrapeLobbying.ts` | ✅ Done | Not deployed; superseded by Cloud Run container | +| `functions/src/lobbying/index.ts` | ✅ Done | | +| `functions/src/index.ts` (export) | ✅ Done | | +| `firestore.rules` | ✅ Done | | +| `firestore.indexes.json` | ✅ Done | | +| `lobbying-scraper/normalize.py` | ✅ Done | Port of normalize.ts | +| `lobbying-scraper/portal.py` | ✅ Done | HTTP + HTML parsing | +| `lobbying-scraper/writer.py` | ✅ Done | Firestore document construction | +| `lobbying-scraper/scrape.py` | ✅ Done | Entry point; `--mode weekly` and `--mode backfill` | +| `lobbying-scraper/Dockerfile` | ✅ Done | Python 3.12 slim | +| `scripts/firebase-admin/backfillLobbying.ts` | ✅ Done | Calls `scrape.py --mode backfill` as subprocess | ### Document ID scheme diff --git a/functions/src/lobbying/http/.gitignore b/functions/src/lobbying/http/.gitignore new file mode 100644 index 000000000..d0ee3b17c --- /dev/null +++ b/functions/src/lobbying/http/.gitignore @@ -0,0 +1,3 @@ +venv/ +__pycache__/ +*.pyc diff --git a/functions/src/lobbying/http/fetch.py b/functions/src/lobbying/http/fetch.py new file mode 100644 index 000000000..4e6c2c4ec --- /dev/null +++ b/functions/src/lobbying/http/fetch.py @@ -0,0 +1,81 @@ +"""Minimal HTTP fetch helper for the lobbying portal. + +Handles the portal's session cookie requirements that standard Node.js HTTP +clients cannot satisfy due to TLS-layer constraints. + +Usage: + python3 fetch.py --url URL [--method GET|POST] [--jar PATH] + +POST body is read from stdin as application/x-www-form-urlencoded. +Cookies are persisted to/from the JSON file at --jar so the session survives +across multiple subprocess invocations. +HTML response is written to stdout. Errors go to stderr with exit code 1. +""" + +import argparse +import json +import sys +from pathlib import Path + +import requests + +_UA = ( + "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) " + "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148" +) + + +def main() -> None: + p = argparse.ArgumentParser() + p.add_argument("--url", required=True) + p.add_argument("--method", default="GET", choices=["GET", "POST"]) + p.add_argument("--jar", default=None, help="Path to JSON cookie-jar file") + args = p.parse_args() + + session = requests.Session() + session.headers.update( + { + "User-Agent": _UA, + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + } + ) + + if args.jar: + jar = Path(args.jar) + if jar.exists(): + try: + session.cookies.update(json.loads(jar.read_text())) + except Exception as e: + print(f"warning: could not read cookie jar: {e}", file=sys.stderr) + + try: + if args.method == "POST": + body = sys.stdin.buffer.read() + resp = session.post( + args.url, + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=180, + ) + else: + resp = session.get(args.url, timeout=60) + + resp.raise_for_status() + + if args.jar: + Path(args.jar).write_text(json.dumps(dict(session.cookies))) + + sys.stdout.buffer.write(resp.content) + + except requests.exceptions.HTTPError as e: + print(f"HTTP error {e.response.status_code}: {args.url}", file=sys.stderr) + sys.exit(1) + except requests.exceptions.RequestException as e: + print(f"request failed: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/functions/src/lobbying/http/requirements.txt b/functions/src/lobbying/http/requirements.txt new file mode 100644 index 000000000..b18d51347 --- /dev/null +++ b/functions/src/lobbying/http/requirements.txt @@ -0,0 +1 @@ +requests>=2.28 diff --git a/functions/src/lobbying/normalize.ts b/functions/src/lobbying/normalize.ts index 8d3d0a0ba..a7beb338f 100644 --- a/functions/src/lobbying/normalize.ts +++ b/functions/src/lobbying/normalize.ts @@ -5,8 +5,7 @@ * registrant may appear as "Acme Corp.", "ACME CORPORATION", "Acme, Inc. d/b/a * Acme Consulting", etc. across filings and years. * - * This pipeline is a direct port of the reference implementation used in the - * companion data analysis project. The steps must be applied in the exact order + * The steps must be applied in the exact order * listed here; changing the order produces different (incorrect) output. */ diff --git a/functions/src/lobbying/portal.ts b/functions/src/lobbying/portal.ts index e441522b8..64d65831b 100644 --- a/functions/src/lobbying/portal.ts +++ b/functions/src/lobbying/portal.ts @@ -19,6 +19,7 @@ import axios, { AxiosInstance } from "axios" import { JSDOM } from "jsdom" import { sha256 } from "js-sha256" +import { CookieJar } from "tough-cookie" import { CHAMBER_PREFIXES, LEGACY_CHAMBER_MAP, @@ -72,19 +73,68 @@ export interface DisclosureDetail { // ─── HTTP helpers ──────────────────────────────────────────────────────────── -export function makePortalClient(): AxiosInstance { - return axios.create({ - headers: { "User-Agent": IPAD_UA }, - timeout: 60_000 +/** + * Create an axios instance pre-configured for the MA SoS portal. + * + * Includes a cookie jar via interceptors so ASP.NET session state (ViewState, + * anti-forgery tokens) is preserved across the GET → POST page flow without + * requiring the axios-cookiejar-support package. + */ +export interface PortalClient { + jar: CookieJar + client: AxiosInstance +} + +/** + * Create a portal client pre-configured for the MA SoS portal. + * + * Uses maxRedirects: 0 so our manual redirect loop (inside getHtml / postHtml) + * can extract Set-Cookie headers at each hop before following. This is necessary + * because the portal is protected by Incapsula, which issues a 302 challenge on + * first contact and requires the session cookies to be sent on the retried request. + * Axios's built-in redirect following happens before response interceptors fire, + * so the cookies from the challenge response are never captured automatically. + */ +export function makePortalClient(): PortalClient { + const jar = new CookieJar() + const client = axios.create({ + headers: { + "User-Agent": IPAD_UA, + Accept: "*/*", + "Accept-Encoding": "gzip, deflate, br", + Connection: "keep-alive" + }, + timeout: 60_000, + maxRedirects: 10, // let axios handle ordinary redirects; only Incapsula challenges need manual handling + validateStatus: s => s < 500 // surface 4xx so we can log them }) + return { jar, client } } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } +function cookieHeader(jar: CookieJar, url: string): string { + return jar + .getCookiesSync(url) + .map(c => c.cookieString()) + .join("; ") +} + +function saveCookies( + jar: CookieJar, + url: string, + headers: Record +): void { + const raw = headers["set-cookie"] + if (!raw) return + const list = Array.isArray(raw) ? raw : [raw] + for (const c of list) jar.setCookieSync(c, url) +} + async function getHtml( - client: AxiosInstance, + pc: PortalClient, url: string, retries = MAX_RETRIES ): Promise { @@ -93,10 +143,16 @@ async function getHtml( attempt === 0 ? REQUEST_DELAY_MS : REQUEST_DELAY_MS * 2 ** attempt ) try { - const res = await client.get(url, { + const res = await pc.client.get(url, { responseType: "text", - headers: { Accept: "text/html" } + headers: { Cookie: cookieHeader(pc.jar, url) } }) + saveCookies( + pc.jar, + url, + res.headers as Record + ) + if (res.status >= 400) throw new Error(`HTTP ${res.status} for ${url}`) return new JSDOM(res.data).window.document } catch (e) { if (attempt === retries - 1) throw e @@ -108,7 +164,7 @@ async function getHtml( } async function postHtml( - client: AxiosInstance, + pc: PortalClient, url: string, data: Record, retries = MAX_RETRIES @@ -119,14 +175,20 @@ async function postHtml( attempt === 0 ? REQUEST_DELAY_MS : REQUEST_DELAY_MS * 2 ** attempt ) try { - const res = await client.post(url, body, { + const res = await pc.client.post(url, body, { responseType: "text", headers: { "Content-Type": "application/x-www-form-urlencoded", - Accept: "text/html" + Cookie: cookieHeader(pc.jar, url) }, timeout: 180_000 }) + saveCookies( + pc.jar, + url, + res.headers as Record + ) + if (res.status >= 400) throw new Error(`HTTP ${res.status} for ${url}`) return new JSDOM(res.data).window.document } catch (e) { if (attempt === retries - 1) throw e @@ -237,10 +299,10 @@ function extractViewState(doc: Document): Record { * Sends a single search POST with page size 20000 to get all registrants at once. */ export async function fetchSummaryLinks( - client: AxiosInstance, + pc: PortalClient, year: number ): Promise { - const searchPage = await getHtml(client, SEARCH_URL) + const searchPage = await getHtml(pc, SEARCH_URL) const vs = extractViewState(searchPage) const postData: Record = { @@ -257,7 +319,7 @@ export async function fetchSummaryLinks( ctl00$ContentPlaceHolder1$btnSearch: "Search" } - const resultsPage = await postHtml(client, SEARCH_URL, postData) + const resultsPage = await postHtml(pc, SEARCH_URL, postData) const table = resultsPage.querySelector( '[id*="grdvSearchResultByTypeAndCategory"]' @@ -280,10 +342,10 @@ export async function fetchSummaryLinks( * Fetch a Summary.aspx page and return the registrant metadata + disclosure URLs. */ export async function fetchDisclosureMeta( - client: AxiosInstance, + pc: PortalClient, summaryUrl: string ): Promise { - const doc = await getHtml(client, summaryUrl) + const doc = await getHtml(pc, summaryUrl) const text = (id: string) => { const el = doc.getElementById(id) @@ -322,11 +384,11 @@ export async function fetchDisclosureMeta( * Handles both modern (≥~2013) and legacy (<~2013) HTML layouts. */ export async function fetchDisclosureDetail( - client: AxiosInstance, + pc: PortalClient, discUrl: string, year: number ): Promise { - const doc = await getHtml(client, discUrl) + const doc = await getHtml(pc, discUrl) const compensation: RawCompensation[] = [] const bills: RawBillActivity[] = [] diff --git a/lobbying-scraper/.dockerignore b/lobbying-scraper/.dockerignore new file mode 100644 index 000000000..9460c99c4 --- /dev/null +++ b/lobbying-scraper/.dockerignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.pyo +.env diff --git a/lobbying-scraper/Dockerfile b/lobbying-scraper/Dockerfile new file mode 100644 index 000000000..738293459 --- /dev/null +++ b/lobbying-scraper/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY normalize.py portal.py writer.py scrape.py ./ + +# Cloud Run sets PORT; we don't use it (this is a job, not a server). +# Cloud Scheduler invokes the container via HTTP POST to /; handle it minimally. +ENV PYTHONUNBUFFERED=1 + +CMD ["python3", "scrape.py", "--mode", "weekly"] diff --git a/lobbying-scraper/__pycache__/normalize.cpython-37.pyc b/lobbying-scraper/__pycache__/normalize.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..47c3ba707ebdca4ef16776e15507d9f385437cab GIT binary patch literal 1412 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 literal 0 HcmV?d00001 diff --git a/lobbying-scraper/normalize.py b/lobbying-scraper/normalize.py new file mode 100644 index 000000000..6e6f7418e --- /dev/null +++ b/lobbying-scraper/normalize.py @@ -0,0 +1,50 @@ +"""Entity name normalization pipeline. + +Direct port of functions/src/lobbying/normalize.ts. Steps must be applied in +this exact order — changing the order produces different (incorrect) output. +""" + +from __future__ import annotations + +import re + +_DBA_RE = re.compile(r"\s+D\s*/+B\s*/+A?\s+.*|\s+DBA\s+.*", re.IGNORECASE) +_LEGAL_RE = re.compile( + r"\b(LLC|LLP|INC|INCORPORATED|CORPORATION|CORP|LTD|LIMITED|PC|PLLC)\b" +) +_THE_RE = re.compile(r"\bTHE\b") +_WS_RE = re.compile(r"\s+") + +_MISC_PHRASES = [ + "LAW OFFICE OF", + "AND ASSOCIATES", + "& ASSOCIATES", + "AND ASSOC", + "ATTORNEY AT LAW", + "ATTORNEY@LAW", + "ATTORNET AT LAW", # known portal typo + "AND PARTNERS", + "PUBLIC POLICY GROUP", + "LEGISLATIVE SERVICES", + "POLICY GROUP", + "ASSOCIATES", + "COUNSELLORS AT LAW", +] + + +def normalize_entity_name(raw: str | None) -> str: + if not raw: + return "" + x = raw.upper() # 1. uppercase + x = _DBA_RE.sub("", x) # 2. strip d/b/a suffix + x = x.replace("-", " ") # 3. hyphen → space + for ch in (",", ".", "'", "‘", "’", "(", ")"): + x = x.replace(ch, " ") # 4. punctuation → space + x = _LEGAL_RE.sub(" ", x) # 5. remove legal entity words + x = _THE_RE.sub(" ", x) # 6. remove THE anywhere + x = x.replace("&", "AND") # 7. ampersand → AND + x = x.replace("ASSICIATES", "ASSOCIATES") # 8. fix known typo + for phrase in _MISC_PHRASES: # 9. remove professional suffix phrases + x = x.replace(phrase, " ") + x = _WS_RE.sub(" ", x).strip() # 10. collapse whitespace + return x diff --git a/lobbying-scraper/portal.py b/lobbying-scraper/portal.py new file mode 100644 index 000000000..257721991 --- /dev/null +++ b/lobbying-scraper/portal.py @@ -0,0 +1,376 @@ +"""HTTP client and HTML parser for the MA SoS lobbying portal. + +Portal: https://www.sec.state.ma.us/LobbyistPublicSearch/ + +Page flow: + 1. Search POST → summary links table + 2. Summary.aspx → registrant name/year/type + CompleteDisclosure links + 3. CompleteDisclosure.aspx → per-client compensation + per-client bill activity + +Two disclosure HTML formats: + Modern (>=~2013): grdvClientPaidToEntity + grdvActivitiesNew{year}_{n} tables. + Legacy (<~2013): grdvSalaryPaid (total only) + grdvActivities (all bills). +""" + +from __future__ import annotations + +import hashlib +import re +import time +from dataclasses import dataclass, field +from typing import Optional + +import requests +from bs4 import BeautifulSoup, Tag + +# ── Constants ───────────────────────────────────────────────────────────────── + +BASE_URL = "https://www.sec.state.ma.us/LobbyistPublicSearch/" +SEARCH_URL = BASE_URL + "Default.aspx" + +_UA = ( + "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) " + "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148" +) +_REQUEST_DELAY = 1.0 +_MAX_RETRIES = 5 + +# Lobby disclosure data begins in 2005; GC 183 started Jan 2003. +FIRST_YEAR = 2005 +FIRST_GC = 183 +FIRST_GC_START_YEAR = 2003 + +# clientName sentinel for pre-2013 filings where compensation is a single total +LEGACY_TOTAL_CLIENT = "_total_salary_" + +# Maps canonical chamber names to the bill-ID prefix used in MAPLE's Bill.id +CHAMBER_PREFIXES: dict[str, str] = { + "House Bill": "H", + "Senate Bill": "S", + "House Docket": "HD", + "Senate Docket": "SD", +} + +# Legacy short-form chamber codes found in older filings +LEGACY_CHAMBER_MAP: dict[str, str] = { + "HB": "House Bill", + "SB": "Senate Bill", +} + +# ── Data types ──────────────────────────────────────────────────────────────── + + +@dataclass +class Compensation: + client_name: str + amount: Optional[float] + + +@dataclass +class BillActivity: + client_name: str + chamber: str # canonical LobbyingChamber value + raw_bill_number: str + bill_id: Optional[str] # e.g. "H1234"; null for Executive/Other + activity_title: str + position: str + amount: Optional[float] + + +@dataclass +class DisclosureMeta: + entity_name: str + year: Optional[int] + reg_type: str # "Lobbyist" | "Employer" + disclosure_urls: list[str] = field(default_factory=list) + + +@dataclass +class DisclosureDetail: + compensation: list[Compensation] = field(default_factory=list) + bills: list[BillActivity] = field(default_factory=list) + + +# ── Derived-value helpers ───────────────────────────────────────────────────── + + +def year_to_general_court(year: int) -> int: + return FIRST_GC + (year - FIRST_GC_START_YEAR) // 2 + + +def normalize_chamber(raw: str) -> str: + t = raw.strip() + if t in LEGACY_CHAMBER_MAP: + return LEGACY_CHAMBER_MAP[t] + known = {"House Bill", "Senate Bill", "House Docket", "Senate Docket", "Executive"} + return t if t in known else "Other" + + +def construct_bill_id(chamber: str, raw_bill_number: str) -> Optional[str]: + """Construct the MAPLE-compatible billId from chamber + raw integer. + + Returns None for Executive and Other chambers where no bill join is possible. + H1234 and S1234 are distinct bills even though they share the same integer — + the prefix is required to disambiguate. + """ + prefix = CHAMBER_PREFIXES.get(chamber) + if not prefix: + return None + try: + return f"{prefix}{int(raw_bill_number)}" + except (ValueError, TypeError): + return None + + +def registrant_id(entity_name: str, year: int) -> str: + key = f"{year}|{entity_name}" + return hashlib.sha256(key.encode()).hexdigest()[:40] + + +def filing_id( + entity_name: str, + client_name: str, + chamber: str, + bill_id: Optional[str], + general_court: int, + position: str, +) -> str: + key = "|".join([entity_name, client_name, chamber, bill_id or "__null__", + str(general_court), position]) + return hashlib.sha256(key.encode()).hexdigest()[:40] + + +# ── HTTP session ────────────────────────────────────────────────────────────── + + +def make_session() -> requests.Session: + s = requests.Session() + s.headers.update({ + "User-Agent": _UA, + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + }) + return s + + +def _get(session: requests.Session, url: str) -> BeautifulSoup: + for attempt in range(_MAX_RETRIES): + time.sleep(_REQUEST_DELAY * (2 ** attempt) if attempt else _REQUEST_DELAY) + try: + r = session.get(url, timeout=60) + r.raise_for_status() + return BeautifulSoup(r.text, "html.parser") + except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: + if attempt == _MAX_RETRIES - 1: + raise + print(f" GET retry {attempt + 1}: {e}") + + +def _post(session: requests.Session, url: str, data: dict) -> BeautifulSoup: + for attempt in range(_MAX_RETRIES): + time.sleep(_REQUEST_DELAY * (2 ** attempt) if attempt else _REQUEST_DELAY) + try: + r = session.post(url, data=data, timeout=180) + r.raise_for_status() + return BeautifulSoup(r.text, "html.parser") + except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: + if attempt == _MAX_RETRIES - 1: + raise + print(f" POST retry {attempt + 1}: {e}") + + +# ── Portal scraping ─────────────────────────────────────────────────────────── + + +def _viewstate(soup: BeautifulSoup) -> dict: + return { + inp["name"]: inp.get("value", "") + for inp in soup.find_all("input", type="hidden") + if inp.get("name") + } + + +def fetch_summary_links(session: requests.Session, year: int) -> list[str]: + """Return all Summary.aspx URLs for a given year via a single search POST.""" + soup = _get(session, SEARCH_URL) + data = { + **_viewstate(soup), + "__EVENTTARGET": "", + "__EVENTARGUMENT": "", + "ctl00$ContentPlaceHolder1$Search": "rdbSearchByType", + "ctl00$ContentPlaceHolder1$ucSearchCriteriaByType$ddlYear": str(year), + "ctl00$ContentPlaceHolder1$ucSearchCriteriaByType$txtN_ame": "", + "ctl00$ContentPlaceHolder1$ucSearchCriteriaByType$lddSearchType$DropDown": "3", + "ctl00$ContentPlaceHolder1$ucSearchCriteriaByType$drpType": "L", + "ctl00$ContentPlaceHolder1$drpPageSize": "20000", + "ctl00$ContentPlaceHolder1$btnSearch": "Search", + } + results = _post(session, SEARCH_URL, data) + table = results.find("table", id=lambda x: x and "grdvSearchResultByTypeAndCategory" in x) + if not table: + return [] + return [ + BASE_URL + a["href"] if not a["href"].startswith("http") else a["href"] + for a in table.find_all("a", href=True) + if "Summary.aspx" in a["href"] + ] + + +def fetch_disclosure_meta(session: requests.Session, summary_url: str) -> DisclosureMeta: + soup = _get(session, summary_url) + + def text(el_id: str) -> str: + el = soup.find(id=el_id) + return el.get_text(strip=True) if el else "" + + entity_name = text("ContentPlaceHolder1_lblRegistrantName") + year_text = text("ContentPlaceHolder1_lblYear") + reg_type_raw = text("ContentPlaceHolder1_lblRegType") + + try: + year = int(year_text) + except ValueError: + year = None + + reg_type = "Employer" if "Entity" in reg_type_raw else "Lobbyist" + + disc_urls = [ + BASE_URL + a["href"] if not a["href"].startswith("http") else a["href"] + for a in soup.find_all("a", href=True) + if "CompleteDisclosure" in a["href"] + ] + + return DisclosureMeta( + entity_name=entity_name, + year=year, + reg_type=reg_type, + disclosure_urls=disc_urls, + ) + + +def _parse_amount(text: str) -> Optional[float]: + cleaned = text.replace("$", "").replace(",", "").strip() + try: + return float(cleaned) + except ValueError: + return None + + +def _grid_rows(table: Tag) -> list[Tag]: + return table.find_all("tr", class_=lambda c: c and "Grid" in c and "Header" not in c) + + +def fetch_disclosure_detail( + session: requests.Session, disc_url: str, year: int +) -> DisclosureDetail: + soup = _get(session, disc_url) + compensation: list[Compensation] = [] + bills: list[BillActivity] = [] + gc = year_to_general_court(year) + + # ── Modern format (>=~2013) ─────────────────────────────────────────────── + comp_table = soup.find("table", id=lambda x: x and "grdvClientPaidToEntity" in (x or "")) + if comp_table: + for row in _grid_rows(comp_table): + cells = [td.get_text(strip=True) for td in row.find_all("td")] + if len(cells) >= 2: + compensation.append(Compensation( + client_name=cells[0], + amount=_parse_amount(cells[1]), + )) + + act_tables = soup.find_all( + "table", + id=lambda x: x and re.search(r"grdvActivitiesNew(\d{4})?_\d+", x or ""), + ) + for act_table in act_tables: + # Walk backwards to find the nearest lblClientName span + client_name = "" + node = act_table + while node: + node = node.find_previous(["span", "div", "td"]) + if not node: + break + if node.get("id") and "lblClientName" in node["id"]: + client_name = node.get_text(strip=True) + break + + for row in _grid_rows(act_table): + cells = [td.get_text(strip=True) for td in row.find_all("td")] + if len(cells) < 4: + continue + chamber = normalize_chamber(cells[0]) + raw_num = cells[1] + bill_id = construct_bill_id(chamber, raw_num) + bills.append(BillActivity( + client_name=client_name, + chamber=chamber, + raw_bill_number=raw_num, + bill_id=bill_id, + activity_title=cells[2] if len(cells) > 2 else "", + position=cells[3] if len(cells) > 3 else "", + amount=_parse_amount(cells[4]) if len(cells) > 4 else None, + )) + + if comp_table or bills: + return DisclosureDetail(compensation=compensation, bills=bills) + + # ── Legacy format (<~2013) ──────────────────────────────────────────────── + salary_table = soup.find("table", id=lambda x: x and "grdvSalaryPaid" in (x or "")) + if salary_table: + total = 0.0 + for row in salary_table.find_all("tr"): + cells = [td.get_text(strip=True) for td in row.find_all("td")] + if len(cells) >= 2 and "Total" not in cells[0]: + amt = _parse_amount(cells[1]) + if amt: + total += amt + if total: + compensation.append(Compensation(client_name=LEGACY_TOTAL_CLIENT, amount=total)) + + act_table = soup.find("table", id=lambda x: x and x.endswith("grdvActivities")) + if act_table: + all_rows = act_table.find_all("tr") + headers = [th.get_text(strip=True) + for th in (all_rows[0].find_all(["th", "td"]) if all_rows else [])] + + if headers and "Activity" in headers[0]: + # 6-col entity layout has Lobbyist as second header + if len(headers) >= 2 and "Lobbyist" in headers[1]: + bill_col, pos_col, client_col = 0, 2, 4 + else: + bill_col, pos_col, client_col = 0, 1, 3 + else: + bill_col, pos_col, client_col = 1, None, 3 + + chamber_map = {"H": "House Bill", "S": "Senate Bill", + "HD": "House Docket", "SD": "Senate Docket"} + skip = {"Activity or Bill No and Title", "N/A", "None", "", "Total amount"} + + for row in all_rows[1:]: + cells = [td.get_text(strip=True) for td in row.find_all("td")] + if len(cells) <= max(bill_col, client_col): + continue + bill_cell = cells[bill_col] + if not bill_cell or bill_cell in skip: + continue + parts = bill_cell.split(None, 1) + bill_no = parts[0] + m = re.match(r"^([A-Z]+)(\d+)$", bill_no) + if not m: + continue + prefix, number = m.group(1), m.group(2) + chamber = chamber_map.get(prefix, "Other") + bill_id = construct_bill_id(chamber, number) + bills.append(BillActivity( + client_name=cells[client_col] if len(cells) > client_col else "", + chamber=chamber, + raw_bill_number=number, + bill_id=bill_id, + activity_title=parts[1] if len(parts) > 1 else "", + position=cells[pos_col] if pos_col is not None and len(cells) > pos_col else "", + amount=None, + )) + + return DisclosureDetail(compensation=compensation, bills=bills) diff --git a/lobbying-scraper/requirements.txt b/lobbying-scraper/requirements.txt new file mode 100644 index 000000000..5e7b4bcc7 --- /dev/null +++ b/lobbying-scraper/requirements.txt @@ -0,0 +1,3 @@ +requests>=2.28 +beautifulsoup4>=4.12 +google-cloud-firestore>=2.14 diff --git a/lobbying-scraper/scrape.py b/lobbying-scraper/scrape.py new file mode 100644 index 000000000..fb985e05f --- /dev/null +++ b/lobbying-scraper/scrape.py @@ -0,0 +1,269 @@ +"""Lobbying disclosure scraper — Cloud Run entry point. + +Runs on a weekly Cloud Scheduler trigger. Checks for new or amended disclosures +and exits immediately if none are found (fast path). When new disclosures exist, +fetches and writes them to Firestore. + +Also serves as the library used by the TypeScript backfill admin script via +subprocess. + +Environment variables: + GOOGLE_CLOUD_PROJECT — GCP project ID (set automatically in Cloud Run) + FIRESTORE_EMULATOR_HOST — set to use the local emulator (e.g. localhost:8080) + +CLI flags (for local / backfill use): + --year YEAR Only process this year (default: current + prior) + --limit N Max registrants per year (for testing) + --dry-run Fetch and parse but do not write to Firestore +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from datetime import datetime, timezone + +from google.cloud import firestore + +from portal import ( + FIRST_YEAR, + fetch_disclosure_detail, + fetch_disclosure_meta, + fetch_summary_links, + make_session, +) +from writer import ( + BACKFILL_DOC, + BACKFILL_URLS_COLLECTION, + SCRAPER_DOC, + write_filings, + write_registrant, +) + + +# ── Cursor helpers ──────────────────────────────────────────────────────────── + + +def _load_live_cursor(db: firestore.Client) -> tuple[set[str], dict[str, list[str]]]: + """Return (processedDiscUrls, summaryDiscCache) from the live scraper doc.""" + doc = db.document(SCRAPER_DOC).get() + data = doc.to_dict() or {} + return ( + set(data.get("processedDiscUrls", [])), + data.get("summaryDiscCache", {}), + ) + + +def _save_live_cursor( + db: firestore.Client, + processed: set[str], + cache: dict[str, list[str]], +) -> None: + db.document(SCRAPER_DOC).set( + {"processedDiscUrls": list(processed), "summaryDiscCache": cache}, + merge=True, + ) + + +def _is_backfill_processed(db: firestore.Client, disc_url: str) -> bool: + h = hashlib.sha256(disc_url.encode()).hexdigest()[:40] + return db.document(BACKFILL_DOC).collection(BACKFILL_URLS_COLLECTION).document(h).get().exists + + +def _mark_backfill_processed(db: firestore.Client, disc_url: str) -> None: + h = hashlib.sha256(disc_url.encode()).hexdigest()[:40] + db.document(BACKFILL_DOC).collection(BACKFILL_URLS_COLLECTION).document(h).set( + {"url": disc_url, "processedAt": datetime.now(tz=timezone.utc).isoformat()} + ) + + +# ── Core processing ─────────────────────────────────────────────────────────── + + +def process_disclosure( + db: firestore.Client | None, + session, + summary_url: str, + disc_url: str, + year: int, + dry_run: bool = False, +) -> tuple[int, int]: + """Fetch one disclosure page and write registrant + filing documents. + + Returns (compensation_rows, filing_rows). + """ + meta = fetch_disclosure_meta(session, summary_url) + detail = fetch_disclosure_detail(session, disc_url, year) + + if dry_run or db is None: + return len(detail.compensation), len(detail.bills) + + write_registrant(db, meta, detail, disc_url) + n_filings = write_filings(db, meta, detail) + return len(detail.compensation), n_filings + + +# ── Weekly incremental run ──────────────────────────────────────────────────── + + +def run_weekly( + db: "firestore.Client | None", + years: list[int], + limit: int | None = None, + dry_run: bool = False, +) -> int: + """Incremental weekly check. Returns number of new disclosures processed.""" + current_year = datetime.now(tz=timezone.utc).year + processed, cache = _load_live_cursor(db) if db is not None else (set(), {}) + + session = make_session() + new_count = 0 + + for year in years: + print(f"\n── {year} ──") + try: + summary_urls = fetch_summary_links(session, year) + except Exception as e: + print(f" failed to fetch summary links: {e}", file=sys.stderr) + continue + + if limit: + summary_urls = summary_urls[:limit] + + print(f" {len(summary_urls)} registrants on portal") + + for summary_url in summary_urls: + # Use cached disc URLs for prior years; always re-check current year + disc_urls = cache.get(summary_url) + if disc_urls is None or year == current_year: + try: + meta = fetch_disclosure_meta(session, summary_url) + disc_urls = meta.disclosure_urls + cache[summary_url] = disc_urls + if not dry_run: + _save_live_cursor(db, processed, cache) + except Exception as e: + print(f" failed to fetch summary {summary_url}: {e}", file=sys.stderr) + continue + + new_disc_urls = [u for u in disc_urls if u not in processed] + if not new_disc_urls: + continue + + for disc_url in new_disc_urls: + try: + comp_n, filing_n = process_disclosure( + db, session, summary_url, disc_url, year, dry_run=dry_run + ) + processed.add(disc_url) + new_count += 1 + print(f" processed: {comp_n} clients, {filing_n} filings") + if not dry_run: + _save_live_cursor(db, processed, cache) + except Exception as e: + print(f" failed to process {disc_url}: {e}", file=sys.stderr) + + return new_count + + +# ── Historical backfill ─────────────────────────────────────────────────────── + + +def run_backfill( + db: "firestore.Client | None", + years: list[int], + limit: int | None = None, + dry_run: bool = False, +) -> int: + """Full historical backfill using the subcollection cursor. Resumable.""" + session = make_session() + total_new = 0 + + for year in years: + print(f"\n── {year} ──") + try: + summary_urls = fetch_summary_links(session, year) + except Exception as e: + print(f" failed to fetch summary links: {e}", file=sys.stderr) + continue + + if limit: + summary_urls = summary_urls[:limit] + + print(f" {len(summary_urls)} registrants on portal") + year_new = 0 + + for i, summary_url in enumerate(summary_urls): + try: + meta = fetch_disclosure_meta(session, summary_url) + except Exception as e: + print(f" [{i+1}/{len(summary_urls)}] failed to fetch summary: {e}", file=sys.stderr) + continue + + for disc_url in meta.disclosure_urls: + if db is not None and not dry_run and _is_backfill_processed(db, disc_url): + continue + try: + comp_n, filing_n = process_disclosure( + db, session, summary_url, disc_url, year, dry_run=dry_run + ) + if not dry_run: + _mark_backfill_processed(db, disc_url) + total_new += 1 + year_new += 1 + except Exception as e: + print(f" failed to process {disc_url}: {e}", file=sys.stderr) + + if (i + 1) % 50 == 0 or i + 1 == len(summary_urls): + print(f" [{i+1}/{len(summary_urls)}] {year_new} new disclosures so far") + + print(f" {year} complete: {year_new} new disclosures") + + return total_new + + +# ── Entry point ─────────────────────────────────────────────────────────────── + + +def main() -> None: + p = argparse.ArgumentParser() + p.add_argument("--year", type=int, default=None) + p.add_argument("--limit", type=int, default=None) + p.add_argument("--dry-run", action="store_true") + p.add_argument( + "--mode", + choices=["weekly", "backfill"], + default="weekly", + help="weekly: incremental check; backfill: full history with subcollection cursor", + ) + args = p.parse_args() + + current_year = datetime.now(tz=timezone.utc).year + + if args.year: + years = [args.year] + elif args.mode == "weekly": + years = [current_year, current_year - 1] + else: + years = list(range(FIRST_YEAR, current_year + 1)) + + db = firestore.Client() if not args.dry_run else None + + if args.mode == "weekly": + n = run_weekly(db, years, limit=args.limit, dry_run=args.dry_run) + if n == 0: + print("\nNo new disclosures found.") + else: + print(f"\nDone: {n} new disclosures written.") + else: + n = run_backfill(db, years, limit=args.limit, dry_run=args.dry_run) + print(f"\nBackfill complete: {n} new disclosures written.") + + # Emit structured result for callers (e.g. TypeScript backfill script) + print(json.dumps({"newDisclosures": n}), file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/lobbying-scraper/writer.py b/lobbying-scraper/writer.py new file mode 100644 index 000000000..a6804f401 --- /dev/null +++ b/lobbying-scraper/writer.py @@ -0,0 +1,126 @@ +"""Firestore document construction and write helpers. + +Mirrors the data model in functions/src/lobbying/types.ts. All collection +names and field names must stay in sync with that file. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from normalize import normalize_entity_name +from portal import ( + BillActivity, + Compensation, + DisclosureDetail, + DisclosureMeta, + filing_id, + registrant_id, + year_to_general_court, +) + +if TYPE_CHECKING: + from google.cloud import firestore + +REGISTRANTS_COLLECTION = "lobbyingRegistrants" +FILINGS_COLLECTION = "lobbyingFilings" +SCRAPER_DOC = "/scrapers/lobbying" +BACKFILL_DOC = "/scrapers/lobbyingBackfill" +BACKFILL_URLS_COLLECTION = "processedUrls" + + +def _now() -> datetime: + return datetime.now(tz=timezone.utc) + + +def write_registrant( + db: firestore.Client, + meta: DisclosureMeta, + detail: DisclosureDetail, + disc_url: str, +) -> None: + """Upsert a LobbyingRegistrant document.""" + if not meta.entity_name or meta.year is None: + return + + doc_id = registrant_id(meta.entity_name, meta.year) + ref = db.collection(REGISTRANTS_COLLECTION).document(doc_id) + + clients = [ + { + "clientName": c.client_name, + "clientNameNorm": normalize_entity_name(c.client_name), + "compensation": c.amount, + } + for c in detail.compensation + ] + + data = { + "registrantId": doc_id, + "entityName": meta.entity_name, + "entityNameNorm": normalize_entity_name(meta.entity_name), + "year": meta.year, + "generalCourt": year_to_general_court(meta.year), + "regType": meta.reg_type, + "clients": clients, + "disclosureUrls": firestore.ArrayUnion([disc_url]), + "fetchedAt": _now(), + } + ref.set(data, merge=True) + + +def write_filings( + db: firestore.Client, + meta: DisclosureMeta, + detail: DisclosureDetail, +) -> int: + """Batch-write LobbyingFiling documents. Returns the number written.""" + if not meta.entity_name or meta.year is None or not detail.bills: + return 0 + + gc = year_to_general_court(meta.year) + entity_name = meta.entity_name + entity_norm = normalize_entity_name(entity_name) + now = _now() + + batch = db.batch() + count = 0 + + for bill in detail.bills: + fid = filing_id( + entity_name, + bill.client_name, + bill.chamber, + bill.bill_id, + gc, + bill.position, + ) + ref = db.collection(FILINGS_COLLECTION).document(fid) + doc = { + "filingId": fid, + "entityName": entity_name, + "entityNameNorm": entity_norm, + "clientName": bill.client_name, + "clientNameNorm": normalize_entity_name(bill.client_name), + "year": meta.year, + "generalCourt": gc, + "chamber": bill.chamber, + "billId": bill.bill_id, + "activityTitle": bill.activity_title, + "position": bill.position, + "amount": bill.amount, + "fetchedAt": now, + } + batch.set(ref, doc) + count += 1 + + # Firestore batch limit is 500 writes + if count % 400 == 0: + batch.commit() + batch = db.batch() + + if count % 400 != 0: + batch.commit() + + return count diff --git a/scripts/firebase-admin/backfillLobbying.ts b/scripts/firebase-admin/backfillLobbying.ts index f7914dd84..a2a66330e 100644 --- a/scripts/firebase-admin/backfillLobbying.ts +++ b/scripts/firebase-admin/backfillLobbying.ts @@ -1,156 +1,64 @@ /** * Backfill lobbying disclosure data from 2005 to the present. * - * This script is the primary ingestion path for all historical data. The live - * Cloud Function (scrapeLobbying) only handles the current and prior year in - * steady state. Run this once to populate the full history, and re-run with - * --year to refresh specific years. + * Delegates all HTTP fetching and Firestore writes to the Python scraper in + * lobbying-scraper/. The TypeScript layer handles argument parsing and + * environment setup only. * * Usage: * GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ * yarn firebase-admin run-script backfillLobbying --env dev * - * Options: - * --year NUMBER Only process this year (useful for testing or re-runs) - * --limit NUMBER Max registrants to process per year (for testing) + * Options (passed through to scrape.py): + * --year NUMBER Only process this year + * --limit NUMBER Max registrants per year (for testing) * - * Cursor storage: - * Processed disclosure URLs are stored as documents in the Firestore - * subcollection /scrapers/lobbyingBackfill/processedUrls/{urlHash}. - * This scales to the full historical URL set (~50,000+) without hitting the - * 1MB Firestore document size limit. Restart the script at any time; it will - * resume from where it left off. + * Requires: pip install -r lobbying-scraper/requirements.txt + * Or run inside the maple-2025 conda environment. */ -import { createHash } from "crypto" +import { spawn } from "child_process" +import path from "path" import { z } from "zod" -import { - allLobbyingYears, - processDisclosure, - writeRegistrant -} from "../../functions/src/lobbying/scrapeLobbying" -import { - fetchDisclosureMeta, - fetchSummaryLinks, - makePortalClient -} from "../../functions/src/lobbying/portal" -import { - BACKFILL_DOC, - BACKFILL_URLS_COLLECTION, - FIRST_LOBBYING_YEAR -} from "../../functions/src/lobbying/types" import { Script } from "./types" const Args = z .object({ - year: z.number().int().min(FIRST_LOBBYING_YEAR).optional(), + year: z.number().int().min(2005).optional(), limit: z.number().int().positive().optional() }) .passthrough() -export const script: Script = async ({ db, args }) => { - const { year: onlyYear, limit } = Args.parse(args) +const SCRAPER = path.resolve(__dirname, "../../lobbying-scraper/scrape.py") - const years = onlyYear ? [onlyYear] : allLobbyingYears() - console.log( - `backfillLobbying: processing years ${years[0]}–${years[years.length - 1]}` - ) - - // Load already-processed disc URLs from the subcollection cursor. - const backfillRef = db.doc(BACKFILL_DOC) - const processedSnap = await backfillRef - .collection(BACKFILL_URLS_COLLECTION) - .select() // fetch only doc IDs (the URL hash), no field data needed - .get() - const processedHashes = new Set(processedSnap.docs.map(d => d.id)) - console.log( - `backfillLobbying: ${processedHashes.size} disc URLs already processed` - ) - - const client = makePortalClient() - let totalNew = 0 - - for (const year of years) { - console.log(`\n── ${year} ──`) - - let summaryUrls: string[] - try { - summaryUrls = await fetchSummaryLinks(client, year) - } catch (e) { - console.error(` Failed to fetch summary links for ${year}:`, e) - continue - } - - if (limit) summaryUrls = summaryUrls.slice(0, limit) - console.log(` ${summaryUrls.length} registrants on portal`) - - let yearNew = 0 - - for (let i = 0; i < summaryUrls.length; i++) { - const summaryUrl = summaryUrls[i] - let meta: Awaited> +export const script: Script = async ({ env, args }) => { + const { year, limit } = Args.parse(args) - try { - meta = await fetchDisclosureMeta(client, summaryUrl) - } catch (e) { - console.warn( - ` [${i + 1}/${ - summaryUrls.length - }] Failed to fetch summary: ${summaryUrl}`, - e - ) - continue - } - - if (meta.entityName && meta.year) { - try { - await writeRegistrant( - db, - meta.entityName, - meta.year, - meta.regType, - meta.disclosureUrls - ) - } catch (e) { - console.warn(` Failed to write registrant ${meta.entityName}:`, e) - } - } - - for (const discUrl of meta.disclosureUrls) { - const urlHash = createHash("sha256") - .update(discUrl) - .digest("hex") - .slice(0, 40) - if (processedHashes.has(urlHash)) continue - - try { - await processDisclosure(db, client, summaryUrl, discUrl, year) - - // Mark as processed in the subcollection cursor - await backfillRef - .collection(BACKFILL_URLS_COLLECTION) - .doc(urlHash) - .set({ url: discUrl, processedAt: new Date().toISOString() }) - - processedHashes.add(urlHash) - totalNew++ - yearNew++ - } catch (e) { - console.warn(` Failed to process disclosure ${discUrl}:`, e) - } - } + if (env === "local") { + throw new Error( + "backfillLobbying requires --env dev or --env prod " + + "(it writes to a real Firestore project; local emulator not supported yet)" + ) + } - if ((i + 1) % 50 === 0 || i + 1 === summaryUrls.length) { - console.log( - ` [${i + 1}/${ - summaryUrls.length - }] ${yearNew} new disclosures this year` - ) - } - } + const pyArgs = ["--mode", "backfill"] + if (year) pyArgs.push("--year", String(year)) + if (limit) pyArgs.push("--limit", String(limit)) - console.log(` ${year} complete: ${yearNew} new disclosures`) - } + console.log(`Running: python3 ${SCRAPER} ${pyArgs.join(" ")}`) + console.log( + `Firestore project: ${process.env.GCLOUD_PROJECT || "(from ADC)"}` + ) - console.log(`\nbackfillLobbying complete: ${totalNew} new disclosures total`) + await new Promise((resolve, reject) => { + const proc = spawn("python3", [SCRAPER, ...pyArgs], { + stdio: ["ignore", "inherit", "inherit"], + env: { ...process.env } + }) + proc.on("close", code => { + if (code === 0) resolve() + else reject(new Error(`scrape.py exited with code ${code}`)) + }) + proc.on("error", reject) + }) } From 2bcb783f2e2e71631b524f49f5b194eb2d551e63 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 8 Jun 2026 22:05:04 -0400 Subject: [PATCH 005/106] refactor: remove dead TypeScript scraper code from lobbying module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per code review feedback: the TypeScript Firebase Function and backfill script added no value — the portal's TLS fingerprinting requirements mean Node.js cannot reach it, so the TS HTTP layer was non-functional and the backfill script was just a thin subprocess wrapper with no benefit over calling scrape.py directly. Removed: - functions/src/lobbying/scrapeLobbying.ts (broken Cloud Function) - functions/src/lobbying/portal.ts (non-functional TS HTTP layer) - functions/src/lobbying/http/ (unused Python fetch helper) - scripts/firebase-admin/backfillLobbying.ts (shell wrapper, no value) - scrapeLobbying export from functions/src/index.ts Kept: - functions/src/lobbying/types.ts — Firestore schema; imported by frontend - functions/src/lobbying/normalize.ts — normalization pipeline - lobbying-scraper/ — the working Cloud Run container (unchanged) The historical backfill is now run directly: python3 lobbying-scraper/scrape.py --mode backfill Co-Authored-By: Claude Sonnet 4.6 --- docs/lobbying-disclosure-ingestion.md | 108 ++-- functions/src/index.ts | 2 - functions/src/lobbying/http/.gitignore | 3 - functions/src/lobbying/http/fetch.py | 81 --- functions/src/lobbying/http/requirements.txt | 1 - functions/src/lobbying/index.ts | 10 - functions/src/lobbying/portal.ts | 553 ------------------- functions/src/lobbying/scrapeLobbying.ts | 274 --------- lobbying-scraper/Dockerfile | 4 +- scripts/firebase-admin/backfillLobbying.ts | 64 --- 10 files changed, 66 insertions(+), 1034 deletions(-) delete mode 100644 functions/src/lobbying/http/.gitignore delete mode 100644 functions/src/lobbying/http/fetch.py delete mode 100644 functions/src/lobbying/http/requirements.txt delete mode 100644 functions/src/lobbying/portal.ts delete mode 100644 functions/src/lobbying/scrapeLobbying.ts delete mode 100644 scripts/firebase-admin/backfillLobbying.ts diff --git a/docs/lobbying-disclosure-ingestion.md b/docs/lobbying-disclosure-ingestion.md index 264c77c52..51719f342 100644 --- a/docs/lobbying-disclosure-ingestion.md +++ b/docs/lobbying-disclosure-ingestion.md @@ -298,64 +298,86 @@ them. No bill-level compensation amount is available for these years. ``` functions/src/lobbying/ - types.ts — Runtypes definitions for LobbyingRegistrant, LobbyingFiling - normalize.ts — Entity name normalization pipeline - portal.ts — Reference implementation (HTTP layer not used in production) - scrapeLobbying.ts — Reference implementation (superseded by Cloud Run container) - index.ts — Re-exports + types.ts — Runtypes schema definitions for LobbyingRegistrant, LobbyingFiling + normalize.ts — Entity name normalization pipeline (also used client-side) + index.ts — Re-exports lobbying-scraper/ - scrape.py — Entry point: --mode weekly (incremental) | --mode backfill - portal.py — HTTP + HTML parsing - normalize.py — Port of normalize.ts - writer.py — Firestore document construction + writes - requirements.txt — requests, beautifulsoup4, google-cloud-firestore - Dockerfile — Python 3.12-slim image + scrape.py — Entry point: --mode weekly (incremental) | --mode backfill + portal.py — HTTP + HTML parsing + normalize.py — Port of normalize.ts + writer.py — Firestore document construction + writes + requirements.txt — requests, beautifulsoup4, google-cloud-firestore + Dockerfile — Python 3.12-slim image ``` +The TypeScript lobbying module (`functions/src/lobbying/`) contains only the +schema types and normalization logic. There is no TypeScript scraper or +Firebase Function — ingestion is handled entirely by the Cloud Run container. +This follows the same pattern as the MCP server and avoids the complexity of +running multiple language runtimes in the same Firebase Functions deployment. + --- ## Deploying the Cloud Run Container -Follows the same pattern as the MCP server. Requires the -`maple-lobbying-scraper` Artifact Registry repository to exist. +Follows the same pattern as the MCP server. The Artifact Registry repo +(`maple-lobbying`) and Cloud Run job (`maple-lobbying-scraper`) are already +created in `digital-testimony-dev`. ```bash cd lobbying-scraper IMAGE=us-central1-docker.pkg.dev/digital-testimony-dev/maple-lobbying/scraper:latest docker build -t $IMAGE . && docker push $IMAGE -gcloud run jobs create maple-lobbying-scraper \ +gcloud run jobs update maple-lobbying-scraper \ --image=$IMAGE \ --project=digital-testimony-dev \ + --region=us-central1 +``` + +For a new project (prod), create the job first: + +```bash +gcloud artifacts repositories create maple-lobbying \ + --repository-format=docker --location=us-central1 --project= + +gcloud run jobs create maple-lobbying-scraper \ + --image=$IMAGE \ + --project= \ --region=us-central1 \ - --service-account=@digital-testimony-dev.iam.gserviceaccount.com + --task-timeout=30m \ + --max-retries=0 -# Schedule weekly via Cloud Scheduler +# Schedule weekly (Mondays 6am UTC) gcloud scheduler jobs create http maple-lobbying-weekly \ --schedule="0 6 * * 1" \ - --uri="https://us-central1-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/digital-testimony-dev/jobs/maple-lobbying-scraper:run" \ + --uri="https://us-central1-run.googleapis.com/apis/run.googleapis.com/v1/namespaces//jobs/maple-lobbying-scraper:run" \ --http-method=POST \ - --oauth-service-account-email=@digital-testimony-dev.iam.gserviceaccount.com \ + --oauth-service-account-email=@.iam.gserviceaccount.com \ --location=us-central1 ``` -## Historical Backfill (Admin Script) +## Historical Backfill -Ingests all historical filings from 2005 to the present. Delegates to -`scrape.py --mode backfill` via subprocess. Resumable — the subcollection -cursor at `/scrapers/lobbyingBackfill/processedUrls` tracks what has been -processed. Run directly on the machine (requires `lobbying-scraper/` deps -installed or the `maple-2025` conda environment). +Runs `scrape.py --mode backfill` directly. Resumable — the subcollection +cursor at `/scrapers/lobbyingBackfill/processedUrls` tracks progress. +Requires `lobbying-scraper/` deps or the `maple-2025` conda environment. ```bash +cd lobbying-scraper + +# Test a single year with no writes GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ - yarn firebase-admin run-script backfillLobbying --env dev + python3 scrape.py --mode backfill --year 2024 --limit 3 --dry-run -# Or call scrape.py directly for more control: -cd lobbying-scraper -python3 scrape.py --mode backfill --year 2024 --limit 3 --dry-run -python3 scrape.py --mode backfill --year 2024 +# Run a single year for real +GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ + python3 scrape.py --mode backfill --year 2024 + +# Full history (2005-present, resumable) +GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ + python3 scrape.py --mode backfill ``` --- @@ -400,22 +422,18 @@ export { scrapeLobbying } from "./lobbying" ## Implementation Status -| File | Status | Notes | -| -------------------------------------------- | ------- | ---------------------------------------------------------- | -| `functions/src/lobbying/types.ts` | ✅ Done | TypeScript type definitions; source of truth for schema | -| `functions/src/lobbying/normalize.ts` | ✅ Done | Normalization pipeline (also ported to `normalize.py`) | -| `functions/src/lobbying/portal.ts` | ✅ Done | Kept for reference; HTTP layer not used (see architecture) | -| `functions/src/lobbying/scrapeLobbying.ts` | ✅ Done | Not deployed; superseded by Cloud Run container | -| `functions/src/lobbying/index.ts` | ✅ Done | | -| `functions/src/index.ts` (export) | ✅ Done | | -| `firestore.rules` | ✅ Done | | -| `firestore.indexes.json` | ✅ Done | | -| `lobbying-scraper/normalize.py` | ✅ Done | Port of normalize.ts | -| `lobbying-scraper/portal.py` | ✅ Done | HTTP + HTML parsing | -| `lobbying-scraper/writer.py` | ✅ Done | Firestore document construction | -| `lobbying-scraper/scrape.py` | ✅ Done | Entry point; `--mode weekly` and `--mode backfill` | -| `lobbying-scraper/Dockerfile` | ✅ Done | Python 3.12 slim | -| `scripts/firebase-admin/backfillLobbying.ts` | ✅ Done | Calls `scrape.py --mode backfill` as subprocess | +| File | Status | Notes | +| ------------------------------------- | ------- | -------------------------------------------------------- | +| `functions/src/lobbying/types.ts` | ✅ Done | Firestore schema types; imported by future frontend code | +| `functions/src/lobbying/normalize.ts` | ✅ Done | Normalization pipeline; also ported to `normalize.py` | +| `functions/src/lobbying/index.ts` | ✅ Done | Re-exports types and normalize | +| `firestore.rules` | ✅ Done | | +| `firestore.indexes.json` | ✅ Done | | +| `lobbying-scraper/normalize.py` | ✅ Done | Port of normalize.ts | +| `lobbying-scraper/portal.py` | ✅ Done | HTTP + HTML parsing | +| `lobbying-scraper/writer.py` | ✅ Done | Firestore document construction | +| `lobbying-scraper/scrape.py` | ✅ Done | Entry point; `--mode weekly` and `--mode backfill` | +| `lobbying-scraper/Dockerfile` | ✅ Done | Python 3.12-slim; deployed to Cloud Run | ### Document ID scheme diff --git a/functions/src/index.ts b/functions/src/index.ts index 6c52b78c1..641255bf4 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -60,8 +60,6 @@ export { export { transcription } from "./webhooks" -export { scrapeLobbying } from "./lobbying" - export * from "./triggerPubsubFunction" // Export the health check last so it is loaded last. diff --git a/functions/src/lobbying/http/.gitignore b/functions/src/lobbying/http/.gitignore deleted file mode 100644 index d0ee3b17c..000000000 --- a/functions/src/lobbying/http/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -venv/ -__pycache__/ -*.pyc diff --git a/functions/src/lobbying/http/fetch.py b/functions/src/lobbying/http/fetch.py deleted file mode 100644 index 4e6c2c4ec..000000000 --- a/functions/src/lobbying/http/fetch.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Minimal HTTP fetch helper for the lobbying portal. - -Handles the portal's session cookie requirements that standard Node.js HTTP -clients cannot satisfy due to TLS-layer constraints. - -Usage: - python3 fetch.py --url URL [--method GET|POST] [--jar PATH] - -POST body is read from stdin as application/x-www-form-urlencoded. -Cookies are persisted to/from the JSON file at --jar so the session survives -across multiple subprocess invocations. -HTML response is written to stdout. Errors go to stderr with exit code 1. -""" - -import argparse -import json -import sys -from pathlib import Path - -import requests - -_UA = ( - "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) " - "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148" -) - - -def main() -> None: - p = argparse.ArgumentParser() - p.add_argument("--url", required=True) - p.add_argument("--method", default="GET", choices=["GET", "POST"]) - p.add_argument("--jar", default=None, help="Path to JSON cookie-jar file") - args = p.parse_args() - - session = requests.Session() - session.headers.update( - { - "User-Agent": _UA, - "Accept": "*/*", - "Accept-Encoding": "gzip, deflate, br", - "Connection": "keep-alive", - } - ) - - if args.jar: - jar = Path(args.jar) - if jar.exists(): - try: - session.cookies.update(json.loads(jar.read_text())) - except Exception as e: - print(f"warning: could not read cookie jar: {e}", file=sys.stderr) - - try: - if args.method == "POST": - body = sys.stdin.buffer.read() - resp = session.post( - args.url, - data=body, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - timeout=180, - ) - else: - resp = session.get(args.url, timeout=60) - - resp.raise_for_status() - - if args.jar: - Path(args.jar).write_text(json.dumps(dict(session.cookies))) - - sys.stdout.buffer.write(resp.content) - - except requests.exceptions.HTTPError as e: - print(f"HTTP error {e.response.status_code}: {args.url}", file=sys.stderr) - sys.exit(1) - except requests.exceptions.RequestException as e: - print(f"request failed: {e}", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/functions/src/lobbying/http/requirements.txt b/functions/src/lobbying/http/requirements.txt deleted file mode 100644 index b18d51347..000000000 --- a/functions/src/lobbying/http/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -requests>=2.28 diff --git a/functions/src/lobbying/index.ts b/functions/src/lobbying/index.ts index 5e594cb34..6d039ae51 100644 --- a/functions/src/lobbying/index.ts +++ b/functions/src/lobbying/index.ts @@ -1,12 +1,2 @@ -export { scrapeLobbying } from "./scrapeLobbying" export * from "./types" export { normalizeEntityName } from "./normalize" -export { - constructBillId, - fetchDisclosureDetail, - fetchDisclosureMeta, - fetchSummaryLinks, - makePortalClient, - normalizeChamber, - yearToGeneralCourt -} from "./portal" diff --git a/functions/src/lobbying/portal.ts b/functions/src/lobbying/portal.ts deleted file mode 100644 index 64d65831b..000000000 --- a/functions/src/lobbying/portal.ts +++ /dev/null @@ -1,553 +0,0 @@ -/** - * HTTP client and HTML parser for the MA Secretary of State lobbying portal. - * - * Portal: https://www.sec.state.ma.us/LobbyistPublicSearch/ - * - * Page flow: - * 1. Search POST → grdvSearchResultByTypeAndCategory table - * One row per registrant; each row has a Summary.aspx link. - * 2. Summary.aspx → registrant name/year/type + CompleteDisclosure links - * 3. CompleteDisclosure.aspx → per-client compensation + per-client bill activity - * - * Two disclosure HTML formats exist: - * Modern (≥~2013): per-client compensation in grdvClientPaidToEntity; - * per-client bill tables as grdvActivitiesNew{year}_{n}. - * Legacy (<~2013): total salary in grdvSalaryPaid (no client breakdown); - * all bill activity in a single grdvActivities table. - */ - -import axios, { AxiosInstance } from "axios" -import { JSDOM } from "jsdom" -import { sha256 } from "js-sha256" -import { CookieJar } from "tough-cookie" -import { - CHAMBER_PREFIXES, - LEGACY_CHAMBER_MAP, - LEGACY_TOTAL_CLIENT, - LobbyingChamber -} from "./types" - -// ─── Constants ────────────────────────────────────────────────────────────── - -const BASE_URL = "https://www.sec.state.ma.us/LobbyistPublicSearch/" -const SEARCH_URL = BASE_URL + "Default.aspx" -const REQUEST_DELAY_MS = 1000 -const MAX_RETRIES = 5 - -const IPAD_UA = - "Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) " + - "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148" - -const FIRST_GC = 183 -const FIRST_GC_START_YEAR = 2003 - -// ─── Public types ─────────────────────────────────────────────────────────── - -export interface RawCompensation { - clientName: string - amount: number | null -} - -export interface RawBillActivity { - clientName: string - chamber: LobbyingChamber - rawBillNumber: string - billId: string | null // pre-computed from chamber + rawBillNumber - activityTitle: string - position: string - amount: number | null -} - -export interface DisclosureMeta { - entityName: string - year: number | null - /** Portal reg_type mapped to our vocabulary */ - regType: "Lobbyist" | "Employer" - disclosureUrls: string[] -} - -export interface DisclosureDetail { - compensation: RawCompensation[] - bills: RawBillActivity[] -} - -// ─── HTTP helpers ──────────────────────────────────────────────────────────── - -/** - * Create an axios instance pre-configured for the MA SoS portal. - * - * Includes a cookie jar via interceptors so ASP.NET session state (ViewState, - * anti-forgery tokens) is preserved across the GET → POST page flow without - * requiring the axios-cookiejar-support package. - */ -export interface PortalClient { - jar: CookieJar - client: AxiosInstance -} - -/** - * Create a portal client pre-configured for the MA SoS portal. - * - * Uses maxRedirects: 0 so our manual redirect loop (inside getHtml / postHtml) - * can extract Set-Cookie headers at each hop before following. This is necessary - * because the portal is protected by Incapsula, which issues a 302 challenge on - * first contact and requires the session cookies to be sent on the retried request. - * Axios's built-in redirect following happens before response interceptors fire, - * so the cookies from the challenge response are never captured automatically. - */ -export function makePortalClient(): PortalClient { - const jar = new CookieJar() - const client = axios.create({ - headers: { - "User-Agent": IPAD_UA, - Accept: "*/*", - "Accept-Encoding": "gzip, deflate, br", - Connection: "keep-alive" - }, - timeout: 60_000, - maxRedirects: 10, // let axios handle ordinary redirects; only Incapsula challenges need manual handling - validateStatus: s => s < 500 // surface 4xx so we can log them - }) - return { jar, client } -} - -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) -} - -function cookieHeader(jar: CookieJar, url: string): string { - return jar - .getCookiesSync(url) - .map(c => c.cookieString()) - .join("; ") -} - -function saveCookies( - jar: CookieJar, - url: string, - headers: Record -): void { - const raw = headers["set-cookie"] - if (!raw) return - const list = Array.isArray(raw) ? raw : [raw] - for (const c of list) jar.setCookieSync(c, url) -} - -async function getHtml( - pc: PortalClient, - url: string, - retries = MAX_RETRIES -): Promise { - for (let attempt = 0; attempt < retries; attempt++) { - await sleep( - attempt === 0 ? REQUEST_DELAY_MS : REQUEST_DELAY_MS * 2 ** attempt - ) - try { - const res = await pc.client.get(url, { - responseType: "text", - headers: { Cookie: cookieHeader(pc.jar, url) } - }) - saveCookies( - pc.jar, - url, - res.headers as Record - ) - if (res.status >= 400) throw new Error(`HTTP ${res.status} for ${url}`) - return new JSDOM(res.data).window.document - } catch (e) { - if (attempt === retries - 1) throw e - if (axios.isAxiosError(e)) continue - throw e - } - } - throw new Error("unreachable") -} - -async function postHtml( - pc: PortalClient, - url: string, - data: Record, - retries = MAX_RETRIES -): Promise { - const body = new URLSearchParams(data).toString() - for (let attempt = 0; attempt < retries; attempt++) { - await sleep( - attempt === 0 ? REQUEST_DELAY_MS : REQUEST_DELAY_MS * 2 ** attempt - ) - try { - const res = await pc.client.post(url, body, { - responseType: "text", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Cookie: cookieHeader(pc.jar, url) - }, - timeout: 180_000 - }) - saveCookies( - pc.jar, - url, - res.headers as Record - ) - if (res.status >= 400) throw new Error(`HTTP ${res.status} for ${url}`) - return new JSDOM(res.data).window.document - } catch (e) { - if (attempt === retries - 1) throw e - if (axios.isAxiosError(e)) continue - throw e - } - } - throw new Error("unreachable") -} - -// ─── Year / General Court helpers ──────────────────────────────────────────── - -export function yearToGeneralCourt(year: number): number { - return FIRST_GC + Math.floor((year - FIRST_GC_START_YEAR) / 2) -} - -// ─── Chamber normalization ──────────────────────────────────────────────────── - -/** Normalize raw portal chamber string to a canonical LobbyingChamber value. */ -export function normalizeChamber(raw: string): LobbyingChamber { - const trimmed = raw.trim() - if (LEGACY_CHAMBER_MAP[trimmed]) return LEGACY_CHAMBER_MAP[trimmed] - const known: LobbyingChamber[] = [ - "House Bill", - "Senate Bill", - "House Docket", - "Senate Docket", - "Executive" - ] - if (known.includes(trimmed as LobbyingChamber)) - return trimmed as LobbyingChamber - return "Other" -} - -/** - * Construct the MAPLE-compatible billId from the portal's chamber + raw integer. - * - * The portal stores bill numbers as bare integers; the chamber prefix is what - * distinguishes H1234 from S1234. Returns null for Executive and Other chambers - * where no bill join is possible. - */ -export function constructBillId( - chamber: LobbyingChamber, - rawBillNumber: string -): string | null { - const prefix = CHAMBER_PREFIXES[chamber] - if (!prefix) return null - const n = parseInt(rawBillNumber, 10) - if (isNaN(n)) return null - return `${prefix}${n}` -} - -// ─── Document ID generation ─────────────────────────────────────────────────── - -/** Stable Firestore document ID for a registrant (entity + year). */ -export function registrantId(entityName: string, year: number): string { - return sha256(`${year}|${entityName}`).slice(0, 40) -} - -/** - * Stable Firestore document ID for a filing. - * - * Uses a hash of the logical deduplication key. For null-bill rows (billId is - * null) the chamber is included in the key to avoid merging executive null rows - * with legislative null rows. - */ -export function filingId( - entityName: string, - clientName: string, - chamber: LobbyingChamber, - billId: string | null, - generalCourt: number, - position: string -): string { - const key = [ - entityName, - clientName, - chamber, - billId ?? "__null__", - generalCourt, - position - ].join("|") - return sha256(key).slice(0, 40) -} - -// ─── Amount parsing ─────────────────────────────────────────────────────────── - -function parseAmount(text: string): number | null { - const cleaned = text.replace(/[$,]/g, "").trim() - const n = parseFloat(cleaned) - return isNaN(n) ? null : n -} - -// ─── Portal scraping functions ──────────────────────────────────────────────── - -/** Extract ASP.NET WebForms ViewState hidden inputs from a page. */ -function extractViewState(doc: Document): Record { - const fields: Record = {} - doc.querySelectorAll('input[type="hidden"]').forEach(el => { - const input = el as HTMLInputElement - if (input.name) fields[input.name] = input.value ?? "" - }) - return fields -} - -/** - * Fetch all Summary.aspx URLs for a given year. - * Sends a single search POST with page size 20000 to get all registrants at once. - */ -export async function fetchSummaryLinks( - pc: PortalClient, - year: number -): Promise { - const searchPage = await getHtml(pc, SEARCH_URL) - const vs = extractViewState(searchPage) - - const postData: Record = { - ...vs, - __EVENTTARGET: "", - __EVENTARGUMENT: "", - ctl00$ContentPlaceHolder1$Search: "rdbSearchByType", - ctl00$ContentPlaceHolder1$ucSearchCriteriaByType$ddlYear: String(year), - ctl00$ContentPlaceHolder1$ucSearchCriteriaByType$txtN_ame: "", - ctl00$ContentPlaceHolder1$ucSearchCriteriaByType$lddSearchType$DropDown: - "3", - ctl00$ContentPlaceHolder1$ucSearchCriteriaByType$drpType: "L", - ctl00$ContentPlaceHolder1$drpPageSize: "20000", - ctl00$ContentPlaceHolder1$btnSearch: "Search" - } - - const resultsPage = await postHtml(pc, SEARCH_URL, postData) - - const table = resultsPage.querySelector( - '[id*="grdvSearchResultByTypeAndCategory"]' - ) - if (!table) return [] - - const links: string[] = [] - table.querySelectorAll("a[href]").forEach(el => { - const href = (el as HTMLAnchorElement).href - if (href && href.includes("Summary.aspx")) { - // href from JSDOM is already absolute when base is set; handle both cases - const url = href.startsWith("http") ? href : BASE_URL + href - links.push(url) - } - }) - return links -} - -/** - * Fetch a Summary.aspx page and return the registrant metadata + disclosure URLs. - */ -export async function fetchDisclosureMeta( - pc: PortalClient, - summaryUrl: string -): Promise { - const doc = await getHtml(pc, summaryUrl) - - const text = (id: string) => { - const el = doc.getElementById(id) - return el?.textContent?.trim() ?? "" - } - - const entityName = text("ContentPlaceHolder1_lblRegistrantName") - const yearText = text("ContentPlaceHolder1_lblYear") - const regTypeRaw = text("ContentPlaceHolder1_lblRegType") - - const year = parseInt(yearText, 10) - const regType: "Lobbyist" | "Employer" = regTypeRaw.includes("Entity") - ? "Employer" - : "Lobbyist" - - const disclosureUrls: string[] = [] - doc.querySelectorAll("a[href]").forEach(el => { - const raw = (el as HTMLAnchorElement).getAttribute("href") ?? "" - if (raw.includes("CompleteDisclosure")) { - const url = raw.startsWith("http") ? raw : BASE_URL + raw - disclosureUrls.push(url) - } - }) - - return { - entityName, - year: isNaN(year) ? null : year, - regType, - disclosureUrls - } -} - -/** - * Parse a CompleteDisclosure.aspx page. - * - * Handles both modern (≥~2013) and legacy (<~2013) HTML layouts. - */ -export async function fetchDisclosureDetail( - pc: PortalClient, - discUrl: string, - year: number -): Promise { - const doc = await getHtml(pc, discUrl) - const compensation: RawCompensation[] = [] - const bills: RawBillActivity[] = [] - - // ── Modern format ────────────────────────────────────────────────────────── - const compTable = doc.querySelector('[id*="grdvClientPaidToEntity"]') - if (compTable) { - compTable - .querySelectorAll("tr.GridRow, tr.GridAlternatingRow") - .forEach(row => { - const cells = Array.from(row.querySelectorAll("td")).map( - td => td.textContent?.trim() ?? "" - ) - if (cells.length >= 2) { - compensation.push({ - clientName: cells[0], - amount: parseAmount(cells[1]) - }) - } - }) - } - - // Bill activity tables — one per client per reporting period. Two ID patterns: - // 2014–2018: …rptActivityNew_grdvActivitiesNew_0 (no year suffix) - // 2019+: …rptActivityNew2020_grdvActivitiesNew2020_0 (year suffix) - doc.querySelectorAll('[id*="grdvActivitiesNew"]').forEach(actTable => { - // The client name lives in the nearest preceding span with lblClientName - let clientName = "" - let node: Element | null = actTable - while ((node = node.previousElementSibling ?? node.parentElement)) { - const span = node.id?.includes("lblClientName") - ? node - : node.querySelector?.('[id*="lblClientName"]') - if (span) { - clientName = span.textContent?.trim() ?? "" - break - } - if (node === node.parentElement) break - } - - actTable - .querySelectorAll("tr.GridRow, tr.GridAlternatingRow") - .forEach(row => { - const cells = Array.from(row.querySelectorAll("td")).map( - td => td.textContent?.trim() ?? "" - ) - // Columns: House/Senate, Bill Number, Bill title, Position, Amount, Direct business - if (cells.length < 4) return - const chamber = normalizeChamber(cells[0]) - const rawBillNumber = cells[1] - const billId = constructBillId(chamber, rawBillNumber) - bills.push({ - clientName, - chamber, - rawBillNumber, - billId, - activityTitle: cells[2] ?? "", - position: cells[3] ?? "", - amount: cells.length > 4 ? parseAmount(cells[4]) : null - }) - }) - }) - - if (compTable || bills.length > 0) { - return { compensation, bills } - } - - // ── Legacy format (<~2013) ───────────────────────────────────────────────── - const salaryTable = doc.querySelector('[id*="grdvSalaryPaid"]') - if (salaryTable) { - let total = 0 - salaryTable.querySelectorAll("tr").forEach(row => { - const cells = Array.from(row.querySelectorAll("td")).map( - td => td.textContent?.trim() ?? "" - ) - if (cells.length >= 2 && !cells[0].includes("Total")) { - const amt = parseAmount(cells[1]) - if (amt !== null) total += amt - } - }) - if (total > 0) { - compensation.push({ clientName: LEGACY_TOTAL_CLIENT, amount: total }) - } - } - - // Legacy bill activity: single grdvActivities table. Three known column layouts: - // 2009 4-col: Date | Bill+Title | Lobbyist | Client - // 2010+ individual 5-col: Activity | Position | DirectBiz | Client | Compensation - // 2010+ entity 6-col: Activity | Lobbyist | Position | DirectBiz | Client | Compensation - const actTable = doc.querySelector('[id$="grdvActivities"]') - if (actTable) { - const allRows = Array.from(actTable.querySelectorAll("tr")) - const headerCells = Array.from( - allRows[0]?.querySelectorAll("th, td") ?? [] - ).map(el => el.textContent?.trim() ?? "") - - let billCol = 1 - let positionCol: number | null = null - let clientCol = 3 - - if (headerCells[0]?.includes("Activity")) { - if (headerCells[1]?.includes("Lobbyist")) { - // 6-col entity layout - billCol = 0 - positionCol = 2 - clientCol = 4 - } else { - // 5-col individual layout - billCol = 0 - positionCol = 1 - clientCol = 3 - } - } - - const chamberMap: Record = { - H: "House Bill", - S: "Senate Bill", - HD: "House Docket", - SD: "Senate Docket" - } - - allRows.slice(1).forEach(row => { - const cells = Array.from(row.querySelectorAll("td")).map( - td => td.textContent?.trim() ?? "" - ) - if (cells.length <= Math.max(billCol, clientCol)) return - - const billCell = cells[billCol] - const skipValues = new Set([ - "Activity or Bill No and Title", - "N/A", - "None", - "", - "Total amount" - ]) - if (!billCell || skipValues.has(billCell)) return - - const parts = billCell.split(/\s+/) - const billNo = parts[0] - const activityTitle = parts.slice(1).join(" ") - const match = billNo.match(/^([A-Z]+)(\d+)$/) - if (!match) return - - const [, prefix, number] = match - const chamber: LobbyingChamber = chamberMap[prefix] ?? "Other" - const billId = constructBillId(chamber, number) - const position = positionCol !== null ? cells[positionCol] ?? "" : "" - const clientName = cells[clientCol] ?? "" - - bills.push({ - clientName, - chamber, - rawBillNumber: number, - billId, - activityTitle, - position, - amount: null - }) - }) - } - - return { compensation, bills } -} diff --git a/functions/src/lobbying/scrapeLobbying.ts b/functions/src/lobbying/scrapeLobbying.ts deleted file mode 100644 index 7a6140e8e..000000000 --- a/functions/src/lobbying/scrapeLobbying.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { logger } from "firebase-functions" -import { runWith } from "firebase-functions/v1" -import { db, Timestamp } from "../firebase" -import type { Database } from "../types" -import { normalizeEntityName } from "./normalize" -import { - fetchDisclosureDetail, - fetchDisclosureMeta, - fetchSummaryLinks, - filingId, - makePortalClient, - registrantId, - yearToGeneralCourt -} from "./portal" -import { - FILINGS_COLLECTION, - FIRST_LOBBYING_YEAR, - LobbyingFiling, - LobbyingRegistrant, - REGISTRANTS_COLLECTION, - SCRAPER_DOC -} from "./types" - -/** - * Scraper state stored in Firestore at /scrapers/lobbying. - * - * processedDiscUrls: disc URLs already fetched; skip on re-runs. - * summaryDiscCache: maps summaryUrl → its known disc URLs so we can skip - * summary page GETs for registrants with no new filings. - */ -interface ScraperState { - processedDiscUrls: string[] - summaryDiscCache: Record -} - -/** - * Maximum number of new disclosure pages to fetch per function invocation. - * Each page takes ~1s; this keeps the run well within the 540s timeout. - * Remaining work is picked up on the next scheduled run. - */ -const MAX_DISCLOSURES_PER_RUN = 200 - -/** - * Scrape lobbying disclosure data for the current and prior calendar year. - * - * Runs every 24 hours. New filers arrive semi-annually so daily polling is - * more than sufficient for steady-state freshness. For initial historical - * ingestion (2005-present) use the backfillLobbying admin script instead. - * - * Progress is checkpointed to Firestore after every disclosure page so the - * function is fully resumable if it times out or is interrupted. - */ -export const scrapeLobbying = runWith({ timeoutSeconds: 540, maxInstances: 1 }) - .pubsub.schedule("every 24 hours") - .onRun(async () => { - const currentYear = new Date().getFullYear() - const years = [currentYear, currentYear - 1] - - const scraperRef = db.doc(SCRAPER_DOC) - const scraperDoc = await scraperRef.get() - const state: ScraperState = { - processedDiscUrls: scraperDoc.data()?.processedDiscUrls ?? [], - summaryDiscCache: scraperDoc.data()?.summaryDiscCache ?? {} - } - const processedSet = new Set(state.processedDiscUrls) - const summaryCache: Record = state.summaryDiscCache - - const client = makePortalClient() - let newDiscCount = 0 - - for (const year of years) { - if (newDiscCount >= MAX_DISCLOSURES_PER_RUN) break - - logger.info(`scrapeLobbying: fetching summary links for ${year}`) - let summaryUrls: string[] - try { - summaryUrls = await fetchSummaryLinks(client, year) - } catch (e) { - logger.error( - `scrapeLobbying: failed to fetch summary links for ${year}`, - e - ) - continue - } - logger.info( - `scrapeLobbying: ${summaryUrls.length} registrants for ${year}` - ) - - for (const summaryUrl of summaryUrls) { - if (newDiscCount >= MAX_DISCLOSURES_PER_RUN) break - - // Use cached disc URLs when available to avoid re-fetching summary pages. - // For current year we always re-check (new filings arrive mid-year). - let discUrls = summaryCache[summaryUrl] - if (!discUrls || year === currentYear) { - try { - const meta = await fetchDisclosureMeta(client, summaryUrl) - discUrls = meta.disclosureUrls - - // Write registrant doc (upsert); don't wait for individual writes to - // finish — use a bulkWriter for the doc contents but checkpoint the - // scraper state separately so interruptions are recoverable. - if (meta.entityName && meta.year) { - await writeRegistrant( - db, - meta.entityName, - meta.year, - meta.regType, - discUrls - ) - } - - summaryCache[summaryUrl] = discUrls - await scraperRef.set( - { summaryDiscCache: summaryCache }, - { merge: true } - ) - } catch (e) { - logger.warn( - `scrapeLobbying: failed to fetch summary ${summaryUrl}`, - e - ) - continue - } - } - - const newDiscUrls = discUrls.filter(u => !processedSet.has(u)) - if (newDiscUrls.length === 0) continue - - for (const discUrl of newDiscUrls) { - if (newDiscCount >= MAX_DISCLOSURES_PER_RUN) break - try { - await processDisclosure(db, client, summaryUrl, discUrl, year) - processedSet.add(discUrl) - newDiscCount++ - - // Checkpoint after every disclosure so restarts lose at most one page - await scraperRef.set( - { processedDiscUrls: Array.from(processedSet) }, - { merge: true } - ) - } catch (e) { - logger.warn( - `scrapeLobbying: failed to process disclosure ${discUrl}`, - e - ) - } - } - } - } - - logger.info(`scrapeLobbying: processed ${newDiscCount} new disclosures`) - }) - -// ─── Shared write helpers (also used by backfillLobbying) ──────────────────── - -/** - * Write or update a LobbyingRegistrant document. Client list is assembled from - * the disclosure meta; filing documents are written separately per-bill. - */ -export async function writeRegistrant( - database: Database, - entityName: string, - year: number, - regType: "Lobbyist" | "Employer", - disclosureUrls: string[] -): Promise { - const id = registrantId(entityName, year) - const ref = database.collection(REGISTRANTS_COLLECTION).doc(id) - const partial: Omit & { - fetchedAt: FirebaseFirestore.Timestamp - } = { - registrantId: id, - entityName, - entityNameNorm: normalizeEntityName(entityName), - year, - generalCourt: yearToGeneralCourt(year), - regType, - disclosureUrls, - fetchedAt: Timestamp.now() - } - // Merge so repeated runs don't wipe clients accumulated from multiple disclosures - await ref.set(partial, { merge: true }) -} - -/** - * Fetch one CompleteDisclosure page and write LobbyingFiling documents. - * Also updates the registrant's client list. - */ -export async function processDisclosure( - database: Database, - client: ReturnType, - summaryUrl: string, - discUrl: string, - year: number -): Promise { - const meta = await fetchDisclosureMeta(client, summaryUrl) - const detail = await fetchDisclosureDetail(client, discUrl, year) - - const { entityName, regType } = meta - const gc = yearToGeneralCourt(year) - const entityNameNorm = normalizeEntityName(entityName) - const now = Timestamp.now() - - // Update registrant's client list - if (entityName && year) { - const regRef = database - .collection(REGISTRANTS_COLLECTION) - .doc(registrantId(entityName, year)) - - const clients = detail.compensation.map(c => ({ - clientName: c.clientName, - clientNameNorm: normalizeEntityName(c.clientName), - compensation: c.amount - })) - - await regRef.set( - { - registrantId: registrantId(entityName, year), - entityName, - entityNameNorm, - year, - generalCourt: gc, - regType: regType ?? "Lobbyist", - clients, - disclosureUrls: [discUrl], - fetchedAt: now - }, - { merge: true } - ) - } - - // Write one LobbyingFiling doc per bill row - if (detail.bills.length === 0) return - - const writer = database.bulkWriter() - for (const bill of detail.bills) { - const fid = filingId( - entityName, - bill.clientName, - bill.chamber, - bill.billId, - gc, - bill.position - ) - const doc: LobbyingFiling = { - filingId: fid, - entityName, - entityNameNorm, - clientName: bill.clientName, - clientNameNorm: normalizeEntityName(bill.clientName), - year, - generalCourt: gc, - chamber: bill.chamber, - billId: bill.billId, - activityTitle: bill.activityTitle, - position: bill.position, - amount: bill.amount, - fetchedAt: now - } - writer.set(database.collection(FILINGS_COLLECTION).doc(fid), doc, { - merge: false - }) - } - await writer.close() -} - -/** All years to scrape, for use by the backfill script. */ -export function allLobbyingYears(): number[] { - const current = new Date().getFullYear() - const years: number[] = [] - for (let y = FIRST_LOBBYING_YEAR; y <= current; y++) years.push(y) - return years -} diff --git a/lobbying-scraper/Dockerfile b/lobbying-scraper/Dockerfile index 738293459..4b2da65b5 100644 --- a/lobbying-scraper/Dockerfile +++ b/lobbying-scraper/Dockerfile @@ -11,4 +11,6 @@ COPY normalize.py portal.py writer.py scrape.py ./ # Cloud Scheduler invokes the container via HTTP POST to /; handle it minimally. ENV PYTHONUNBUFFERED=1 -CMD ["python3", "scrape.py", "--mode", "weekly"] +# ENTRYPOINT is the fixed executable; CMD provides default args that --args overrides. +ENTRYPOINT ["python3", "scrape.py"] +CMD ["--mode", "weekly"] diff --git a/scripts/firebase-admin/backfillLobbying.ts b/scripts/firebase-admin/backfillLobbying.ts deleted file mode 100644 index a2a66330e..000000000 --- a/scripts/firebase-admin/backfillLobbying.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Backfill lobbying disclosure data from 2005 to the present. - * - * Delegates all HTTP fetching and Firestore writes to the Python scraper in - * lobbying-scraper/. The TypeScript layer handles argument parsing and - * environment setup only. - * - * Usage: - * GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ - * yarn firebase-admin run-script backfillLobbying --env dev - * - * Options (passed through to scrape.py): - * --year NUMBER Only process this year - * --limit NUMBER Max registrants per year (for testing) - * - * Requires: pip install -r lobbying-scraper/requirements.txt - * Or run inside the maple-2025 conda environment. - */ - -import { spawn } from "child_process" -import path from "path" -import { z } from "zod" -import { Script } from "./types" - -const Args = z - .object({ - year: z.number().int().min(2005).optional(), - limit: z.number().int().positive().optional() - }) - .passthrough() - -const SCRAPER = path.resolve(__dirname, "../../lobbying-scraper/scrape.py") - -export const script: Script = async ({ env, args }) => { - const { year, limit } = Args.parse(args) - - if (env === "local") { - throw new Error( - "backfillLobbying requires --env dev or --env prod " + - "(it writes to a real Firestore project; local emulator not supported yet)" - ) - } - - const pyArgs = ["--mode", "backfill"] - if (year) pyArgs.push("--year", String(year)) - if (limit) pyArgs.push("--limit", String(limit)) - - console.log(`Running: python3 ${SCRAPER} ${pyArgs.join(" ")}`) - console.log( - `Firestore project: ${process.env.GCLOUD_PROJECT || "(from ADC)"}` - ) - - await new Promise((resolve, reject) => { - const proc = spawn("python3", [SCRAPER, ...pyArgs], { - stdio: ["ignore", "inherit", "inherit"], - env: { ...process.env } - }) - proc.on("close", code => { - if (code === 0) resolve() - else reject(new Error(`scrape.py exited with code ${code}`)) - }) - proc.on("error", reject) - }) -} From 3689796d40c9a3a48a95a925cad73492f64cf631 Mon Sep 17 00:00:00 2001 From: Sean Moss Date: Tue, 19 May 2026 21:54:36 -0400 Subject: [PATCH 006/106] Link legislator profiles to district pages --- .serena/.gitignore | 2 + .serena/project.yml | 133 +++++++++++++++++ .../LegislatorProfile/DistrictTab.test.tsx | 77 ++++++++++ components/LegislatorProfile/DistrictTab.tsx | 100 +++++++++++++ .../LegislatorProfilePage.tsx | 91 +++++++++++ components/LegislatorProfile/index.ts | 2 + components/ProfilePage/ProfileLegislators.tsx | 20 ++- components/db/districts.ts | 51 +++++++ components/db/index.ts | 1 + components/links.tsx | 2 + functions/src/districts/index.ts | 3 + functions/src/districts/normalize.ts | 116 ++++++++++++++ .../src/districts/parseSecDistricts.test.ts | 141 ++++++++++++++++++ functions/src/districts/parseSecDistricts.ts | 103 +++++++++++++ functions/src/districts/types.ts | 32 ++++ pages/legislators/[court]/[memberCode].tsx | 39 +++++ .../scrapeLegislativeDistricts.ts | 89 +++++++++++ 17 files changed, 997 insertions(+), 5 deletions(-) create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml create mode 100644 components/LegislatorProfile/DistrictTab.test.tsx create mode 100644 components/LegislatorProfile/DistrictTab.tsx create mode 100644 components/LegislatorProfile/LegislatorProfilePage.tsx create mode 100644 components/LegislatorProfile/index.ts create mode 100644 components/db/districts.ts create mode 100644 functions/src/districts/index.ts create mode 100644 functions/src/districts/normalize.ts create mode 100644 functions/src/districts/parseSecDistricts.test.ts create mode 100644 functions/src/districts/parseSecDistricts.ts create mode 100644 functions/src/districts/types.ts create mode 100644 pages/legislators/[court]/[memberCode].tsx create mode 100644 scripts/firebase-admin/scrapeLegislativeDistricts.ts diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 000000000..2e510aff5 --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 000000000..75e8c0642 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,133 @@ +# the name by which the project can be referenced within Serena +project_name: "maple" + + +# list of languages for which language servers are started; choose from: +# al angular ansible bash clojure +# cpp cpp_ccls crystal csharp csharp_omnisharp +# dart elixir elm erlang fortran +# fsharp go groovy haskell haxe +# hlsl html java json julia +# kotlin lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor powershell python +# python_jedi python_ty r rego ruby +# ruby_solargraph rust scala scss solidity +# svelte swift systemverilog terraform toml +# typescript typescript_vts vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- typescript + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries. +# Currently supported for: TypeScript. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +additional_workspace_folders: [] + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +fixed_tools: [] + +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. +# This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] diff --git a/components/LegislatorProfile/DistrictTab.test.tsx b/components/LegislatorProfile/DistrictTab.test.tsx new file mode 100644 index 000000000..3f82e8983 --- /dev/null +++ b/components/LegislatorProfile/DistrictTab.test.tsx @@ -0,0 +1,77 @@ +import "@testing-library/jest-dom" +import { render, screen } from "@testing-library/react" +import { DistrictTab } from "./DistrictTab" +import type { District } from "components/db" + +const baseDistrict: District = { + id: "house-9-hampden", + branch: "House", + district: "9th Hampden", + sourceDistrict: "Ninth Hampden", + sourceUrl: "https://example.test/districts", + fetchedAt: {} as any, + municipalities: [] +} + +describe("DistrictTab", () => { + it("renders full-town-only districts without subdivision chips", () => { + render( + + ) + + expect(screen.getAllByText("9th Hampden District")).toHaveLength(2) + expect( + screen.getByText("West Springfield & Springfield") + ).toBeInTheDocument() + expect(screen.queryByText(/Ward/)).not.toBeInTheDocument() + expect( + screen.getByText("Source: MA Secretary of the Commonwealth district data") + ).toBeInTheDocument() + }) + + it("renders subdivision chips and prefixes them when multiple municipalities are split", () => { + render( + + ) + + expect( + screen.getByText("West Springfield: Ward 1 Precinct C1") + ).toBeInTheDocument() + expect( + screen.getByText("Springfield: Ward 8 Precinct A") + ).toBeInTheDocument() + expect( + screen.getByText("Springfield: Ward 8 Precinct B") + ).toBeInTheDocument() + }) + + it("renders a fallback when district data is missing", () => { + render() + + expect( + screen.getByText("District details are not available yet.") + ).toBeInTheDocument() + }) +}) diff --git a/components/LegislatorProfile/DistrictTab.tsx b/components/LegislatorProfile/DistrictTab.tsx new file mode 100644 index 000000000..a00f706b4 --- /dev/null +++ b/components/LegislatorProfile/DistrictTab.tsx @@ -0,0 +1,100 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { faLocationDot } from "@fortawesome/free-solid-svg-icons" +import styled from "styled-components" +import type { District } from "components/db" + +const MapPreview = styled.div` + background: #dfe8fb; + border: 1px solid #d4deef; + border-radius: 8px 8px 0 0; + color: #18358f; + min-height: 300px; +` + +const DistrictCard = styled.section` + border: 1px solid #d9dee5; + border-radius: 8px; + overflow: hidden; +` + +const Chip = styled.span` + border: 1px solid #d9dee5; + border-radius: 999px; + color: #4a5564; + display: inline-flex; + font-size: 1rem; + font-weight: 600; + line-height: 1.2; + padding: 0.55rem 1rem; +` + +function municipalitySummary(district: District) { + return district.municipalities + .map(municipality => municipality.name) + .join(" & ") +} + +function subdivisionChips(district: District) { + const municipalitiesWithSubdivisions = district.municipalities.filter( + municipality => municipality.subdivisions.length > 0 + ) + const shouldPrefix = municipalitiesWithSubdivisions.length > 1 + + return municipalitiesWithSubdivisions.flatMap(municipality => + municipality.subdivisions.map(subdivision => + shouldPrefix ? `${municipality.name}: ${subdivision}` : subdivision + ) + ) +} + +export function DistrictTab({ + district, + loading +}: { + district?: District + loading?: boolean +}) { + if (loading) { + return
Loading district...
+ } + + if (!district) { + return ( +
+ District details are not available yet. +
+ ) + } + + const chips = subdivisionChips(district) + + return ( + <> + + + +

{district.district} District

+

+ Interactive map · Coming soon +

+
+
+

{district.district} District

+

+ {municipalitySummary(district)} +

+ {chips.length > 0 && ( +
+ {chips.map(chip => ( + {chip} + ))} +
+ )} +
+
+

+ Source: MA Secretary of the Commonwealth district data +

+ + ) +} diff --git a/components/LegislatorProfile/LegislatorProfilePage.tsx b/components/LegislatorProfile/LegislatorProfilePage.tsx new file mode 100644 index 000000000..34a0d4ed8 --- /dev/null +++ b/components/LegislatorProfile/LegislatorProfilePage.tsx @@ -0,0 +1,91 @@ +import ErrorPage from "next/error" +import styled from "styled-components" +import { Col, Container, Row, Spinner } from "components/bootstrap" +import { useDistrict, useMember } from "components/db" +import { DistrictTab } from "./DistrictTab" + +const tabs = [ + "Priorities", + "Bills", + "Elections", + "Finance", + "District", + "Her testimony", + "Votes" +] + +const TabButton = styled.button` + background: transparent; + border: 0; + border-bottom: 5px solid transparent; + color: #68707a; + font-size: 1.35rem; + font-weight: 700; + padding: 1.35rem 1.9rem 1.15rem; + + &.active { + border-bottom-color: #18358f; + color: #18358f; + } +` + +export function LegislatorProfilePage({ + court, + memberCode +}: { + court: number + memberCode: string +}) { + const { member, loading: memberLoading } = useMember(court, memberCode) + const { district, loading: districtLoading } = useDistrict( + court, + member?.Branch, + member?.District + ) + + if (memberLoading) { + return ( + + + + ) + } + + if (!member) { + return + } + + return ( + + + +

{member.Name}

+

+ {member.Branch} - {member.District} +

+ +
+
+ {tabs.map(label => ( + + {label} + + ))} +
+
+ +
+
+ ) +} diff --git a/components/LegislatorProfile/index.ts b/components/LegislatorProfile/index.ts new file mode 100644 index 000000000..056729577 --- /dev/null +++ b/components/LegislatorProfile/index.ts @@ -0,0 +1,2 @@ +export * from "./DistrictTab" +export * from "./LegislatorProfilePage" diff --git a/components/ProfilePage/ProfileLegislators.tsx b/components/ProfilePage/ProfileLegislators.tsx index 6f1cc6bd2..3fbacd6e3 100644 --- a/components/ProfilePage/ProfileLegislators.tsx +++ b/components/ProfilePage/ProfileLegislators.tsx @@ -3,6 +3,8 @@ import { ProfileMember } from "../db" import { LabeledIcon, TitledSectionCard } from "../shared" import { Card as MapleCard } from "components/Card" import { useTranslation } from "next-i18next" +import { currentGeneralCourt } from "functions/src/shared" +import { Internal, maple } from "components/links" type ProfileMemberPlus = (ProfileMember & { title: string }) | undefined @@ -23,11 +25,19 @@ const DisplayLegislator = ({ return ( <> {legislator ? ( - + + + ) : (
{t("content.noLegislatorInfo")}
)} diff --git a/components/db/districts.ts b/components/db/districts.ts new file mode 100644 index 000000000..f5b9f35b1 --- /dev/null +++ b/components/db/districts.ts @@ -0,0 +1,51 @@ +import type { Timestamp } from "firebase/firestore" +import { useMemo } from "react" +import { useAsync } from "react-async-hook" +import { districtId } from "functions/src/districts/normalize" +import { loadDoc } from "./common" + +export type DistrictBranch = "House" | "Senate" + +export type DistrictMunicipality = { + name: string + subdivisions: string[] +} + +export type District = { + id: string + branch: DistrictBranch + district: string + sourceDistrict: string + sourceUrl: string + municipalities: DistrictMunicipality[] + fetchedAt: Timestamp +} + +async function getDistrict( + court: number, + branch?: string | null, + district?: string | null +): Promise { + if (branch !== "House" && branch !== "Senate") return undefined + if (!district) return undefined + + return loadDoc( + `/generalCourts/${court}/districts/${districtId(branch, district)}` + ) as Promise +} + +export function useDistrict( + court: number, + branch?: string | null, + district?: string | null +) { + const { loading, result } = useAsync(getDistrict, [court, branch, district]) + + return useMemo( + () => ({ + district: result, + loading + }), + [loading, result] + ) +} diff --git a/components/db/index.ts b/components/db/index.ts index 3fc6a012f..913c78537 100644 --- a/components/db/index.ts +++ b/components/db/index.ts @@ -1,6 +1,7 @@ export * from "./api" export * from "./bills" export * from "./createTableHook" +export * from "./districts" export * from "./members" export * from "./news" export * from "./profile" diff --git a/components/links.tsx b/components/links.tsx index b6ac2cdd1..0482a64fc 100644 --- a/components/links.tsx +++ b/components/links.tsx @@ -75,6 +75,8 @@ export const maple = { ballotQuestion: ({ id }: { id: string }) => `/ballotQuestions/${id}`, bill: ({ court, id }: { court: number; id: string }) => `/bills/${court}/${id}`, + legislator: ({ court, memberCode }: { court: number; memberCode: string }) => + `/legislators/${court}/${memberCode}`, testimony: ({ publishedId }: { publishedId: string }) => `/testimony/${publishedId}`, userTestimony: ({ diff --git a/functions/src/districts/index.ts b/functions/src/districts/index.ts new file mode 100644 index 000000000..5f2634ed3 --- /dev/null +++ b/functions/src/districts/index.ts @@ -0,0 +1,3 @@ +export * from "./normalize" +export * from "./parseSecDistricts" +export * from "./types" diff --git a/functions/src/districts/normalize.ts b/functions/src/districts/normalize.ts new file mode 100644 index 000000000..867c26ed9 --- /dev/null +++ b/functions/src/districts/normalize.ts @@ -0,0 +1,116 @@ +const ordinalWords: Record = { + first: 1, + second: 2, + third: 3, + fourth: 4, + fifth: 5, + sixth: 6, + seventh: 7, + eighth: 8, + ninth: 9, + tenth: 10, + eleventh: 11, + twelfth: 12, + thirteenth: 13, + fourteenth: 14, + fifteenth: 15, + sixteenth: 16, + seventeenth: 17, + eighteenth: 18, + nineteenth: 19 +} + +const tensOrdinalWords: Record = { + twentieth: 20, + thirtieth: 30 +} + +const tensWords: Record = { + twenty: 20, + thirty: 30 +} + +const ordinalSuffix = /^(\d+)(st|nd|rd|th)?$/i + +function parseOrdinalToken(word: string) { + const numericOrdinal = word.match(ordinalSuffix) + if (numericOrdinal) return Number(numericOrdinal[1]) + + return ordinalWords[word] ?? tensOrdinalWords[word] +} + +function normalizeLeadingOrdinal(words: string[]) { + const [firstWord, secondWord] = words + + if (!firstWord) return words + + const ordinal = parseOrdinalToken(firstWord) + if (ordinal) return [String(ordinal), ...words.slice(1)] + + const tens = tensWords[firstWord] + const ones = secondWord ? ordinalWords[secondWord] : undefined + if (tens && ones) return [String(tens + ones), ...words.slice(2)] + + return words +} + +export function normalizeDistrictName(district: string) { + const words = district + .toLowerCase() + .replace(/&/g, " and ") + .replace(/[-–—]/g, " ") + .replace(/[^\w\s]/g, " ") + .replace(/\band\b/g, " ") + .replace(/\s+/g, " ") + .trim() + .split(" ") + .filter(Boolean) + + return normalizeLeadingOrdinal(words).join(" ") +} + +export function districtId(branch: "House" | "Senate", district: string) { + return `${branch.toLowerCase()}-${normalizeDistrictName(district).replace( + /\s+/g, + "-" + )}` +} + +const ordinalSuffixes = ["th", "st", "nd", "rd"] + +function formatOrdinal(number: number) { + const suffix = + number % 100 >= 11 && number % 100 <= 13 + ? "th" + : ordinalSuffixes[number % 10] ?? "th" + + return `${number}${suffix}` +} + +export function displayDistrictName(sourceDistrict: string) { + const words = sourceDistrict.split(/\s+/) + const firstParts = (words[0] ?? "").toLowerCase().split(/[-–—]/) + + if (firstParts.length === 2) { + const normalized = normalizeLeadingOrdinal(firstParts) + if (normalized.length === 1 && /^\d+$/.test(normalized[0])) { + return [formatOrdinal(Number(normalized[0])), ...words.slice(1)].join(" ") + } + } + + const firstOrdinal = parseOrdinalToken( + (words[0] ?? "").toLowerCase().replace(/[^\w]/g, "") + ) + if (firstOrdinal) { + return [formatOrdinal(firstOrdinal), ...words.slice(1)].join(" ") + } + + const secondOrdinal = (words[1] ?? "").toLowerCase().replace(/[^\w]/g, "") + const tens = tensWords[(words[0] ?? "").toLowerCase().replace(/[^\w]/g, "")] + const ones = ordinalWords[secondOrdinal] + if (tens && ones) { + return [formatOrdinal(tens + ones), ...words.slice(2)].join(" ") + } + + return sourceDistrict +} diff --git a/functions/src/districts/parseSecDistricts.test.ts b/functions/src/districts/parseSecDistricts.test.ts new file mode 100644 index 000000000..34bd4c8e2 --- /dev/null +++ b/functions/src/districts/parseSecDistricts.test.ts @@ -0,0 +1,141 @@ +import { + displayDistrictName, + districtId, + normalizeDistrictName +} from "./normalize" +import { parseSecDistricts } from "./parseSecDistricts" + +const sourceUrl = "https://example.test/districts" + +describe("district normalization", () => { + it("normalizes district names across ordinal and punctuation variants", () => { + expect(normalizeDistrictName("Ninth Hampden")).toBe("9 hampden") + expect(normalizeDistrictName("9th Hampden")).toBe("9 hampden") + expect(normalizeDistrictName("Thirty-Seventh Middlesex")).toBe( + "37 middlesex" + ) + expect( + normalizeDistrictName("Berkshire, Hampden, Franklin, and Hampshire") + ).toBe("berkshire hampden franklin hampshire") + }) + + it("builds firestore-safe district ids with the branch", () => { + expect(districtId("House", "9th Hampden")).toBe("house-9-hampden") + expect(districtId("Senate", "Third Bristol and Plymouth")).toBe( + "senate-3-bristol-plymouth" + ) + }) + + it("converts House source headings to member-facing ordinal names", () => { + expect(displayDistrictName("Ninth Hampden")).toBe("9th Hampden") + expect(displayDistrictName("Thirty-Seventh Middlesex")).toBe( + "37th Middlesex" + ) + expect(displayDistrictName("Barnstable, Dukes, and Nantucket")).toBe( + "Barnstable, Dukes, and Nantucket" + ) + }) +}) + +describe("parseSecDistricts", () => { + it("parses Senate h2 district sections and preserves split municipalities", () => { + const html = ` +

Massachusetts Senatorial Districts

+

Third Bristol and Plymouth

+
    +
  • Berkley
  • +
  • Taunton:
    Ward 1 Precincts A, B;
    Ward 2;
  • +
+
+

Plymouth and Barnstable

+
    +
  • Bourne
  • +
  • Falmouth
  • +
+ ` + + expect(parseSecDistricts(html, { branch: "Senate", sourceUrl })).toEqual([ + { + id: "senate-3-bristol-plymouth", + branch: "Senate", + district: "Third Bristol and Plymouth", + sourceDistrict: "Third Bristol and Plymouth", + sourceUrl, + municipalities: [ + { name: "Berkley", subdivisions: [] }, + { + name: "Taunton", + subdivisions: ["Ward 1 Precincts A, B", "Ward 2"] + } + ] + }, + { + id: "senate-plymouth-barnstable", + branch: "Senate", + district: "Plymouth and Barnstable", + sourceDistrict: "Plymouth and Barnstable", + sourceUrl, + municipalities: [ + { name: "Bourne", subdivisions: [] }, + { name: "Falmouth", subdivisions: [] } + ] + } + ]) + }) + + it("parses House county h2 wrappers, district h3s, and cross-county h2 districts", () => { + const html = ` +

Massachusetts Representative Districts

+

Barnstable County

+

First Barnstable

+
    +
  • Brewster
  • +
  • Yarmouth:
    Precincts 1, 2, 3;
  • +
+
+

Barnstable, Dukes, and Nantucket

+
    +
  • Aquinnah
  • +
  • Falmouth:
    Precincts 1, 2, 6;
  • +
+

Middlesex County

+

Thirty-Seventh Middlesex

+
    +
  • Acton:
    Precinct 6A;
  • +
+ ` + + expect(parseSecDistricts(html, { branch: "House", sourceUrl })).toEqual([ + { + id: "house-1-barnstable", + branch: "House", + district: "1st Barnstable", + sourceDistrict: "First Barnstable", + sourceUrl, + municipalities: [ + { name: "Brewster", subdivisions: [] }, + { name: "Yarmouth", subdivisions: ["Precincts 1, 2, 3"] } + ] + }, + { + id: "house-barnstable-dukes-nantucket", + branch: "House", + district: "Barnstable, Dukes, and Nantucket", + sourceDistrict: "Barnstable, Dukes, and Nantucket", + sourceUrl, + municipalities: [ + { name: "Aquinnah", subdivisions: [] }, + { name: "Falmouth", subdivisions: ["Precincts 1, 2, 6"] } + ] + }, + { + id: "house-37-middlesex", + branch: "House", + district: "37th Middlesex", + sourceDistrict: "Thirty-Seventh Middlesex", + sourceUrl, + municipalities: [{ name: "Acton", subdivisions: ["Precinct 6A"] }] + } + ]) + }) +}) diff --git a/functions/src/districts/parseSecDistricts.ts b/functions/src/districts/parseSecDistricts.ts new file mode 100644 index 000000000..ff2b45c99 --- /dev/null +++ b/functions/src/districts/parseSecDistricts.ts @@ -0,0 +1,103 @@ +import { JSDOM } from "jsdom" +import { compact } from "lodash" +import { districtId, displayDistrictName } from "./normalize" +import type { DistrictBranch, ParsedDistrict } from "./types" + +type ParseOptions = { + branch: DistrictBranch + sourceUrl: string +} + +function cleanText(text: string) { + return text + .replace(/\u00a0/g, " ") + .replace(/\s+/g, " ") + .trim() +} + +function isHouseCountyHeading(element: Element) { + return ( + element.tagName.toLowerCase() === "h2" && + / county$/i.test(cleanText(element.textContent ?? "")) + ) +} + +function isDistrictHeading(element: Element, branch: DistrictBranch) { + const tagName = element.tagName.toLowerCase() + if (branch === "Senate") return tagName === "h2" + return ( + (tagName === "h2" || tagName === "h3") && !isHouseCountyHeading(element) + ) +} + +function followingDistrictItems(heading: Element, branch: DistrictBranch) { + const items: HTMLLIElement[] = [] + let element = heading.nextElementSibling + + while (element) { + if (isDistrictHeading(element, branch)) break + if (branch === "House" && isHouseCountyHeading(element)) break + items.push( + ...Array.from(element.querySelectorAll("li")).filter( + (item): item is HTMLLIElement => + item instanceof heading.ownerDocument.defaultView!.HTMLLIElement + ) + ) + element = element.nextElementSibling + } + + return items +} + +function parseMunicipality(text: string) { + const [rawName, ...detailParts] = text.split(":") + const name = cleanText(rawName) + const details = cleanText(detailParts.join(":")) + const subdivisions = compact( + details + .split(";") + .map(detail => detail.replace(/[.;]+$/g, "")) + .map(cleanText) + ) + + return { name, subdivisions } +} + +export function parseSecDistricts( + html: string, + options: ParseOptions +): ParsedDistrict[] { + const dom = new JSDOM(html) + const document = dom.window.document + const title = Array.from(document.querySelectorAll("h1")).find(heading => + /massachusetts .* districts/i.test(heading.textContent ?? "") + ) + const headings = Array.from(document.querySelectorAll("h2, h3")).filter( + heading => + (!title || + Boolean( + title.compareDocumentPosition(heading) & + dom.window.Node.DOCUMENT_POSITION_FOLLOWING + )) && + isDistrictHeading(heading, options.branch) + ) + + return headings.map(heading => { + const sourceDistrict = cleanText(heading.textContent ?? "") + const district = + options.branch === "House" + ? displayDistrictName(sourceDistrict) + : sourceDistrict + + return { + id: districtId(options.branch, district), + branch: options.branch, + district, + sourceDistrict, + sourceUrl: options.sourceUrl, + municipalities: followingDistrictItems(heading, options.branch) + .map(item => parseMunicipality(item.textContent ?? "")) + .filter(municipality => municipality.name.length > 0) + } + }) +} diff --git a/functions/src/districts/types.ts b/functions/src/districts/types.ts new file mode 100644 index 000000000..30804f877 --- /dev/null +++ b/functions/src/districts/types.ts @@ -0,0 +1,32 @@ +import { + Array as RtArray, + InstanceOf, + Literal, + Record, + Static, + String, + Union +} from "runtypes" +import { Timestamp } from "../firebase" + +export const DistrictBranch = Union(Literal("House"), Literal("Senate")) +export type DistrictBranch = Static + +export const DistrictMunicipality = Record({ + name: String, + subdivisions: RtArray(String) +}) +export type DistrictMunicipality = Static + +export const District = Record({ + id: String, + branch: DistrictBranch, + district: String, + sourceDistrict: String, + sourceUrl: String, + municipalities: RtArray(DistrictMunicipality), + fetchedAt: InstanceOf(Timestamp) +}) +export type District = Static + +export type ParsedDistrict = Omit diff --git a/pages/legislators/[court]/[memberCode].tsx b/pages/legislators/[court]/[memberCode].tsx new file mode 100644 index 000000000..4c3084166 --- /dev/null +++ b/pages/legislators/[court]/[memberCode].tsx @@ -0,0 +1,39 @@ +import { GetServerSideProps } from "next" +import { serverSideTranslations } from "next-i18next/serverSideTranslations" +import { z } from "zod" +import { LegislatorProfilePage } from "components/LegislatorProfile" +import { createPage } from "components/page" + +const Query = z.object({ + court: z.coerce.number(), + memberCode: z.string() +}) + +export default createPage<{ + court: number + memberCode: string +}>({ + titleI18nKey: "titles.legislatorProfile", + Page: ({ court, memberCode }) => ( + + ) +}) + +export const getServerSideProps: GetServerSideProps = async ctx => { + ctx.res.setHeader( + "Cache-Control", + "public, s-maxage=60, stale-while-revalidate=300" + ) + + const query = Query.safeParse(ctx.query) + if (!query.success) return { notFound: true } + + const locale = ctx.locale ?? ctx.defaultLocale ?? "en" + + return { + props: { + ...query.data, + ...(await serverSideTranslations(locale, ["auth", "common", "footer"])) + } + } +} diff --git a/scripts/firebase-admin/scrapeLegislativeDistricts.ts b/scripts/firebase-admin/scrapeLegislativeDistricts.ts new file mode 100644 index 000000000..de3bf3598 --- /dev/null +++ b/scripts/firebase-admin/scrapeLegislativeDistricts.ts @@ -0,0 +1,89 @@ +import axios from "axios" +import { Timestamp } from "../../functions/src/firebase" +import { parseSecDistricts } from "../../functions/src/districts" +import type { ParsedDistrict } from "../../functions/src/districts" +import { currentGeneralCourt } from "../../functions/src/shared" +import type { Script } from "./types" + +const sources = [ + { + branch: "Senate" as const, + expectedCount: 40, + url: "https://www.sec.state.ma.us/divisions/elections/voting-information/district/2022-senatorial.htm" + }, + { + branch: "House" as const, + expectedCount: 160, + url: "https://www.sec.state.ma.us/divisions/elections/voting-information/district/2022-representative.htm" + } +] + +async function fetchHtml(url: string) { + const response = await axios.get(url, { + headers: { + "User-Agent": + "Mozilla/5.0 (compatible; MAPLE district backfill; +https://mapletestimony.org)" + }, + responseType: "text", + timeout: 60_000 + }) + return response.data +} + +function logCollisions(districts: ParsedDistrict[]) { + const byId = new Map() + districts.forEach(district => { + byId.set(district.id, [...(byId.get(district.id) ?? []), district]) + }) + + Array.from(byId.entries()) + .filter(([, matchingDistricts]) => matchingDistricts.length > 1) + .forEach(([id, matchingDistricts]) => { + console.warn( + `District normalization collision for ${id}: ${matchingDistricts + .map(district => district.sourceDistrict) + .join(", ")}` + ) + }) +} + +export const script: Script = async ({ db }) => { + const districts = ( + await Promise.all( + sources.map(async source => { + const html = await fetchHtml(source.url) + const parsed = parseSecDistricts(html, { + branch: source.branch, + sourceUrl: source.url + }) + + if (parsed.length !== source.expectedCount) { + throw Error( + `Expected ${source.expectedCount} ${source.branch} districts, parsed ${parsed.length}` + ) + } + + console.log(`Parsed ${parsed.length} ${source.branch} districts`) + return parsed + }) + ) + ).flat() + + logCollisions(districts) + + const writer = db.bulkWriter() + const fetchedAt = Timestamp.now() + + districts.forEach(district => { + writer.set( + db.doc(`/generalCourts/${currentGeneralCourt}/districts/${district.id}`), + { ...district, fetchedAt }, + { merge: true } + ) + }) + + await writer.close() + console.log( + `Upserted ${districts.length} districts for general court ${currentGeneralCourt}` + ) +} From f0aacd56bc2be6ca1867b4e7f8283874a1d56387 Mon Sep 17 00:00:00 2001 From: Sean Moss Date: Tue, 19 May 2026 22:24:06 -0400 Subject: [PATCH 007/106] Enhance SEC district parsing and testing - Added functions to read district snapshots from files and strip ignored HTML tags. - Updated `parseSecDistricts` to utilize the new function for cleaning HTML input. - Introduced tests for parsing SEC snapshots in `parseSecDistricts.test.ts` to ensure correct district counts for Senate and House. - Modified the scraping script to read HTML from local files if not fetching from the web. --- .../src/districts/parseSecDistricts.test.ts | 24 +++++++++++++++++ functions/src/districts/parseSecDistricts.ts | 7 ++++- .../scrapeLegislativeDistricts.ts | 27 ++++++++++++++++--- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/functions/src/districts/parseSecDistricts.test.ts b/functions/src/districts/parseSecDistricts.test.ts index 34bd4c8e2..e684bf648 100644 --- a/functions/src/districts/parseSecDistricts.test.ts +++ b/functions/src/districts/parseSecDistricts.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "fs" +import path from "path" import { displayDistrictName, districtId, @@ -7,6 +9,13 @@ import { parseSecDistricts } from "./parseSecDistricts" const sourceUrl = "https://example.test/districts" +function readDistrictSnapshot(fileName: string) { + return readFileSync( + path.resolve(__dirname, "../../../districts", fileName), + "utf8" + ) +} + describe("district normalization", () => { it("normalizes district names across ordinal and punctuation variants", () => { expect(normalizeDistrictName("Ninth Hampden")).toBe("9 hampden") @@ -38,6 +47,21 @@ describe("district normalization", () => { }) describe("parseSecDistricts", () => { + it("parses the checked-in SEC snapshots", () => { + expect( + parseSecDistricts(readDistrictSnapshot("senatorial.html"), { + branch: "Senate", + sourceUrl + }) + ).toHaveLength(40) + expect( + parseSecDistricts(readDistrictSnapshot("representative.html"), { + branch: "House", + sourceUrl + }) + ).toHaveLength(160) + }) + it("parses Senate h2 district sections and preserves split municipalities", () => { const html = `

Massachusetts Senatorial Districts

diff --git a/functions/src/districts/parseSecDistricts.ts b/functions/src/districts/parseSecDistricts.ts index ff2b45c99..52c945a25 100644 --- a/functions/src/districts/parseSecDistricts.ts +++ b/functions/src/districts/parseSecDistricts.ts @@ -11,10 +11,15 @@ type ParseOptions = { function cleanText(text: string) { return text .replace(/\u00a0/g, " ") + .replace(/\s*▲?\s*Top of page\s*/gi, "") .replace(/\s+/g, " ") .trim() } +function stripIgnoredTags(html: string) { + return html.replace(//gi, "") +} + function isHouseCountyHeading(element: Element) { return ( element.tagName.toLowerCase() === "h2" && @@ -67,7 +72,7 @@ export function parseSecDistricts( html: string, options: ParseOptions ): ParsedDistrict[] { - const dom = new JSDOM(html) + const dom = new JSDOM(stripIgnoredTags(html)) const document = dom.window.document const title = Array.from(document.querySelectorAll("h1")).find(heading => /massachusetts .* districts/i.test(heading.textContent ?? "") diff --git a/scripts/firebase-admin/scrapeLegislativeDistricts.ts b/scripts/firebase-admin/scrapeLegislativeDistricts.ts index de3bf3598..97a11765a 100644 --- a/scripts/firebase-admin/scrapeLegislativeDistricts.ts +++ b/scripts/firebase-admin/scrapeLegislativeDistricts.ts @@ -1,23 +1,33 @@ import axios from "axios" +import { promises as fs } from "fs" +import path from "path" import { Timestamp } from "../../functions/src/firebase" import { parseSecDistricts } from "../../functions/src/districts" import type { ParsedDistrict } from "../../functions/src/districts" import { currentGeneralCourt } from "../../functions/src/shared" -import type { Script } from "./types" +import type { Script, ScriptContext } from "./types" const sources = [ { branch: "Senate" as const, expectedCount: 40, + fileName: "senatorial.html", url: "https://www.sec.state.ma.us/divisions/elections/voting-information/district/2022-senatorial.htm" }, { branch: "House" as const, expectedCount: 160, + fileName: "representative.html", url: "https://www.sec.state.ma.us/divisions/elections/voting-information/district/2022-representative.htm" } ] +type Source = (typeof sources)[number] + +function shouldFetch(args: ScriptContext["args"]) { + return args.fetch === true || args.argv.includes("--fetch") +} + async function fetchHtml(url: string) { const response = await axios.get(url, { headers: { @@ -30,6 +40,17 @@ async function fetchHtml(url: string) { return response.data } +async function readHtml(source: Source, args: ScriptContext["args"]) { + if (shouldFetch(args)) return fetchHtml(source.url) + + const dir = + typeof args["district-dir"] === "string" + ? args["district-dir"] + : path.resolve(process.cwd(), "districts") + + return fs.readFile(path.join(dir, source.fileName), "utf8") +} + function logCollisions(districts: ParsedDistrict[]) { const byId = new Map() districts.forEach(district => { @@ -47,11 +68,11 @@ function logCollisions(districts: ParsedDistrict[]) { }) } -export const script: Script = async ({ db }) => { +export const script: Script = async ({ db, args }) => { const districts = ( await Promise.all( sources.map(async source => { - const html = await fetchHtml(source.url) + const html = await readHtml(source, args) const parsed = parseSecDistricts(html, { branch: source.branch, sourceUrl: source.url From 253014e7a28e73d1a6a3ed534f3f742406d25a31 Mon Sep 17 00:00:00 2001 From: Sean Moss Date: Tue, 16 Jun 2026 20:50:03 -0400 Subject: [PATCH 008/106] Add feature flag check for legislator profile page - Implemented a feature flag to conditionally render the legislator profile page. - Added a redirect to a 404 page if the feature flag for legislators is disabled. --- pages/legislators/[court]/[memberCode].tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pages/legislators/[court]/[memberCode].tsx b/pages/legislators/[court]/[memberCode].tsx index 4c3084166..5824626c9 100644 --- a/pages/legislators/[court]/[memberCode].tsx +++ b/pages/legislators/[court]/[memberCode].tsx @@ -2,6 +2,7 @@ import { GetServerSideProps } from "next" import { serverSideTranslations } from "next-i18next/serverSideTranslations" import { z } from "zod" import { LegislatorProfilePage } from "components/LegislatorProfile" +import { flags } from "components/featureFlags" import { createPage } from "components/page" const Query = z.object({ @@ -20,6 +21,10 @@ export default createPage<{ }) export const getServerSideProps: GetServerSideProps = async ctx => { + if (!flags().legislators) { + return { redirect: { destination: "/404", permanent: false } } + } + ctx.res.setHeader( "Cache-Control", "public, s-maxage=60, stale-while-revalidate=300" From 9e8a14fdda4c6ad2d1262cf3de60582335c7bfe4 Mon Sep 17 00:00:00 2001 From: Sean Moss Date: Tue, 16 Jun 2026 21:31:37 -0400 Subject: [PATCH 009/106] Refactor district parsing tests and update project configuration - Removed unused imports and refactored test functions in `parseSecDistricts.test.ts` to build HTML snapshots for Senate and House districts. - Updated the project configuration in `project.yml` to correct the formatting of the languages list. --- .serena/project.yml | 3 +- .../src/districts/parseSecDistricts.test.ts | 35 +++++++++++++------ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/.serena/project.yml b/.serena/project.yml index 75e8c0642..3ba9628f1 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,7 +1,6 @@ # the name by which the project can be referenced within Serena project_name: "maple" - # list of languages for which language servers are started; choose from: # al angular ansible bash clojure # cpp cpp_ccls crystal csharp csharp_omnisharp @@ -32,7 +31,7 @@ project_name: "maple" # The first language is the default language and the respective language server will be used as a fallback. # Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. languages: -- typescript + - typescript # the encoding used by text files in the project # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings diff --git a/functions/src/districts/parseSecDistricts.test.ts b/functions/src/districts/parseSecDistricts.test.ts index e684bf648..a62a91a00 100644 --- a/functions/src/districts/parseSecDistricts.test.ts +++ b/functions/src/districts/parseSecDistricts.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from "fs" -import path from "path" import { displayDistrictName, districtId, @@ -9,11 +7,28 @@ import { parseSecDistricts } from "./parseSecDistricts" const sourceUrl = "https://example.test/districts" -function readDistrictSnapshot(fileName: string) { - return readFileSync( - path.resolve(__dirname, "../../../districts", fileName), - "utf8" - ) +function buildSenateSnapshotHtml(districtCount: number) { + const sections = Array.from({ length: districtCount }, (_, index) => { + const districtNumber = index + 1 + return `

Example Senate District ${districtNumber}

+
  • Example Town ${districtNumber}
+
` + }).join("\n") + + return `

Massachusetts Senatorial Districts

${sections}` +} + +function buildHouseSnapshotHtml(districtCount: number) { + const sections = Array.from({ length: districtCount }, (_, index) => { + const districtNumber = index + 1 + return `

Example House District ${districtNumber}

+
  • Example Town ${districtNumber}
+
` + }).join("\n") + + return `

Massachusetts Representative Districts

+

Example County

+ ${sections}` } describe("district normalization", () => { @@ -47,15 +62,15 @@ describe("district normalization", () => { }) describe("parseSecDistricts", () => { - it("parses the checked-in SEC snapshots", () => { + it("parses the expected number of Senate and House districts", () => { expect( - parseSecDistricts(readDistrictSnapshot("senatorial.html"), { + parseSecDistricts(buildSenateSnapshotHtml(40), { branch: "Senate", sourceUrl }) ).toHaveLength(40) expect( - parseSecDistricts(readDistrictSnapshot("representative.html"), { + parseSecDistricts(buildHouseSnapshotHtml(160), { branch: "House", sourceUrl }) From beb1861f1d380c070e061a588ec209759301f38d Mon Sep 17 00:00:00 2001 From: "Nathan E. Sanders" Date: Fri, 26 Jun 2026 09:06:39 -0400 Subject: [PATCH 010/106] Delete .tool-versions (#2173) Delete unused, vestigial asdf config file --- .tool-versions | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .tool-versions diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index 1e04d796d..000000000 --- a/.tool-versions +++ /dev/null @@ -1 +0,0 @@ -nodejs 18.17.0 From 26150ef77c8eb31ef75b8b161aa7570b25c27656 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sat, 27 Jun 2026 20:14:04 -0400 Subject: [PATCH 011/106] feat: parser fixes for all 4 disclosure eras + GCS archiving infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portal parser (portal.py): - Hybrid era (2014-2018): per-client compensation from Panel1 divs — was silently $0 due to missing code path (e.g. Murphy Donoghue 2016: $990k) - Legacy era (2009-2013): per-client totals from 'Compensation received' column, with dedup of (client, amount) pairs before summing — was silently $0 per client (e.g. ML Strategies 2011: $641k across 23 clients) - Legacy bill semicolon separator: 'H73; Title' now parsed correctly - 'Total amount' summary row excluded from compensation pairs - HTTP retry on 429/500/502/503/504 (was aborting on first transient error) - parse_summary() and parse_disclosure_detail() split out as pure functions (no I/O) so the offline reparse driver can call them without a session GCS archiving (archive.py): - Write-only cold storage: every fetched Summary/CompleteDisclosure page saved as gs://{project}-lobbying-archive/raw_html/{sha1(url)}.html - Enabled by ARCHIVE_RAW=1 env var; no-op otherwise - Failures are logged but never interrupt the live scrape path Offline reparse driver (reparse_archive.py): - Lists CompleteDisclosure blobs from GCS, resolves registrant meta from Firestore via disclosureUrls array_contains, re-runs pure parsers, writes back via writer.py; resumable via /scrapers/lobbyingReparse cursor Pytest suite (tests/test_portal_parser.py, 26 tests): - All 4 eras verified against committed gzipped fixture pages - Compensation totals, client counts, bill counts, era detection asserted - Specific bug-fix regressions: Total-amount artifact, H73 semicolon, hybrid Panel1 compensation, 2007 _total_salary_ fallback Co-Authored-By: Claude Sonnet 4.6 --- docs/lobbying-disclosure-ingestion.md | 328 +++++++++++++++++- .../__pycache__/normalize.cpython-37.pyc | Bin 1412 -> 1412 bytes .../__pycache__/portal.cpython-37.pyc | Bin 11941 -> 16091 bytes lobbying-scraper/archive.py | 60 ++++ lobbying-scraper/portal.py | 272 +++++++++++---- lobbying-scraper/reparse_archive.py | 155 +++++++++ lobbying-scraper/requirements.txt | 1 + lobbying-scraper/tests/__init__.py | 0 .../tests/__pycache__/__init__.cpython-37.pyc | Bin 0 -> 166 bytes ..._portal_parser.cpython-37-pytest-7.4.4.pyc | Bin 0 -> 17098 bytes .../tests/fixtures/2007e_disc.html.gz | Bin 0 -> 21790 bytes .../tests/fixtures/2007e_summ.html.gz | Bin 0 -> 14101 bytes .../tests/fixtures/2011e_disc.html.gz | Bin 0 -> 32918 bytes .../tests/fixtures/2011e_summ.html.gz | Bin 0 -> 26526 bytes .../tests/fixtures/2011i_disc.html.gz | Bin 0 -> 14723 bytes .../tests/fixtures/2011i_summ.html.gz | Bin 0 -> 13673 bytes .../tests/fixtures/2016e_disc.html.gz | Bin 0 -> 259811 bytes .../tests/fixtures/2016e_summ.html.gz | Bin 0 -> 24496 bytes .../tests/fixtures/2024e_disc.html.gz | Bin 0 -> 23241 bytes .../tests/fixtures/2024e_summ.html.gz | Bin 0 -> 14583 bytes .../tests/fixtures/2024i_disc.html.gz | Bin 0 -> 62766 bytes .../tests/fixtures/2024i_summ.html.gz | Bin 0 -> 11739 bytes lobbying-scraper/tests/test_portal_parser.py | 172 +++++++++ 23 files changed, 902 insertions(+), 86 deletions(-) create mode 100644 lobbying-scraper/archive.py create mode 100644 lobbying-scraper/reparse_archive.py create mode 100644 lobbying-scraper/tests/__init__.py create mode 100644 lobbying-scraper/tests/__pycache__/__init__.cpython-37.pyc create mode 100644 lobbying-scraper/tests/__pycache__/test_portal_parser.cpython-37-pytest-7.4.4.pyc create mode 100644 lobbying-scraper/tests/fixtures/2007e_disc.html.gz create mode 100644 lobbying-scraper/tests/fixtures/2007e_summ.html.gz create mode 100644 lobbying-scraper/tests/fixtures/2011e_disc.html.gz create mode 100644 lobbying-scraper/tests/fixtures/2011e_summ.html.gz create mode 100644 lobbying-scraper/tests/fixtures/2011i_disc.html.gz create mode 100644 lobbying-scraper/tests/fixtures/2011i_summ.html.gz create mode 100644 lobbying-scraper/tests/fixtures/2016e_disc.html.gz create mode 100644 lobbying-scraper/tests/fixtures/2016e_summ.html.gz create mode 100644 lobbying-scraper/tests/fixtures/2024e_disc.html.gz create mode 100644 lobbying-scraper/tests/fixtures/2024e_summ.html.gz create mode 100644 lobbying-scraper/tests/fixtures/2024i_disc.html.gz create mode 100644 lobbying-scraper/tests/fixtures/2024i_summ.html.gz create mode 100644 lobbying-scraper/tests/test_portal_parser.py diff --git a/docs/lobbying-disclosure-ingestion.md b/docs/lobbying-disclosure-ingestion.md index 51719f342..862947cd0 100644 --- a/docs/lobbying-disclosure-ingestion.md +++ b/docs/lobbying-disclosure-ingestion.md @@ -303,12 +303,14 @@ functions/src/lobbying/ index.ts — Re-exports lobbying-scraper/ - scrape.py — Entry point: --mode weekly (incremental) | --mode backfill - portal.py — HTTP + HTML parsing - normalize.py — Port of normalize.ts - writer.py — Firestore document construction + writes - requirements.txt — requests, beautifulsoup4, google-cloud-firestore - Dockerfile — Python 3.12-slim image + scrape.py — Entry point: --mode weekly (incremental) | --mode backfill + portal.py — HTTP fetch wrappers + pure HTML parsers for all 4 format eras + normalize.py — Port of normalize.ts + writer.py — Firestore document construction + writes + archive.py — GCS raw-HTML archive (write-only; enabled by ARCHIVE_RAW=1) + reparse_archive.py — Offline driver to re-ingest archived HTML into Firestore + requirements.txt — requests, beautifulsoup4, google-cloud-firestore, google-cloud-storage + Dockerfile — Python 3.12-slim image ``` The TypeScript lobbying module (`functions/src/lobbying/`) contains only the @@ -422,18 +424,20 @@ export { scrapeLobbying } from "./lobbying" ## Implementation Status -| File | Status | Notes | -| ------------------------------------- | ------- | -------------------------------------------------------- | -| `functions/src/lobbying/types.ts` | ✅ Done | Firestore schema types; imported by future frontend code | -| `functions/src/lobbying/normalize.ts` | ✅ Done | Normalization pipeline; also ported to `normalize.py` | -| `functions/src/lobbying/index.ts` | ✅ Done | Re-exports types and normalize | -| `firestore.rules` | ✅ Done | | -| `firestore.indexes.json` | ✅ Done | | -| `lobbying-scraper/normalize.py` | ✅ Done | Port of normalize.ts | -| `lobbying-scraper/portal.py` | ✅ Done | HTTP + HTML parsing | -| `lobbying-scraper/writer.py` | ✅ Done | Firestore document construction | -| `lobbying-scraper/scrape.py` | ✅ Done | Entry point; `--mode weekly` and `--mode backfill` | -| `lobbying-scraper/Dockerfile` | ✅ Done | Python 3.12-slim; deployed to Cloud Run | +| File | Status | Notes | +| ------------------------------------- | ---------- | ---------------------------------------------------------------- | +| `functions/src/lobbying/types.ts` | ✅ Done | Firestore schema types; imported by future frontend code | +| `functions/src/lobbying/normalize.ts` | ✅ Done | Normalization pipeline; also ported to `normalize.py` | +| `functions/src/lobbying/index.ts` | ✅ Done | Re-exports types and normalize | +| `firestore.rules` | ✅ Done | | +| `firestore.indexes.json` | ✅ Done | | +| `lobbying-scraper/normalize.py` | ✅ Done | Port of normalize.ts | +| `lobbying-scraper/portal.py` | ✅ Done | All 4 format eras; pure parsers separated from fetch wrappers | +| `lobbying-scraper/writer.py` | ✅ Done | Firestore document construction | +| `lobbying-scraper/scrape.py` | ✅ Done | Entry point; `--mode weekly` and `--mode backfill` | +| `lobbying-scraper/archive.py` | ✅ Done | GCS write-only archive; enabled via `ARCHIVE_RAW=1` | +| `lobbying-scraper/reparse_archive.py` | ✅ Done | Offline reparse driver; looks up registrant meta from Firestore | +| `lobbying-scraper/Dockerfile` | ⚠️ Rebuild | Needs rebuild after `google-cloud-storage` added to requirements | ### Document ID scheme @@ -676,3 +680,291 @@ processed). | Incremental strategy | Skip already-processed disclosure URLs; write docs by logical key (upsert) | Survives function restarts and re-runs without re-fetching already-scraped pages; natural upsert prevents duplicates without an explicit dedup pass | | Legacy format (pre-2013) | Store with `clientName: "_total_salary_"` sentinel | Preserves data completeness; callers can filter on this value | | Historical data | Admin backfill script (2005 → present) | Full history is ingested once; Cloud Function maintains current+prior year going forward | + +--- + +## Appendix: Phase 2 — OCPF Contribution Ingestion + +This appendix tracks planned additions to the lobbying pipeline in a subsequent +PR. The changes link OCPF (Office of Campaign and Political Finance) campaign +contribution records to lobbying registrants, enabling questions like "how much +did this firm contribute to politicians while lobbying on this bill?" + +### Background + +The MA Secretary of State portal (Phase 1, implemented) covers who lobbied which +bills and for how much compensation. OCPF covers who donated to which political +candidates. Linking the two surfaces the intersection: lobbying firms that also +made political contributions. + +OCPF publishes bulk data files at +`ocpf2.blob.core.windows.net/downloads/data2/` (the same host used by +`matchOcpfMembers.ts` for the filers file). The contribution records file URL +needs to be confirmed before implementation begins. + +### New Data + +#### `/lobbyingContributionRecipients/{recipientId}` + +One document per deduplicated contribution recipient (politician/PAC). The +deduplication pipeline is described below. + +```typescript +interface LobbyingContributionRecipient { + recipientId: string // SHA-256(recipientName + officeSought)[:40] + recipientName: string // canonical display name after dedup + recipientSlug: string // slugify("{recipientName} {officeSought}") + officeSought: string // canonical office value (see normalization below) + totalReceived: number + nContributions: number + years: number[] + nameVariants: string[] // all raw names that resolved to this record + manuallyMerged: boolean // true if recipient_merges.json was applied + topFirms: [string, string, number][] // [entityName, entityNameNorm, amount] + fetchedAt: Timestamp +} +``` + +#### New fields on `LobbyingRegistrant` (optional, written by contribution run) + +```typescript +nBills?: number // total filing rows across all clients and years +nContributions?: number // total OCPF contribution records from this registrant +totalContributions?: number // total dollar amount contributed +``` + +`nBills` is computed during the disclosure scrape (bill rows are already in +memory) and written as a side effect of `--mode weekly` and `--mode backfill`. +`nContributions` and `totalContributions` are written by `--mode contributions`. + +#### Pure-contribution firms (new registrant subtype) + +Firms that made OCPF contributions but have zero lobbying activity (no filings) +are written as `LobbyingRegistrant` documents with `nBills: 0`, `clients: []`, +`totalContributions ≥ 500`. These were previously invisible. The $500 threshold +filters noise; adjust after reviewing the data. + +### Contribution Recipient Deduplication + +The same politician may appear under many name variants in OCPF data ("Joe +Curtatone", "Joseph Curtatone", "Curtatone"). The dedup pipeline runs in five +passes in-memory after loading all contribution records. + +All rules are systemic (handle a class of inputs); individual special cases are +handled via `recipient_merges.json` only. + +#### New file: `lobbying-scraper/recipient_normalize.py` + +**Step 1 — `_extract_recipient_name(raw)`** + +Strips committee wrapper names using 14 regex patterns (e.g. "Cte. to Elect Ron +Mariano" → "Ron Mariano"). Then applies in order: + +- Last-first flip: `"Mariano, Ronald"` → `"Ronald Mariano"` +- Honorific strip: `"Sen. Marc Rodrigues"` → `"Marc Rodrigues"` +- Party label strip: `"Ron Mariano Democrat"` → `"Ron Mariano"` +- Initial-dot prefix strip: `"R.Mariano"` → `"Mariano"` + +**Step 2 — `_recipient_dedup_key(name, office)` → `(first, last, office)`** + +Key is a `(first_token, last_token, canonical_office)` tuple. Before keying, +`first_token` is passed through `_NICKNAME_MAP`: + +| Raw | Canonical | +| ---------------- | --------- | +| joe | joseph | +| mike | michael | +| bob | robert | +| bill | william | +| jim | james | +| tom | thomas | +| dan | daniel | +| dave | david | +| steve | steven | +| ron | ronald | +| tim | timothy | +| rick, rich, dick | richard | +| charlie, chuck | charles | +| ben | benjamin | +| tony | anthony | +| marty | martin | +| don | donald | +| ken | kenneth | +| ed | edward | +| ray | raymond | +| nick | nicholas | + +If either token is a generic word (`the`, `comm`, `committee`, `democratic`, +`republican`, `house`, `senate`, `friends`, `cte`, `pac`), the key falls back +to the full canonical text. + +**Step 3 — Manual merges (`recipient_merges.json`)** + +JSON file maps `(name, office)` alias pairs to a canonical record. Start as an +empty object `{}`; populate incrementally as data review finds misses. Applied +after key assignment. + +**Step 4 — `_merge_bare_surnames(groups)`** + +Single-name entries where `first == last` (e.g. `"Curtatone"`) are merged into +the highest-total multi-token peer with the same `(last, office)`. + +**Step 5 — `_fuzzy_merge_keys(groups)`** + +Groups keys by `(first, office)`. Pairs where last tokens differ by Levenshtein +distance ≤ 1 are merged into the higher-total key. Minimum 6 chars on both last +tokens (protects short surnames: Walsh, Jones, etc.). + +### Office Normalization — `_normalize_office(raw)` in `recipient_normalize.py` + +Canonical office values: + +`State Representative` · `State Senator` · `Governor` · `Lt. Governor` · +`Attorney General` · `Treasurer` · `Auditor` · `Secretary of State` · `Mayor` · +`City Council` · `District Attorney` · `Sheriff` · `PAC` + +Normalization pipeline (applied in order): + +1. Strip `"Candidate for [Massachusetts] X"` prefix → `"X"` +2. Strip leading `MA` / `Mass.` / `Massachusetts` +3. Strip trailing junk punctuation (`:;/`) +4. Strip parenthetical suffixes +5. Strip `"of the …"` / `"from the …"` suffixes +6. Strip after dash or slash +7. Strip after comma +8. Strip ordinal suffixes (e.g. `" 3rd Norfolk …"`) +9. Re-strip trailing junk (ordinal strip can leave dangling colons) +10. `OFFICE_MAP` lookup (canonical string → canonical value) +11. Fallback — district lookup: ordinal + MA county → `State Representative` +12. Fallback — county suffix: `"Representative Suffolk"` → strip county → retry map +13. Fallback — location qualifier: strip `"of "`, trailing word, or leading + word → retry map (handles `"Mayor of Somerville"`, `"Somerville Mayor"`, + `"Mayor Somerville"` → `"Mayor"`) + +### New Files + +``` +lobbying-scraper/ + ocpf_contributions.py — OCPF bulk data download, parse, match to registrants + recipient_normalize.py — office normalization + 5-step recipient dedup pipeline + recipient_merges.json — manual merge overrides (start as {}) +``` + +### Infrastructure Changes + +**`firestore.rules`** — add: + +``` +match /lobbyingContributionRecipients/{id} { allow read: if true; allow write: if false; } +``` + +**`firestore.indexes.json`** — add: + +| Collection | Fields | Use case | +| -------------------------------- | -------------------------------------- | --------------------------- | +| `lobbyingContributionRecipients` | `officeSought ASC, totalReceived DESC` | Office-filtered leaderboard | +| `lobbyingContributionRecipients` | `officeSought ASC, recipientName ASC` | Alphabetical browse | + +### What Is Not Implemented (Phase 2) + +**AI bill topics (`tags.json` equivalent)** — requires a bill embeddings parquet +file. MAPLE does not produce this file. The topics feature is deferred; MAPLE's +existing bill search index could be used as a substitute in a future PR. + +**`recipient_merges.json`** — starts empty. Requires domain review of the +deduplicated output to find cases the automated rules miss. Populate +incrementally. + +### Order of Work + +1. Confirm OCPF bulk contribution file URL and column schema +2. Confirm or incorporate HTML archiving pattern (see open question below) +3. Implement `recipient_normalize.py` (self-contained, testable in isolation) +4. Implement `ocpf_contributions.py` +5. Update `functions/src/lobbying/types.ts` schema +6. Update `writer.py` and `scrape.py` +7. Update `firestore.rules` and `firestore.indexes.json` + +### HTML Archiving for Fast Reparsing + +The current scraper fetches and immediately parses SoS portal HTML, storing +nothing but the parsed Firestore documents. If parsing logic changes, the full +portal must be re-scraped — slow and fragile given the Imperva TLS constraint. +Phase 2 adds GCS archiving of raw HTML as a side effect of every portal fetch. + +#### Design principles (from the AMEND reference implementation) + +The archive is **write-only cold storage**, not a cache. It is fully decoupled +from both the incremental cursor (which stays Firestore-only) and the parse +path. Fetching from the portal is always driven by the Firestore cursor; the +archive is a downstream side effect of a successful fetch. + +Parsers must be **pure functions with no I/O** — `parse_summary(soup)`, +`parse_disclosure_detail(soup, year)` — so the identical code runs in the live +scrape and in the offline reparse script without modification. + +#### GCS key scheme + +One object per fetched page: `raw_html/{sha1(url)}.html`. URL-hash is +collision-free without modeling the ASP.NET URL key space. Individual `.html` +objects (not batch tarballs) are appropriate here because our scraper runs +weekly in small increments rather than in large historical sweeps; per-object +GCS is simpler and makes the reparse path a flat list + get. + +Bucket: `gs://-lobbying-archive/raw_html/` +Storage class: Archive (written once, read rarely). + +#### Write timing + +Archived immediately after `raise_for_status()` passes, before parsing, and +**not gated on parse success**: + +```python +def _get(session, url): + r = session.get(url, ...) + r.raise_for_status() + if "Summary.aspx" in url or "CompleteDisclosure" in url: + _archive_page(url, r.text) # side effect; never blocks parse + return BeautifulSoup(r.text, "html.parser") +``` + +Gating on parse success would defeat the purpose: the archive exists precisely +to recover from parser gaps later, so we want the bytes even when the current +parser does not fully understand them. The search/results page is excluded +(same URL across all years, trivially regenerable). + +#### Reparse path + +A dedicated offline script `lobbying-scraper/reparse_archive.py`: + +``` +python3 reparse_archive.py [--limit N] [--dry-run] +``` + +Lists `raw_html/*.html` objects from GCS, downloads them, resolves each +`sha1.html` back to its original URL (stored as object metadata at write time), +and runs the pure parser functions against the archived soup. Tracks completed +objects via a `processed_archive.txt` marker so reparse is resumable. + +#### Cursor interaction + +No change to the Firestore cursor. The archive is never consulted to skip a +portal fetch. "Have I processed this disclosure?" is still answered by the +Firestore cursor; the archive is a consequence of fetching, not an input to the +decision. + +#### New infrastructure required + +- GCS bucket `-lobbying-archive` (Archive class, no public access) +- `google-cloud-storage` added to `lobbying-scraper/requirements.txt` +- `GOOGLE_CLOUD_PROJECT` env var (already available on Cloud Run) used to + derive bucket name +- IAM: Cloud Run service account needs `storage.objects.create` on the bucket +- New file: `lobbying-scraper/reparse_archive.py` + +#### Order of work within Phase 2 + +This is a prerequisite for `ocpf_contributions.py` only in the sense that we +want the archive in place before running the full historical backfill so we do +not have to re-scrape. It is otherwise independent and can be implemented +alongside the contribution pipeline rather than before it. diff --git a/lobbying-scraper/__pycache__/normalize.cpython-37.pyc b/lobbying-scraper/__pycache__/normalize.cpython-37.pyc index 47c3ba707ebdca4ef16776e15507d9f385437cab..94efb861e98ad8f45f5a0ec1065e5d0290dfb499 100644 GIT binary patch delta 132 zcmZqSZsF#3;^pOH00O^GyN%ojnHg_QKFeGvaEmE9;}%;%Mp0sM>Pm(p*~xw^eFE}8 zWtu#<*yH0<@{{A^Z%ux~B5Qq%%hTP@KgiYDG1#?82&ke+6hw%D2ni4&4kAEWi)2v= Jxydf94gg<&ADI9E delta 132 zcmZqSZsF#3;^pOH00OyZiW|8PGBZX^KFeGv5XF?75ye)JQIuGmx{{$tYO)_opMVTd znI_LI_W1ae{N(ufsL5|wWUZsPJl*~LgIt{*gI$XRfhvkbK!hlW5C;)rAOfVdND7sZ Jp6tTv004rD9S;Bi diff --git a/lobbying-scraper/__pycache__/portal.cpython-37.pyc b/lobbying-scraper/__pycache__/portal.cpython-37.pyc index 413885e3d657f24ebf306be5b8d9a45a80835a47..d515175f364a6ecf06fd72e46412580c29dbe6e1 100644 GIT binary patch literal 16091 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 KpHQy!#QzrrFX^EG delta 4985 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 str: + global _bucket_name + if _bucket_name is None: + project = os.environ.get("GOOGLE_CLOUD_PROJECT") or _gcs().project + _bucket_name = f"{project}-lobbying-archive" + return _bucket_name + + +def blob_name(url: str) -> str: + """Return the GCS object name for a given URL.""" + return "raw_html/" + hashlib.sha1(url.encode()).hexdigest() + ".html" + + +def save_page(url: str, html: str) -> None: + """Write raw HTML to GCS. No-op when ARCHIVE_RAW is not set.""" + if not _ENABLED: + return + try: + bucket = _gcs().bucket(_get_bucket_name()) + blob = bucket.blob(blob_name(url)) + blob.metadata = {"source-url": url} + blob.upload_from_string( + html.encode("utf-8"), + content_type="text/html; charset=utf-8", + ) + except Exception as exc: + # Archive failures must never interrupt the live scrape path. + print(f" [archive] WARNING: failed to save {url[:80]!r}: {exc}") diff --git a/lobbying-scraper/portal.py b/lobbying-scraper/portal.py index 257721991..3a5528898 100644 --- a/lobbying-scraper/portal.py +++ b/lobbying-scraper/portal.py @@ -3,13 +3,19 @@ Portal: https://www.sec.state.ma.us/LobbyistPublicSearch/ Page flow: - 1. Search POST → summary links table - 2. Summary.aspx → registrant name/year/type + CompleteDisclosure links - 3. CompleteDisclosure.aspx → per-client compensation + per-client bill activity - -Two disclosure HTML formats: - Modern (>=~2013): grdvClientPaidToEntity + grdvActivitiesNew{year}_{n} tables. - Legacy (<~2013): grdvSalaryPaid (total only) + grdvActivities (all bills). + 1. Search POST -> summary links table + 2. Summary.aspx -> registrant name/year/type + CompleteDisclosure links + 3. CompleteDisclosure.aspx -> per-client compensation + per-client bill activity + +Four HTML format eras for CompleteDisclosure pages (detected by table IDs): + Modern (2019+): grdvClientPaidToEntity + grdvActivitiesNew{year}_{n} + Hybrid (2014-2018): no comp table; Panel1_{n} divs + grdvActivitiesNew_{n} + Legacy (2009-2013): grdvActivities with Compensation received column + Legacy (2005-2008): grdvSalaryPaid (entity total) + grdvActivities (no comp col) + +parse_summary() and parse_disclosure_detail() are pure functions (no I/O) so +they can be called from both the live scraper and the offline reparse driver. +fetch_* are thin wrappers that handle HTTP and raw-HTML archiving. """ from __future__ import annotations @@ -23,6 +29,8 @@ import requests from bs4 import BeautifulSoup, Tag +import archive + # ── Constants ───────────────────────────────────────────────────────────────── BASE_URL = "https://www.sec.state.ma.us/LobbyistPublicSearch/" @@ -33,14 +41,15 @@ "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148" ) _REQUEST_DELAY = 1.0 -_MAX_RETRIES = 5 +_MAX_RETRIES = 6 +_RETRY_STATUS = {429, 500, 502, 503, 504} # Lobby disclosure data begins in 2005; GC 183 started Jan 2003. FIRST_YEAR = 2005 FIRST_GC = 183 FIRST_GC_START_YEAR = 2003 -# clientName sentinel for pre-2013 filings where compensation is a single total +# clientName sentinel for pre-2009 filings where compensation is a single entity total LEGACY_TOTAL_CLIENT = "_total_salary_" # Maps canonical chamber names to the bill-ID prefix used in MAPLE's Bill.id @@ -69,7 +78,7 @@ class Compensation: @dataclass class BillActivity: client_name: str - chamber: str # canonical LobbyingChamber value + chamber: str # canonical LobbyingChamber value raw_bill_number: str bill_id: Optional[str] # e.g. "H1234"; null for Executive/Other activity_title: str @@ -81,7 +90,7 @@ class BillActivity: class DisclosureMeta: entity_name: str year: Optional[int] - reg_type: str # "Lobbyist" | "Employer" + reg_type: str # "Lobbyist" | "Employer" disclosure_urls: list[str] = field(default_factory=list) @@ -110,7 +119,7 @@ def construct_bill_id(chamber: str, raw_bill_number: str) -> Optional[str]: """Construct the MAPLE-compatible billId from chamber + raw integer. Returns None for Executive and Other chambers where no bill join is possible. - H1234 and S1234 are distinct bills even though they share the same integer — + H1234 and S1234 are distinct bills even though they share the same integer; the prefix is required to disambiguate. """ prefix = CHAMBER_PREFIXES.get(chamber) @@ -135,8 +144,10 @@ def filing_id( general_court: int, position: str, ) -> str: - key = "|".join([entity_name, client_name, chamber, bill_id or "__null__", - str(general_court), position]) + key = "|".join([ + entity_name, client_name, chamber, + bill_id or "__null__", str(general_court), position, + ]) return hashlib.sha256(key.encode()).hexdigest()[:40] @@ -159,12 +170,23 @@ def _get(session: requests.Session, url: str) -> BeautifulSoup: time.sleep(_REQUEST_DELAY * (2 ** attempt) if attempt else _REQUEST_DELAY) try: r = session.get(url, timeout=60) - r.raise_for_status() - return BeautifulSoup(r.text, "html.parser") except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: if attempt == _MAX_RETRIES - 1: raise - print(f" GET retry {attempt + 1}: {e}") + print(f" GET network error (attempt {attempt + 1}): {e}") + continue + if r.status_code in _RETRY_STATUS: + print(f" GET HTTP {r.status_code} (attempt {attempt + 1}) — retrying") + if attempt == _MAX_RETRIES - 1: + r.raise_for_status() + continue + r.raise_for_status() + # Archive content pages before parsing; excludes the search page which + # shares one URL across all years and is trivially regenerable. + if "Summary.aspx" in url or "CompleteDisclosure" in url: + archive.save_page(url, r.text) + return BeautifulSoup(r.text, "html.parser") + raise RuntimeError("_get: exhausted retries") # unreachable def _post(session: requests.Session, url: str, data: dict) -> BeautifulSoup: @@ -172,12 +194,19 @@ def _post(session: requests.Session, url: str, data: dict) -> BeautifulSoup: time.sleep(_REQUEST_DELAY * (2 ** attempt) if attempt else _REQUEST_DELAY) try: r = session.post(url, data=data, timeout=180) - r.raise_for_status() - return BeautifulSoup(r.text, "html.parser") except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: if attempt == _MAX_RETRIES - 1: raise - print(f" POST retry {attempt + 1}: {e}") + print(f" POST network error (attempt {attempt + 1}): {e}") + continue + if r.status_code in _RETRY_STATUS: + print(f" POST HTTP {r.status_code} (attempt {attempt + 1}) — retrying") + if attempt == _MAX_RETRIES - 1: + r.raise_for_status() + continue + r.raise_for_status() + return BeautifulSoup(r.text, "html.parser") + raise RuntimeError("_post: exhausted retries") # unreachable # ── Portal scraping ─────────────────────────────────────────────────────────── @@ -207,7 +236,10 @@ def fetch_summary_links(session: requests.Session, year: int) -> list[str]: "ctl00$ContentPlaceHolder1$btnSearch": "Search", } results = _post(session, SEARCH_URL, data) - table = results.find("table", id=lambda x: x and "grdvSearchResultByTypeAndCategory" in x) + table = results.find( + "table", + id=lambda x: x and "grdvSearchResultByTypeAndCategory" in x, + ) if not table: return [] return [ @@ -217,9 +249,8 @@ def fetch_summary_links(session: requests.Session, year: int) -> list[str]: ] -def fetch_disclosure_meta(session: requests.Session, summary_url: str) -> DisclosureMeta: - soup = _get(session, summary_url) - +def parse_summary(soup: BeautifulSoup) -> DisclosureMeta: + """Parse a Summary.aspx page. Pure function — no I/O.""" def text(el_id: str) -> str: el = soup.find(id=el_id) return el.get_text(strip=True) if el else "" @@ -249,6 +280,10 @@ def text(el_id: str) -> str: ) +def fetch_disclosure_meta(session: requests.Session, summary_url: str) -> DisclosureMeta: + return parse_summary(_get(session, summary_url)) + + def _parse_amount(text: str) -> Optional[float]: cleaned = text.replace("$", "").replace(",", "").strip() try: @@ -257,20 +292,42 @@ def _parse_amount(text: str) -> Optional[float]: return None -def _grid_rows(table: Tag) -> list[Tag]: +def _grid_rows(table: Tag) -> list: return table.find_all("tr", class_=lambda c: c and "Grid" in c and "Header" not in c) -def fetch_disclosure_detail( - session: requests.Session, disc_url: str, year: int -) -> DisclosureDetail: - soup = _get(session, disc_url) +def parse_disclosure_detail(soup: BeautifulSoup, year: int) -> DisclosureDetail: + """Parse a CompleteDisclosure page. Pure function — no I/O. + + Four HTML format eras (detected by table IDs): + + Modern (2019+): grdvClientPaidToEntity holds per-client compensation; + bills in grdvActivitiesNew{year}_{n} (one table per client). + + Hybrid (2014-2018): no grdvClientPaidToEntity. Bills in grdvActivitiesNew_{n} + (no year suffix). Per-client compensation is in id-less Panel1_{n} divs + ("Total amount paid by client...: $X") indexed by lblClientName_{n} spans. + Each client reports either a Panel1 total OR activity-level amounts — + summing both sources is safe because the unused one is always zero. + Omitting this path silently drops ~99% of 2014-2018 compensation. + + Legacy (2009-2013): single grdvActivities table with a "Compensation received" + column carrying a per-client total repeated on every bill row for that client. + Must deduplicate distinct (client, amount) pairs before summing — never sum + raw rows or the total is multiplied by bill count. + + Legacy (2005-2008): grdvActivities has no compensation column; fall back to + grdvSalaryPaid entity total under the _total_salary_ placeholder client. + """ compensation: list[Compensation] = [] bills: list[BillActivity] = [] gc = year_to_general_court(year) - # ── Modern format (>=~2013) ─────────────────────────────────────────────── - comp_table = soup.find("table", id=lambda x: x and "grdvClientPaidToEntity" in (x or "")) + # ── Modern / Hybrid: per-client activity tables ─────────────────────────── + comp_table = soup.find( + "table", + id=lambda x: x and "grdvClientPaidToEntity" in (x or ""), + ) if comp_table: for row in _grid_rows(comp_table): cells = [td.get_text(strip=True) for td in row.find_all("td")] @@ -280,21 +337,19 @@ def fetch_disclosure_detail( amount=_parse_amount(cells[1]), )) - act_tables = soup.find_all( + # Activity tables: ID pattern grdvActivitiesNew{year}_{n} (Modern, 2019+) + # or grdvActivitiesNew_{n} with no year (Hybrid, 2014-2018). The same loop + # handles both; compensation source differs (see Hybrid block below). + activity_by_client: dict[str, float] = {} + for act_table in soup.find_all( "table", id=lambda x: x and re.search(r"grdvActivitiesNew(\d{4})?_\d+", x or ""), - ) - for act_table in act_tables: - # Walk backwards to find the nearest lblClientName span - client_name = "" - node = act_table - while node: - node = node.find_previous(["span", "div", "td"]) - if not node: - break - if node.get("id") and "lblClientName" in node["id"]: - client_name = node.get_text(strip=True) - break + ): + client_span = act_table.find_previous( + "span", + id=lambda x: x and "lblClientName" in (x or ""), + ) + client_name = client_span.get_text(strip=True) if client_span else "" for row in _grid_rows(act_table): cells = [td.get_text(strip=True) for td in row.find_all("td")] @@ -303,6 +358,7 @@ def fetch_disclosure_detail( chamber = normalize_chamber(cells[0]) raw_num = cells[1] bill_id = construct_bill_id(chamber, raw_num) + amt = _parse_amount(cells[4]) if len(cells) > 4 else None bills.append(BillActivity( client_name=client_name, chamber=chamber, @@ -310,30 +366,52 @@ def fetch_disclosure_detail( bill_id=bill_id, activity_title=cells[2] if len(cells) > 2 else "", position=cells[3] if len(cells) > 3 else "", - amount=_parse_amount(cells[4]) if len(cells) > 4 else None, + amount=amt, )) + if amt: + activity_by_client[client_name] = ( + activity_by_client.get(client_name, 0.0) + amt + ) + + # Hybrid (2014-2018): no modern comp table — reconstruct per-client amounts + # from Panel1 "Total amount paid by client" divs indexed by client-name spans. + if not comp_table and bills: + client_by_idx = { + sp["id"].split("_")[-1]: sp.get_text(strip=True) + for sp in soup.find_all( + "span", id=lambda x: x and "lblClientName_" in (x or "") + ) + } + panel_by_client: dict[str, float] = {} + for div in soup.find_all("div", id=lambda x: x and "Panel1_" in (x or "")): + idx = div["id"].split("_")[-1] + cn = client_by_idx.get(idx) + if not cn: + continue + m = re.search(r"\$([\d,]+\.\d\d)", div.get_text(" ", strip=True)) + panel_by_client[cn] = float(m.group(1).replace(",", "")) if m else 0.0 + for cn in set(panel_by_client) | set(activity_by_client): + amt = panel_by_client.get(cn, 0.0) + activity_by_client.get(cn, 0.0) + if amt: + compensation.append(Compensation(client_name=cn, amount=amt)) if comp_table or bills: return DisclosureDetail(compensation=compensation, bills=bills) - # ── Legacy format (<~2013) ──────────────────────────────────────────────── - salary_table = soup.find("table", id=lambda x: x and "grdvSalaryPaid" in (x or "")) - if salary_table: - total = 0.0 - for row in salary_table.find_all("tr"): - cells = [td.get_text(strip=True) for td in row.find_all("td")] - if len(cells) >= 2 and "Total" not in cells[0]: - amt = _parse_amount(cells[1]) - if amt: - total += amt - if total: - compensation.append(Compensation(client_name=LEGACY_TOTAL_CLIENT, amount=total)) - + # ── Legacy format (2005-2013): single grdvActivities table ─────────────── act_table = soup.find("table", id=lambda x: x and x.endswith("grdvActivities")) + # Distinct (client, amount) pairs from the "Compensation received" column + # (2009-2013). The portal repeats the per-client total on every bill row for + # that client, so we deduplicate before summing to avoid multiplying by bill count. + legacy_comp_pairs: set = set() + comp_col: Optional[int] = None + if act_table: all_rows = act_table.find_all("tr") - headers = [th.get_text(strip=True) - for th in (all_rows[0].find_all(["th", "td"]) if all_rows else [])] + headers = [ + th.get_text(strip=True) + for th in (all_rows[0].find_all(["th", "td"]) if all_rows else []) + ] if headers and "Activity" in headers[0]: # 6-col entity layout has Lobbyist as second header @@ -344,19 +422,35 @@ def fetch_disclosure_detail( else: bill_col, pos_col, client_col = 1, None, 3 - chamber_map = {"H": "House Bill", "S": "Senate Bill", - "HD": "House Docket", "SD": "Senate Docket"} + if any("Compensation" in h for h in headers): + comp_col = len(headers) - 1 + + chamber_map = { + "H": "House Bill", "S": "Senate Bill", + "HD": "House Docket", "SD": "Senate Docket", + } skip = {"Activity or Bill No and Title", "N/A", "None", "", "Total amount"} for row in all_rows[1:]: cells = [td.get_text(strip=True) for td in row.find_all("td")] if len(cells) <= max(bill_col, client_col): continue + client_name = cells[client_col] bill_cell = cells[bill_col] + amt = ( + _parse_amount(cells[comp_col]) + if comp_col is not None and len(cells) > comp_col + else None + ) + # Exclude "Total amount" summary rows appended by legacy individual + # disclosures — these are not real clients. + if amt is not None and client_name not in ("Total amount", "Total", ""): + legacy_comp_pairs.add((client_name, amt)) if not bill_cell or bill_cell in skip: continue - parts = bill_cell.split(None, 1) - bill_no = parts[0] + # Bill token may use a semicolon separator ("H73; Title") or a space + parts = re.split(r"[;\s]", bill_cell, maxsplit=1) + bill_no = parts[0].rstrip(";") m = re.match(r"^([A-Z]+)(\d+)$", bill_no) if not m: continue @@ -364,13 +458,55 @@ def fetch_disclosure_detail( chamber = chamber_map.get(prefix, "Other") bill_id = construct_bill_id(chamber, number) bills.append(BillActivity( - client_name=cells[client_col] if len(cells) > client_col else "", + client_name=client_name, chamber=chamber, raw_bill_number=number, bill_id=bill_id, - activity_title=parts[1] if len(parts) > 1 else "", - position=cells[pos_col] if pos_col is not None and len(cells) > pos_col else "", - amount=None, + activity_title=parts[1].strip() if len(parts) > 1 else "", + position=( + cells[pos_col] + if pos_col is not None and len(cells) > pos_col + else "" + ), + amount=amt, )) + # Per-client compensation from the "Compensation received" column (2009-2013). + # Fall back to grdvSalaryPaid entity total when no compensation column exists (2005-2008). + if comp_col is not None: + per_client: dict[str, float] = {} + for cn, amt in legacy_comp_pairs: + per_client[cn] = per_client.get(cn, 0.0) + amt + for cn, amt in per_client.items(): + if amt: + compensation.append(Compensation(client_name=cn, amount=amt)) + else: + salary_table = soup.find( + "table", id=lambda x: x and "grdvSalaryPaid" in (x or "") + ) + if salary_table: + total = 0.0 + for row in salary_table.find_all("tr"): + cells = [td.get_text(strip=True) for td in row.find_all("td")] + if len(cells) >= 2 and "Total" not in cells[0]: + amt = _parse_amount(cells[1]) + if amt: + total += amt + if total: + compensation.append( + Compensation(client_name=LEGACY_TOTAL_CLIENT, amount=total) + ) + return DisclosureDetail(compensation=compensation, bills=bills) + + +def fetch_disclosure_detail( + session: requests.Session, disc_url: str, year: int +) -> DisclosureDetail: + return parse_disclosure_detail(_get(session, disc_url), year) + + +def year_from_disc_url(url: str) -> Optional[int]: + """Extract the filing year from a CompleteDisclosure URL query string.""" + m = re.search(r"[?&]FilingYear=(\d{4})", url) + return int(m.group(1)) if m else None diff --git a/lobbying-scraper/reparse_archive.py b/lobbying-scraper/reparse_archive.py new file mode 100644 index 000000000..32f4b1563 --- /dev/null +++ b/lobbying-scraper/reparse_archive.py @@ -0,0 +1,155 @@ +"""Offline reparse driver: re-ingests raw HTML from the GCS archive. + +Downloads archived CompleteDisclosure pages from GCS, re-runs the pure parsers +against them, and writes results back to Firestore. Use this when parser logic +has changed and historical data needs to be re-ingested without re-scraping. + +For each archived disclosure page the driver looks up the corresponding +registrant document in Firestore (via the disclosureUrls array) to obtain the +entity name needed to construct filing document IDs. Registrant documents must +therefore already exist before running a reparse. + +Usage: + GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \\ + python3 reparse_archive.py [--limit N] [--dry-run] + +Progress is tracked in Firestore at /scrapers/lobbyingReparse so the run is +resumable: restarting skips blobs already marked as processed. +""" + +from __future__ import annotations + +import argparse +import os + +from bs4 import BeautifulSoup +from google.cloud import firestore, storage + +import archive +from portal import DisclosureMeta, parse_disclosure_detail, year_from_disc_url +from writer import REGISTRANTS_COLLECTION, write_filings + +REPARSE_DOC = "/scrapers/lobbyingReparse" + + +def _meta_for_disc_url(db: firestore.Client, disc_url: str) -> DisclosureMeta | None: + """Look up the registrant that owns this disclosure URL.""" + results = ( + db.collection(REGISTRANTS_COLLECTION) + .where("disclosureUrls", "array_contains", disc_url) + .limit(1) + .get() + ) + if not results: + return None + data = results[0].to_dict() + return DisclosureMeta( + entity_name=data.get("entityName", ""), + year=data.get("year"), + reg_type=data.get("regType", "Lobbyist"), + disclosure_urls=data.get("disclosureUrls", []), + ) + + +def _is_processed(db: firestore.Client, blob_name: str) -> bool: + doc = db.document(REPARSE_DOC).get() + if not doc.exists: + return False + return blob_name in doc.to_dict().get("processedBlobs", []) + + +def _mark_processed(db: firestore.Client, blob_name: str) -> None: + db.document(REPARSE_DOC).set( + {"processedBlobs": firestore.ArrayUnion([blob_name])}, + merge=True, + ) + + +def run(limit: int | None, dry_run: bool) -> None: + gcs = storage.Client() + bucket_name = archive._get_bucket_name() + bucket = gcs.bucket(bucket_name) + + db: firestore.Client | None = None if dry_run else firestore.Client() + + blobs = list(bucket.list_blobs(prefix="raw_html/")) + print(f"Found {len(blobs)} archived pages") + + processed = 0 + skipped = 0 + errors = 0 + + for blob in blobs: + if limit is not None and processed >= limit: + break + + blob.reload() + url = (blob.metadata or {}).get("source-url", "") + + if "CompleteDisclosure" not in url: + skipped += 1 + continue + + if db is not None and _is_processed(db, blob.name): + skipped += 1 + continue + + year = year_from_disc_url(url) + if year is None: + print(f" SKIP {blob.name}: cannot extract year from {url!r}") + skipped += 1 + continue + + meta: DisclosureMeta | None = None + if db is not None: + meta = _meta_for_disc_url(db, url) + if meta is None: + print(f" SKIP {blob.name}: no registrant found for {url!r}") + skipped += 1 + continue + + try: + html = blob.download_as_text(encoding="utf-8") + soup = BeautifulSoup(html, "html.parser") + detail = parse_disclosure_detail(soup, year) + except Exception as exc: + print(f" ERROR parsing {url}: {exc}") + errors += 1 + continue + + print( + f" {url[:80]!r} — {len(detail.compensation)} comp," + f" {len(detail.bills)} bills" + ) + + if not dry_run and db is not None and meta is not None: + write_filings(db, meta, detail) + _mark_processed(db, blob.name) + + processed += 1 + + print( + f"\nDone: {processed} reparsed, {skipped} skipped, {errors} errors" + + (" (dry run — nothing written)" if dry_run else "") + ) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Reparse raw HTML archive into Firestore" + ) + parser.add_argument("--limit", type=int, default=None, help="Stop after N pages") + parser.add_argument( + "--dry-run", action="store_true", help="Parse but do not write to Firestore" + ) + args = parser.parse_args() + + # Ensure archive module can resolve the bucket name even in dry-run mode + if not os.environ.get("ARCHIVE_RAW"): + os.environ["ARCHIVE_RAW"] = "1" + + run(limit=args.limit, dry_run=args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/lobbying-scraper/requirements.txt b/lobbying-scraper/requirements.txt index 5e7b4bcc7..d92c075e4 100644 --- a/lobbying-scraper/requirements.txt +++ b/lobbying-scraper/requirements.txt @@ -1,3 +1,4 @@ requests>=2.28 beautifulsoup4>=4.12 google-cloud-firestore>=2.14 +google-cloud-storage>=2.10 diff --git a/lobbying-scraper/tests/__init__.py b/lobbying-scraper/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lobbying-scraper/tests/__pycache__/__init__.cpython-37.pyc b/lobbying-scraper/tests/__pycache__/__init__.cpython-37.pyc new file mode 100644 index 0000000000000000000000000000000000000000..33712a9e9d1c30a3e9f3117c1aeee0bc732287a3 GIT binary patch literal 166 zcmZ?b<>g`k0uBNDED-$|M8E(ekl_Ht#VkM~g&~+hlhJP_LlHU z)_2KIF3nBND=F6Z@ClB0@^+4QOU=p2FE7r>EYQzQEXYaK&&f|ps?5ww*DX#iN-Rh% m(l1FZ2C9#b&&p literal 0 HcmV?d00001 diff --git a/lobbying-scraper/tests/__pycache__/test_portal_parser.cpython-37-pytest-7.4.4.pyc b/lobbying-scraper/tests/__pycache__/test_portal_parser.cpython-37-pytest-7.4.4.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e18d528fef20c09e455674b6193d29d211ecb5a7 GIT binary patch literal 17098 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 TqF`psEDqBgiQT8WIWzyiIwjag literal 0 HcmV?d00001 diff --git a/lobbying-scraper/tests/fixtures/2007e_disc.html.gz b/lobbying-scraper/tests/fixtures/2007e_disc.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..4311a97da1c89818ae2c6c06bfc17f94a57f8001 GIT binary patch literal 21790 zcmV(xK(&PXmo9C0PH^BOhl4RqyU$F~B@ zb+E1L=WlDM)(?W=p01BZqxz^_cfFo|b)nzTzM8bLxYPorW!((S+7TO^jd?S$9se++ zr{?bNF7Kd*j%aOy&7*_gGz}dLNPrP-p^IIuc4sUgF40^$UR zwO8kd#Oy(deso}4&R5j;NcXU&8@{jm!L3d3aOl_Ju7*5fAJ$lz-zOxfq2P8%4r>9q z33OJ04huC5j}Rxb!@wOn__|tFoZ z(SgqI=IEp6x;>kG#xNtdfn_B6(KJ3oK|lpO0Mqc>N8*@P_fraL-eKSM0wWBN1xyk3VGVGP_<9##)8hKj>B-^P z6a7Zx)yFWabilq(0$&gPk?Cu#&&}rN7K4fx0843Ii}HFA8f6Vc`!?Kb;@z|F%t=j+ z(Yf#3qO*5zuby0?x98_)h?R4>6}-urPz(#a_~lzjyxaP~awc(eb_=atZ`Yf3-?j$x zH14==;DbsUrW+v@2?qU(cNggQ*Y8e_U!%9je+p!>I*#$e7kO_vR!|7Bt$HKg<{+>F zn?TcFUb~&nt>p*keb})r16>mA8GYze=T+3nrIUI|r|^*y{`W!D$`#Qdq|<4pnYNl(3w7mi;ipF9w9y=V0U0z*T^ytYi<}2k6Maf zeuLm{>PR>Jajk2rfE4nt>XrPJJ@SChBv=igCQsd z>;(JYAN?BI_0Y!}XztIShaQMO@FFe1hT(c97_HP}t!KL(Y{zSfLS$R(XaSwV|93Xb zy2cRID24YN*CG4xz#&?nSiOF**Q{@l0Y!>Zmw0QmGk&RQFdLvJi7EPQ*2fR)S@qOH z1-1#@-2o0g%hA9F?zJ|PG_LbX6yfpQ+>I9V#`PMbLKEF}p?9r|2bO)iSG#mW&mia$+z<5LbI}LVsjZ_#={h>aw$<@q-p(x3 z4c5^W?z;oLjxJna8hC!?kxppzNr2v5t|K4XX+H6+ZX8DykXA83#P|_Bc>!T07NkOP z4q6@{9SqXf0Vt?R;lf85RwWvnK)eQhGrE1yWZYfqtVz$oNIP=fX@V*3V>h$w66+KeTT~rXrZXz=t6Y87P0<-^!O7b z+21DJfblT;o*&j2=fnV4go<@45TOFYrq#FpCVS0|1eqTFYFlId;)k69aYD@s1URWu z35H3A*bAUI04K&6qB}tpw6#g4l3GUn`QsJ*J=cFi{?NZgK#tJA-<+SlIz~^yr_#7E zw6=GjAynol&vo>Z=nwr{ZOFMRBL4n2TNtT4ihxOnH_z@K{n`n^?l^H1{rR&}%9Jy; zg=;3PAB3|-4J7i1*ooU2#B72n@tCS+C5ma-jP8s2MDv*bV%QDcVQ7Q1@WUdg*wr5w zNr#M906Qw6#qje8t-2B@7DsZ(LJF9MYJYG{p^IzFAU`;kHo=|)%j%WDvM7?c1M9_= zz>pzH*JsjKSY@B*C*%+Nd#DA~&hGZl1(p2$WS58kB^Oj;J%w!>VaJTEtAJPOx=LtP zW+DvNmjBAQmIyxcC|k_M5%J`<5*g%%qQxwQh+gs{-?c3hHHSB7t=k<(I_DX$B?sJW zBnK36@0^`Rc3t#`7zQzLQ4-#{JCC468|}x5$gXlJ`$(0byJYyXgN?5}&kY?@V`DBmnW!m`9+s#@PjtJ&0i*IfWAO zT_w+u3Z1fa42N`AG-z#RGE!_}=#22Wk$g10>l4YOjmJC&o0*0|>4Xvu?V=<8zz97L z)}MQOxEl~}9Tn*#a=!NZG*2x0LU+on^4K-o?*U6*7%&mOr*urjHB=_in(@pL5&`I~ zRz@Nq*^x{hq`+BgG+NCKEIhD`hF#YP{b}v^ZW-Ek&b+{#Y57AN-|ls6*Z7)_=f|@V zJfA01TpMFnV$8dh%BtnbEyRRyGeyREGAapNGV&B0&T*oGBN0nbl0l3L?Y7J;B=UHP z!9;(}oml3h)lQNmo`j=o0u}#eJfWoalz5U>)8ovJg827hIi4w#G8v{Mc6ZZi7J`bA zYjUql3`)){rN-cfoMi<0x=-rj)=t8Lq;w6?&USIIM`sFViJCES9aAx?;Eg9WrnFg| z?-CA1mN-9tPrwMKOKRzs(%S?%-xyn=l*UD($HZJ@HMgw)E7AiPABIx z6lk;A-ri~`&4nSPbWz-s#zxE7Xht4J={V(qjOn0h@rP*xq->hpbi(*+dp5%<$=%IX zQkKhtN*KrtgDouGf`(=0HSef0E>gzmBC!ykjjhbtkoG0b?Ii-Tgb9!%&oKu|c&ZEt z6xE+G5uEP3Lf@7=-V*4ojy@OPmq~J(ipfwx=@RLswHLzm6pSTI0ULHcepgq7Wq=@Z zMHzFzDV|)hN=_n{ptK5tBIGcNA9N zS_IwYuuGtMG;{&CraTQJV|0`0zOx9r%VC#5_h{&)nN^l&6wAEr^xC+zA(lW~KJL8g zJAb;iz?w}AO+%!JMJ)V2Hr){mpVB`KwsPb#n5>`%Xrm=4z@Qk1tMNqLbqbG_g|V@3}4}&u6c(pgWE{8(#)-I&RRHdaad= z;l=2owKBpj%~&o6n^x%WWe1ZuxAB^4oF3RLE^}Si8wiLsdYuWjxXA)A0~iqzA<(sWBE_-4LC#llU zN(@lI;kSdME|Sr*toh?*G5{A3k~bd50T5BOiFi)Ia;KS_PR z&&-Es&rc!NN86i^(S$KYGt2oxjDVOLP5pr!DzDf*2bm0>`ox9`&yq>s^s-~zt>yRu zwr%aU|3)+a^LE#|9(-$^+`er!|8gX_9`OMcc}HJ+W6aEQ-@GqQT}sByS$4=P$jmArG6$;@I7q!oF;41% zI$Ni<>l5?_s??6?-v>G$MzSdfL)_5Ud)3D8Ma~$?tsY~DT7st~LuYYwij~v93@H)Y zt5EzltN+BKDZ@-4FYCi#1d88gj~0KFCNduYn8X_N`0)i)Jp63=$UN79?ogw{(X&B( zosT!_ThPY8?$waz+Ux)kxCGXh!=CVA5vzWbnBR%i2<;RlSX7T6=(d%ECGs)#qPZFjX*qDBOf9;2 zP81>4EN0x8z4B^}nf*!GDirA{pc2%X)SIom@FhwkIThT^0DCfd^j<@@|l~c zdX~KxktC!>j8XEb$}qCh6rXp(Y{@C;xHnSvnb5bzA1eIt!|hxeKMN~%Jdo#(ki3|~ z{!OD4s4gV_jT_s{q(o9wl#PM&0>XgF@D|kgUwXEbkYqwZWyK9AfXgMh0K5$545g}W z9ZkcmD%B&f=3`4eNe18jd7&p31!MHU130=MJwOw@*}z4A!a6>(D07*RWuOM^XZbXF zq{EZYvjU5#m-T)yn2Pxe%b^P)kl6PgAkHLl6|sl49R2gvGIjW4nKv^rKQ}|3cLUrn zh|k{a^j8=4!1|jwn&r@dHjg`r9SW2XYd!Z`v&l8F{i!s0&8pC~`XwC-{Z2zj4o!Hj zDq(sN20C4=f%*;koNI2k3)Jy)iHM2d;knJiYUmwp2of`aBn8yA9@_Jo9p>wnZ>lC) zs#XQfQ6ZQZT=(Ka2AA~VgxvXBFSJZ@AJG+E%;d&XQ62qGt5bu;MfFnbM_N@u?b8q~ zzGWJ0CJ|op%IMWt-K{F#*980y?63=(`{8zICIuAC5nQ1^MK`NT29~2R0zGg?jwWtq z%L1>5FaoK#ML>otXpUZDdy;ZqG96%bJxmdT7LG_qKZSq!A+E}09s9$ZsYKxQM8Mq} z(u@E>XE?we?3d)o{*WfET}6|2yd0Z0_gaINYJE5Ki57sy$|#vByxeTPq%A;fp+z-C zb~+*0W(Rh4?FKX_sW{-Y2>eTJWCeZAw0zvL?0k!|KUqUW8=^C%;VM#yIpNe*Vg7eG zpcJvOLld}p^gC-&uAP4X-84`AymcA2>}u)xm)xHB!-S!eacE_mZsAz;nc7D=Vd!9# zO7Bml-NaDzb}|&L?Duasi-x5YswshKg${|BwDKg9$(0E*)47-@$h@r3amsm7x2G!@ zsGD6H3HI1rPBeL@vs1_cV}RX%{hm08U~a1hZFFp!uwL`23h07;a{>#4nZDJAp4;>A zV5W6D=Iz02kF}?PSlYe{#y&->0raHN^@ep#{Q0RS8b%j0X%yQsgpNhCDv)LYQQACl zAJr9-F`tzrhdUTpe(E}hOBmIy0@_SK2Ed--pb!b<_Bf6AkyVz9B+)jzH>^#PG(XxB zUVRa(g3I`g)mX(FB3zWZ$-#2t%2g1<;>-5vN(1L!sdwt z;tcu*{WCiV9}R)VTDb}onHOPOd`T<1n2GvpQLz#XFfY3Nl~Z|GL*jNO>f&o-7rgpP zL}PfPd)k>QhKn~>!_EMQ`M_U2n(z;eCN&bB#4{F{fTgU5CmdAFL| z1sA?5(PMO_~>wcvMasFKZQOogF&g{vFtX~C+ z{2)oB!UidC?Fyt3mYzY#ZB3W5k)0kLRycF;mVN+-9#DRMU266KM2pCmF0!%$JIMnL z+gqh=S*HY1k^%`76Nv%#l$&h+disN?IZ0MJVXOThzsCs-;mZtRQ{^q4=^e*;zU7~Vl-fV zBy$IGy3bxuuvE(hK!W`WIgmq{L8ANEicyDmVL9JIYYs}hlJ!?&1;5Ds6ifGoaz~4o z;tPqmP+@oILbfl*4eElEfB%>HGCd=CZcn?Ymn1`IV@F9KpKy?pv9r0{B8C_>;kg_c zMy>^TQRR6?#hsV39HL~uxt$^@8b!^=Vtu3SNg`VmUIz31O`f2dM(q~MU*iZ+^+cft z`viN!B!VcKR?iBs9qk}Uise+>cg7oMZ6wIMFn$2a#5Y;wCQiZq7i%yq4C%~rEtw*F zgB{!>gVJ6IyFcYh&3=H<8tHgAB-o~#d9h{^$HMlUNfC6+dMJy#3iwyNS%GByg8Z_| z_*%I$Cd)ZMK}mWPKSd$dNl9K8D2sCTuBJS%!rqojEcGM>HF1h%aVa(r zKb^1S!O0c%z;==wbonWw{H(U+x*FqcFF?nfHuMzRz@6CpKsx$+@B)1O4QP=52SUEe zN3-8nlUrxd8IXv&G)T6sibKKZqwds{TQnJ|O7zCWkp*!@c8_hKC{w>xftusZNE2z; zthbBMknUBQi+=G9#hF`}X3%j7PTX8gmY{!&zlpzK1$U!>gy`?>U zYZE#XV5RmrRbBC*d3DcXvjDldb-_!{+&RjnEIH+8j9^t!bisKUby3b@)3wMxs(pXJ>cm4vl(_k)l}2 zTQOC1$6%R6R9>u6816@V*RUjNu>&?Vv57i{VhLx z6liHOaIC*er;$U>@GT7por7ud9Bk(&8MH;v7+o^h{;nR;TpaZMh3GN$I9oYrMpLZWponv7kPBu;q+c8!k=-g3})&FLqnj*nh@!NIMRD&r~b zg-~!I`E;H@M;Ij%>u0R_@)^8@De1YcsR2n@glWDB$vj1nG9Wd?r^E}aE{!h{U%h!p zS8gxB#>`eLY8TYZ{0^YfQ$`u6mEvo$hlsm1LmNBJ+{Db0Ur+|oGe^v|=T^2t??tPU zHX+|HzE`n`U81ZK0le)>2k_+guxo|mcdw%!N{Zi+YvPo*Tcl9HPEth)*hw@K0Xtc| zq5(Tb>z_M9*vH=_A6GLz*K8~=KzIK@n+bDz>xSA_#~sCPX4<|p;J?4z5dAMy{#T{E z2cG@ERT!tpx#iIo0_9TkzmmY{=R88?cJjv&t3W2YSsD}9c6;|A6?LTWxFbCvfGkyD zK}e6jo8#nj1)TABJJ=cMSG-S|RK4U~m-VvO!|lRl?e3!itGXYf**IBpl7ca(qSt<= zASRq<*Q)YycB^7W=l>`X`_{}NzfAOyyFse9xU9r#a;MU(N!)S6p!ibqm^Md$l(rRw z#cs-3O70Y0N-~lt>qv=XVjU@qS92Z737y>fA=i=h&7ZqJxMWn+uPW^DG2kBj5H#ic zYxMNRynDBKx)Z&2E4Ify9|_>zz#hr-#ko^nZ?*7KUNfblqJ_~%o!~O}r=0#+S*(59 z7!PNA|GZu${JDxbXo<2s@0CtPKii@bU`rFcGL$ZgG_H)NV&iMib3@0}_&3P|`i-?A zh$s63`g8!N!1bFp^M@|ak1tMNo*ZAG*_GjUG<#HeYJ|_W3DgicVPt$4-_pyzDb}@! zZB9e;pzkR?YJIP!&z$cAy3s{#MeieucWoC3d+arh$lc^GZ6rTy0r%?X=x2X__`q_8 z;GzY$Ll}pCRv!FZDWQR8@=X_-0E*R(ppvf_uuo|q2OO+vbenCA4|Iz3^f#iV^h~UA z;2WMbR1wm@;A_lFr^E2H8#>JYdpcS-*Ms$N-C6h7$+NrB$0wH~t4q6l*ZBfI;*(E@ z&i?45^XZWO_0K<1!)JF>2cWg<-wydd|NQgu)3f?8^!ra?{z7_ufd6d&`@Vl@)(ww< zHa)l5p#o2XXZzE-CD5Qp0#VyPxxK=@w=f&f?qlQAzF)`wtz#TE;fi|t;XbKzVSVeG zq;5Gr@q!Zqv?os~x>Lvc{_N=pR^u*E$Tb+u>rXgspR7O8br?Yq$}pX};SO}@{{Ps! z)+R- z*>r?a*>E_Iz0cZv?ekn4uKPK_Kd;uGoE0SdZMFUhm2#~dNb}*R?YHnrJmG(~+CpO* zc)#OhEZ}8r4v+Cq*eC3#Yc&RklGg4fcko9o*!8#HvTr3?ph^O*-elu0w(FXkx%}>5 zpTYE>dGoK&8%~{iN7Rtg-!uJJqUTnrm42G>i<7TY1HP#Z)L`woDe~XF+Z*lh5v`+1 z&pvtwkH3ZezkS&C+K-F6HI_a96D`)dg+{rlggET3#v3~j9G z?%R*0|4*Cw`Lp2yWKq$m>%)`3eR%&rJI)0P92t-AKSJFN3vvVj{fwI7BCo#B|L0Az zl$Y)B^X@-s1NZ0nZ=R0b)wuMnmM((FtSt;Du-fA_~HdQWwi3i$?Q9rmMN*4F7-FnE+nUxq!NoAINu zxS?s=Mr{Ot!*`pNZmaJV2HF<7TR?YN97i|dWR=BYKDLbbd#m^Kx}HP5-lY}W9j=sF zL)-MX_45E7vc z8+>tN^55p;&s1N#W~J*B(5CQzpgrUtv$lG_ z9sMO-$_0h&+RzjR8LMT2;YVEsZI9obc?qWBd+N8p2;*Gk{)7HH3UBtD_(|S~8mEkX zw}l=e^`HcAN@ySw9gUnu5u2D`GYqj=+W+hO zf819a*g$@01d&wrMzv;z;3B#Di_JtpJWn_oz_rbR@dUJ7$fS=tPk0o=$2_-EPbnn`@HW ze+d!&Yd1-;-h=-ilT6U> zBZhar9+ROyY%fQ6f2I>$iwf&1F{u+|-wgRd9sOdSA}U-Hvw2Dj_=E^}hX@5oPuQy- z6=}_uZK;s|Qu88L$3>N}Ig)~ZpuNa| z%|%J9d=oe(UjlFC?pud zRV4IX`%o`laOg@$5@SBbLZ0HP=>3;ew_=kxq#NpNnXo+2XRBS)PU=(h_2H@RbQS5Q zj>=_Q6Y{Ihx`~FB8HHA=Cad@+t|X)}9S~CZdCZnqTuXP&xd45j+Ta}8=bM}jZFitg zEJvQ%;N}XZPq9dta-VxP_N7NNvFR~23D@c4e$^jNLijGx2V`}mM!uOWt0pcsIB$Yk zE#j9>+^AR;)3*}dA644(PQpQ(=|H8+yo?JA(9MLK9)CN#>D!L#`evm+8F_-sIaQ%K zCF~yEUz)hE8dZd{=_$;(MY^N8;G%W_ilB!^6g;0;(7(j_>_Jl9IkXKk)P~<{HcsAA z;sPNL@)V#SF&N0XH*%^eJ~|Tm@sUE&ao3@%@Jl+5o$yH*h?TaBbfwdf=b>By$yfK> z&2tB}$6TksgpqlP`-PWm`0;+1;uEOLY5KGIv}?Utd8t?0P-iz7Ibk5FjhUh9OQ>se zENx^0-_;e?UF%>YG_@S|W(+CH>94KKmq||wgOm4%vf3sF@|^c;Jx`yES|G<} zojYQo->Bk)?lCJ9m+KwOP5P{|(vd8vLtO@t2HJ@W4uR6lj`A4*3fqu2gmQL&@4u5& zCN^+o*7q9mHeb|-kIE}gOm7Wx(F1d~sZXlcL;mDQZIr6jFIbRXm@Kk(Cft&wL;p~4WIJRwnrOIecalS$_ZX1{)UW2ymyymGFg(H8 zcSm*D^V;_kMQ<%th$LsgG^J|9()6m`JuiI%wUzchh*f{ zmS#)}falO?&ET8uyAz!VOOth>E`3sG!KQ*e;+pkCehljU+>itlZ!I#bM+S)`wo zgG#_hv5k%2=iLS0bKIWuTtn`I-kIfyj+SOc+{+sBI-g!Dc)=gy1x~w?6ECRn)GRk% zAUI~&ab}aeLfIbWTL3);69I=d}5B7fS1m7Z# z`j8cvsNczojO@(0cSrW-)CQnFlN{L-rC&2o?9-_oKsHZe8&hrW0*3mhvaIwM`jC47 zIr<~d1?-l~=A2xNk$v61JCA#2rlUBs!k^eb*;5>2NG_3VB#&9^QjOP#lQP&G)=+DR zyDv30EI7hFa|FlEi6fZM=G-^}E+hnS1R`sU5To%EA;sa5jd+@#Ay=9T@#?YvJm8s1 z`w-rUQNQae;<3L!dUu9jY%vdgCONhrTkdec_bKj?Jw4?Ohx+y`@ZqLY+m7S#_HLX8 zzS+Jzk8@G&&!B%NW_)7rp8NV)dBHiZ(!>Ks`VnHMigZcgz8%jd@-iK**H?*nU^aHZKSq zATBsSf&Vv&e~eLb!@ooSAoBroz{hVA|1$J_->i@U==0Erim`ydoE?zy-1ZMt!4Ky< z79fe)c);6TA5`#wzc~IC3}W$o@Lv`h0@N=R!DiCL7@2p zm7OaTV}`eK1q*CJzgA%XuCX8Fhy5RA{UGp!G0~V!YsO;y*tgkcc^+H^O6`bnQ?U9-sxhQ|u=y_7c9eHA5C_<+Wx=GILx% zEaKfJ=!?pFt^ikko9B=*KKS}zgw9@7+cS{a#9*1(%W(kEQ>#BI{Qo`se=W?<|K~Vg zkj#MpXFG=w@&9hIZs^8+gy^i`XjNeUp0Ph+^1?p&{~YHE>1uZFPv6g6;g7@pC6Fm* zv@A#~7W;>rce{UhIH2=7hj>=&QaSD)j;olE?JSW9_+uVpzjUAW;N{L=6g9O!8{xtuM@N|6EY4oKYa1XDx!vI+ z#wlwWu*<A&$HfLi~#TZb3?7oUv!<-5cw!{PU9qiwij!z?wJ!z>v^5eP!%LC&-IpZ$Kf)c z)5xRWTjm7CxZz)&6AVRJ&WRJ)Wi%(3A1APVwD%(OnT;=dX4~D(A-?)JL90LOIK@6J z?tb@Sk+PK6+09y*-##oF-Q!Z}W?P4e<`RoJgklciZN~>#Zuh#?ggnO#)A(S9=XEa2 z{ly3A9716O{+c!*zcX3#+?VBupf3~4>kP;HvjJ~wj3C?ojPiDWh5+#}39*Oei+IDg zTJ5Da-}IfdOk&vRCoj8C<7qiuYqB1(wWK?rBszsnjaDBssYTGKSQRU-Rrt6Tn4TG+ zScD%mG)aym4WGCpm7X4dYe+YJyEdTDft~e%!%okStq1`+W7AOEE8rVUw zsVlT66xlS;t;aE7Z#@BxdAa74q*6P-*?Z=J4|O2E0-G1P7dX%EjLGtNKn}q^t_+n~ zbF{TWct>{f(m2Ocp7Ip$1=hX~Yoq+I7UhdIwEl_6ewLJU{XWX?#hHcj8;}y@jmAfA z#noiQ$~N#KUuPNU5Bz0*@N69LJXW4?2nzTWW_s?_dNxK}%nuyq2NX!zbK(+lI^h`xGxO#9)vg2Qn}b$Ht*-z?cDLq4D}t z{9WbVhw>llS zVl0N~=QD0ahy3OH1Q371Uw`b1xPb$5zyakn_P@<%X}9<+v@Q&Es}*t85ALUOy4M%^ zspJRpQ%NT=Z;h+)KM+?9^dX7nTo7hZzr<5+`^2YRn~M?WfDN^3gUwl+Ad~TxGja=T zy)L%&{IG=;*m`+v4R8fF09!}rSr%L{A1;{F946v|@(;uX`QRJt8^4zazSP)-1bx+q zIIE22@J3hG6Ba(}b$yna&sss`v*I7lXT5HWwZ#_rD-V@N;TSCAj+&$ZZI7Y6 zf(zz|tNiA#=*xE@+#jy6PNKag7i#OIhkVA;L4 z-mTz)uge3K{CMES^{jWu14YCGQ{BJTIQw_wsUo->t)-YUab{M`UFGMi%=~;+y-QKg_(6fUZ$c2p8R5+vOu`h-f58o}5E&ooyA!HP{Xtk=JU z@FpaBj4h6v<@yB4T9eyIKQle5c7`$2hCa21a*%GCgeQ{pCLfg`LK(h?zuO)vkF+^5 z{g>5xI;qiwP4}osYqmmyp|Hc98vA>7)c%aH`E43zp+lVru`x^Yi)6AX*QoL;JX|ggTk&#HNmfr}Y?-Zu1GZ zLg(6bZ(9X?KP()O4EFnW>A(94oxar z_5crtR70|IJ5AC-cad=|qHaiFyYShA!8Uw-5ZWL+UuP$P@BVBrmyJAFUJ`2^N5pq! zozxfIb%4h0aim)s^@HYdKY$v7?NF*5i{5k3-m_&gK>R|-WvG|10Dlys_CZ~dP63Nb zyz4jUTivcJgeX^pX%*|Hx<H{17szYJ|#-(%zC{YXYOf@S8Y4kkWX znk^FT`&ohPp)M}258E|;FS8BiNQtj>965n{GS79Z1;XI6Zkr5}pZg0Mqqc*F0rrK7`8apc= z^c+O6?F#zsyCgl`NB5f)zmRS7{n8$NdaV+-l__<1P=2wPINXD5_i&s~%69nJss3`Z zEutT)CH*G$hRD~CJKfUVLc|>G{?fSfLOI0&atfNKyHz%UKM~?Gd-&C23&cVMVJ zKYmY;PRRAZ50<`MhjFNndt_J=cbk8^1nQheX5`M3l| zr;x9`e*VKBx|>c7>IGFy%18ql<>lu?9`GVj^E$WBvWdHq+tW$0Wgd6SuC6Fzihilc~SVYHz?zl{%UgbW6{zyann2uG|!&^%3oY`v%`3sT*`v**{mP!|% zJ2epZ+#2@)iS&Vv_T1|r-VrlQB}QX?v~=s`qivhD=@@*wp>G9xv;i9#h+0YM88;NZ zi{b$5!%!gdP8jn$1 zr4QHLWy`)6mpy}PdIov5Mz3>6vLXNE^(S3_4RnKFs2-0g7xXNXxx@YGyX53?9k+Mw zxq56Tb7!^;SafB}#%Y|n`W&9}r6K3wyHisd*Sk`nCw<}Jd~^_DOp$hZ61Aeka7gF^}^F3%7?kVbr;oH>B&K8`pzd%jES<*nxJ#{3GPu>kZ^F1@i0ZQ}h|d z)^;AuO=kOydct4hw>z+xvM0YM+xl={0zCgi{6~6@UYqYrc9Y$;yL6_E8{u3F#>l|- z@tSJ28=2dBV8$ZKayqME{^jOH9E>%t93r{UDw+$4(Y(pcsW2~s@!j05;?e;0?tMIW z!}%@bw!3yHzdg!Kn{B2me%qnbbMV>_-$fYh-8}^j3g-(`vF*vXo4B9TC3Q-x6!>G( zm9M*xP^;|M!^@zzpM2Id`7A2$SzfPziRREG=wr{1eLSm7ytfpxhm#$&jIt&cEAKP% z{Q9T?c4%OY5#;O%J5Jz_>5dw+Nsp9$97cI#JfDyb=?K`vL|?TpU!>QX9>2eoqRS@P z?w5A$vF|&hy2O2lsn^q`T8CIj&M~jMOrNcu!5yUf4(h&Pnodi1i)A^G9UJT0hUYTR zwO>Z>?WeAfOY@mbdMv68`B7bkcwRO!wqq>!^3=2W>?H2@df$ympL)^V-7VN%5VsN- z9#mcp!&P>sip3tp7e7A7V^%$sPe%LuCoJ?6JaX;BaMK>(m}1f=YajNVC-~$i>XgIp zQ9*rjk!>=#M*+@3)Q>zId(s`SNA#`Lq&?#N;qLY9Q6E4oAccm6>j5v~ud4Hp(xkVR z=+VLN(M2&jfEck*Fz&aK7{%|Ry`Gr{-kwO7Mv^W+~#m+5hMl1&;` zc|Uekw+XD$(KQ(_o(JGMG;(@r9ss$oRA*D-;`G2*OQm)&H<{$^HFKP2=B-P20^Yg_ z-uA%TEBct5_G|*V=botx<1)@cnA7oPzLaHmy4=8cID=do=ZbW7p5k-(g$T9A5`9i5 znFmDYA79uW?+5i!Nt(2w>eDya*Am8@eUb0!F)4D}rs#UOZ|_S*oP(<+ZFCpxZ}ofP znB4=8i=DAQe;eU#3OHW=4Pr=l!!OijMNQP6b*MXEFE{TwL!mxY28M3W0JmNG!{)9J z(wwv`oKJ0X{C0j}j?mx|^@-Eu7+ibou5%d{na9-JVbLvu9pjp%etPcEe7zctOz}PS zi}2k|^99LRJEi#pu8TU(Nq1v&(udcxV=94#%P?O~+@Help06zd`#2BUP3BM4=)B?! z`|EaZqLy%uaxlKp5U-c~+|G0k#zZUy8j*^wH4)I-_>P`3? zUCK$e$GQU79MZ?Wu(C_eX?Sb(^VYoMI-vb&ooX|~-zDcMYNOb9|GeH(X(pC>b2D)l+(C@mw#7wO#N&*^k)8i2YH!m>$|y@7)z9+b(wBd55I|`F^Oc zd4XL}$?s%m>V@4s&gzo4&OE_K6YDLief0fld|)hRb953pU(JXGDRaCx?l&~1lOE<# zO{g_=zs66SM>u|KJk7?v@3NzfD_Qo@aKF8pvj&Gb1m9`Cln*NY4WcY;eGPvbHHD8u zXVBcV7kcoXL^dCJ?`pYxh(D7L|C_i>&?n90Bln#%myh<(zBz#|HGl3 zx6VB}$M_O_aGuXf@wxtY2;q1;+vf(~9?SH(RAi3gXT%QwTWZ038bDw8e6zjZ;vP7g z_JDW$DrxU<)^gr~Z3ORdhSq~r2cdnfV|Chh*0ZFn8|E9*WT}j3-CLL4XBzYR%~J4V zON#7fI0q#fx-rw3$-LNF;C@51NmKFO>Ft%nvOLHA#Ox*cT}{UNz$H z+ojFT-ms9bZczV&KEq3s)>dffuOaU+z1$_3&tB)1>{GC0#=h%WGViLV?`h@YdK$+F z)=9;bK3VpK}YJwM&e7oi*QiSq6iN4P)2 zCJWEpD0{!xe4H%fIB9w~!To)Hz!rNPFN}HcGmvky)T0Qv0h-FsWRG8yvW~vr`4m}K zN%K*tmy}T*`<$h$cr)x8_?{Bh6TFG*sN6VX@Ma_@Pttss@FBI+-fur(llOg~2jo?U zntwPi^6CtHcUGPe-wQ4u8{1@@>3`Tghnz!uUS-ib%Gs|Zl;0iev*Z31?!2yIX1`DS zq}W2Wg>g@X8q%AiS!L!UPp4an(=F1G8vcp+=t3=5d~lkEhe_KP!B` zT5H<8|BXD5>EN`+wYRKSY_3Q1KeP86%b*wZnu_aVob zK}>D;dy&0oKn66_JH_Cew{U(X8a;XdVmf{!c;daMRI9Mfioz|Kp}LUjTaZi7-RB17 zH>~f}DLrb=>D?0WzH3GIXoEO2GiMWtolSbGFy8K3b!k6q%Yz`zG#SZLOaMgd#E44864~0tUzDQ=h9b~-kF|UICIAJ9(%Y3 ze9|k|XbI%UJe&Li`msiiVejkn@xnPG@zbWXHeXTJ^6|-COQmoepuBwLA|Nmecer)WT!msjuvIecID1q6Qc%4!#7Z#W zuW#Q*O?KQLR0eFF<=I|k(CHNjP0Bj+DCirwtyC_TxGKYWgyavvj#z#$s@yeel|i$^ z2TxBN_8)z{Wu;1`HMg%jf3c16*i>3GYjwY-_A0&W*8eKM|N3uj-nl6iOFg-2Fa~29 zH@j(3noK8O1)lqNr{5~hwsLJYx>l_Be~rFc_Mmf5eyFr|QMvtrKc5|8uFHY&M0w@N zR)Mgcy$-Z&HOWyw67S2Tx%)&If~9Dyx6a!9NKhY4=Mym(#HC`bH!JC_xYpO!N5Zj$ zm2Cy z6tG|b#j30+s`HU3BOiHBKK{6%@+1=#1x1qoO5=O0YL0vq(R}SVYXh#PO{d_C;mG9o znFa=fVFS{O@8C%x%8^4~XDU@7ZtvFf`ChXf;(jyNHIeAcf+eb-uN^6NleON$fU7SB zI<959{I3Eb=$Zm2EgUs2sn43B5Vj?LDKrg1k?retrSAxi+*ogHxo-%%ezhw*a9b>N@+)-)6vM{vFr+Ccdgj1PAcD&Ag*F-*~Agj^tXS8* zvDe^%3Ci3Nwx&PI){(f6#0?50PJEoso-D$XY{Im(wM95!Q}?2uq=C>=BhA2Qz)x-S*`jsNRPA;4ZVN0Z?YJ_}^0S%54J4uh?r z#R#GbrsBX;>+(vr1f94OwlLCP8_klDf<16!Iq-jL{iq>V1PIBJ9YI^+A17LlW7@Ly zsEKg%5h^l>b+y`nHSaw_Ji60(bGC3RA-J%*3S;10;{>_JjpBSAP1hTc&)P6hKrW!S zvJQXCAOCALx6My6&<3D00PxQGBS_$XZCSi>ebAL5Ty5+G*aEv|ATZx)tEFAI^@Z^u z%Mu0wL}DJ>@DCc-S=S`Z=xH%1?n<1aEAgTe#N!JBQB-4bPmoPsXslTL3k?$`<6Vj) zjgFQT_ruYUF5K4QP(`FmXa>3+^9~`kq&+-$$m~So2(|_IS6j3+)A^`^ZLO{Sm9qZt zx77cnVT%Ecq8-}4^Kl6V^D{t}`zX?4q(2u&fhNOI#!CSQ75M52KY_npId8XqIx>fl zo|~efS!X5!`dmM$s38jyrksK5Pp%+5A`bYea7uGn^p*RhQ$kIfG6X;A^EJ|N`!SnH z&rJ|Nz{M0abtdH`bq)VP?0rwXCCGQzMbiDlr}D)$o1?3zoN)`TEWwy7-BS-Db9Hqs+V*uH<=9tc*|~y03w~vVu)KEvwq*TF!4fui2+WpSfhGW!ogN*ly?;FuonRpz5JJTHv~ikcJ3P=AHlN#?yjp2?s!cpU z32ms;bvZ#p`;~^j6H)}w-dLu(Pg0PcP(1;t&k4X{1b_q0(a%BKGYi%oHX_438h#z& z(GlS>8yku*+dkK1pIM02wE+*6Q0p&u-LI%rDq0a%q1bh- zWORH5_vj8BQRJ|Hrd|9j;gut@yI0q%pAcBwVIy*TN4TzE&|9tH!D+)mWnXikD}8HI_XqCOSGv{+WWQ zKTnMT4@@<;3qt+rP3Ym-d$+uAO4$<)8PE8Z_6&d44Qp`<3D0l3o!>zV6`bF91cqCR z^)sH$r9bInN`s3%mc0o=ulq=bx{t?+j8bxXCEw3v=@Z;cU(f|>e=lg#(aWeM z3>AhcuWZ*bUAxz4vQ6M+ui>@*>D6FrHLvGcx*WxdB|V1Oj^NaVsjml|wGd2O{uuo7 z$!*B|fxP*$s#}`mHQ*|`I@M(zv*?_TPT zi~^1g-*jZ-II$lTQw5Y1A9LO`Ea3LylN!A5bkbV#S6zMnRAJ$J| z&OWNGC~+yB>A~6&bixjtr0F_IkHPP1)Unnp<@QgC3K#*od&kb@2>s+S*NW#%XU-$$ zN3(Dx^E-vMjwxeiy;9g6#xmKCM7ZNwA~=0sWXlh1`3nqwN~lW_!%IZG)YIW;7BlZM zns*H<{9_#a?h|f%vZ$FjpMSwU1IV6<^#dV$+@F7EOiD$hw_;!=dqFt9EnZW_Yn-M! z%OU)Lk58x>P2eanY?MPlzOBLq8*o%a?!4I%{O7o^XC)Z5)p_ubAi)D!m(9=G?dNOl z7Hi=*u1yfpL#nWnR9R2vrg@CzF4YSE{7IE=u1fE)BFlZ|*@*aM06`UQIay+NzXuwy z3$wNzK>>~1s%DyS^&0~66%8D5%A8LQ8|Ul({kyBuhv!p0ee} z-nIU=Z6xu}IpF?>wF^PAau?5v2T$-yB8Obw;ryPDmYPOx-G6}d1`=>&5ZI>CgYH=7!I zN4HfOdP`=o((oTyVv1q#Ej2U&HJIi%rV6M|T~!3a#x`NJ6_E(r(~M^+5e_mH;V2>y zhN(GrQv$(ss*KHWQB4=zT}&5zL-QLOdsFvR8F~+yF7Oo51<2SesP74(B8L5Fx6;M?oi-z5xF0j?H4U}SY9VEp{ zh=k2FgFQ!)VAzy|B9TU^B;3Z#hOJXe8IiyhY@|DhHd>zM8qYA=NG)80Z|x-1>}ZbL z+~BK|i>Byei&UB5B5Tx(P$#%Z9dt6GN_^4w8=N#Uohp;2rf%!#{Fod^Q7GG#$V#49 z$z4W%ByYkr%E(DH>L7_U(49md8Dm9#WK-&bDI*ykXbT>ZPvP)d#QWqT)yQkv&ZBHA zgF_^|oz%2g4N8Qm(2UZ5KDy#5!;;c?G$;@1_?JwE`OZF(Q}Ini$xt>r!ZG}k@TVGn z+ir05sXE$#;i_(AyD4w9G_NsVLZ!SkW7vENrb@WcGBn3}mNH=?i$F_)l@JNT)6DM- zL*28=YIf<-^4FScu#(A|o~g~O`L2^CS>`@9TzG5s7GzZi9_k==pWF@O=s3v3Z?aA1 zt1ybn=0B3;$D0GFw!CZdBJ=CIrib^5d`0qpH0X%5>@Qlp@0ts}zHbThUb{^O{92Hr zw_eyQTDb;EG#bRRAu2FLbob{D#$9n+{`7T}$u6g$rHcNk9qH5(n8L#BRRTQ{DQUMC zA2@DFy~UMevmCFzgzT|3Wa}*=tGi;XBKc0Py)LC8nF>OU`%Knw&#l~k{JN8({jBV* zn2Jm`g6tlS7mqz=JuCCcWCFMC=yd%=?&daWI>HiRl3^u!(IV6(Ji~;FU9|)!k>u%?vvl*K&vOryB1>L(|i3Gl@zljNnmKC zcpcieCdJ1C@KFPJyK~W%lTPwUWW%IfyIxCV*H7$jL1MvmeQTO71tUUz#tIO^C!3)X zVsl9lOf*8g4((f3#pk0q48kGE@aKL4o)-tt(IV6v0cn~o+O&= zLwvFzx>dlk3RJ#$EK)h;+$MefYqv^TEpe*|`~G0v8vRqzMMuxiE?2g_Iu|?w59l)G zARy)bL}zk;61l}uAuaW~VKvg{ahoyAIX)l>T5cL_JS#%PvR!QLi=reLo40i@lu9g< zJ~oe{DmpDU&no77ah}x)(v}V8o4VV4kFK@sw5?M*YoHgBQrk)a&Isqq=2vsb+e5}oq84-4&U4EDic~+d*GO!x3nEW75XnV- zg{f0+m}k&Kl}8Uy_y&OUcoovl!BU5p^}u2}nq@VI6;nBb!!TfF)&4}gCU2>+-eq5q z_hAGR8t}c;C3lROIy|k18D>$9(xyOT>C9FQG$sbnn974jE(GRT9`O~MtSCe27%z3$ zS<}XNn(sGgV^l+ng#j&=@@TQpnV32}-KB{EAI4A(G&Tm%*vf;(#$;mZu(PI#u{GM5 z8{;fAv{nHw4hFb5%EJXHyy%NaJuWU`r4BD^V#U{JW8pEXV2s(tfE8DHtN?`<$tdsN zh)lgO{Ssd4@Uvz-nR=SH&R$IG7EdS#7!LzrJmmr7VVaB8VP{SI;%Zi7_66o*Of|Up z7~tY74;LR3T~;xQnaO29xqh=Y`-M&R75#fm(EGTa(n50$Fv%pjydIe zF0}xIBfpOz!B6o3W_u}q%W2YoVG8;$6h)Gss-NYTB)${3ql~-`V;%<~SC6C1(Ai$5 z-*FAiZ_w5#m*}S`GW}HjRKFwP_Uvr%1a6{FbxuGZ#pz7BMND|L(Zmqj`>}P89y%&vai@IfF9h0 z=X<`^dd11!JN7>HOwFpb_s&L5Evt{Uw%NjU@3Q$L z>7~iwaQz;J#Wi_SG5R&nOH{2``0jBvhSX$mleFb zHam}ydzoxP>=R9sR@1)Jo$(_;J_)YLJJ6V_GW_Ex9VXd)kIqkUTm8>GjEa`f>yuuy zGTi{{-&pQ>m#CsrP?>`SR+~_(5U%JPt~5Nrq9w4l1t(@Zn%!7FdY5nl7v0hs5;Sc< ztU_R7SiuB@Z{0x2&UKAt+;#~lvo)zNQFC}_0kr|K3IWQ*3Mlgl044Wab{lKdwM#@< zt!c5NngcuwsZFR=2vgP!rY=wMK+1Y5Ps%Y3&3YD=$}S|;M}n(Os8tA8cF_(m<#`g5 zw@EG{JkYXbbg+9RYxuY2PBHJ9xKmE+h@px3Z9uF-h;r~E%6S4r`OgGVZsGG=NFcQd zwF+U%#p+SHPZ6TVa1g91elUDKjT^0DZh|XEP=mG zs8tA4K2}WmPZ9Yu?FRc>>@?SFw5B_qsX4-P$=*%KRR~qozzQpBY&YL4r+$88>nL{$ zDcYLuR;%Xd&OvGua#ey9-Ugk+QkZe5z4X$s*;+HCRfOPxxTSfiihn8g@of!sm*W$0v~4GoDMm^6IyL z=G)b8TzdSW*9!vyk~sprZn*%N$({+!m|0zP9OwNcz9YxvxYHF8@AK}~b^a>(SXo8; zW^_kBhfx&r0eKT8-LQu0e{$+C!#omW%4IGf?Dfd!Bn?ECi?rOO{cdZf%P<;@TsS(N z>q(=okjSmPIjFG{9(Q@lamWV{eUZyG5f>tsf41c&G%FJG?Z4F(2?nY$z7kPFK8}XN z1pZ=>2{x9APhyyg3~tWz@Y~j8qgj2y7TWZliZ}*ceNN6{9^b!HL@-Q@y25-KUD3Ea zEKD=hln4v$6vLWMF;GZ^sk`bc2)lLkDh=Tgr(ytZNR7$b4kf|HY&cMeh0{9v11^m# zs!&LU^M9$>vQ)@jQUyHWWR#}x2ZKjX&PcNBObvMahs2;EvBsp zCloHd+#x6ZRP=snedXVH7Np@YKe!3wZgQhZ7>WP<_vNHA_j#!2K65Ud)dF{29F@9aN&SclzPv^6!slq@NEWNr(JPA}+Zp z;yHeRU`#|mN_c({i5_qxzvOA z;kZ=0tFLSwjgu6wQ@I0AM&9t~TNpp6HSfD&NyV5w1D;-maZw&+16O2ND@Z}7r*|aj zfdmSe><

d?R?2_vNv3xFHYvqfF%aa?R2l1-$Gex`K!S7!6=GACnP!Jg3qVoEB|XmE_j^Dua~LljDo(% z$@v9*|4XD@9&`91gA+hZ(y;eai&Wt3=lL)@(Dj>}8|^Adt|FlY$v_|QVWtnI_0uO@ z`8nzRTv#yuSBq(;?Hi`S_S-Ji42OD#&GxCM8Qwp7m*@PKJ##?!?990Jd||qV-!W;& z_Bxh*VBB{6fLq)afngZEjtKUwfeq(ddvtQX=L+9)I|g;(d<*}CMGd+{}-kvvL zduF$!!uBnH&+3+RFzPcOblHbkkmIc18Ku&jFUc`R|VrzH8Yl-$Mc+PYwXnzXCk8Op2B^ z;MxeRz>0&VB^cgoT`R<`G_j>xTU%wplq43xz4MNTuL6E@XL8y6Uz9MCPz)TsV7n;y~zVXS@4igx?O zT}zL4?(uKleRy^6^K}q-k!SiSyXj_a_Jz_fOg^FbzUuCWdgZ*mn7H{brl@*R{5u2! RyLYoE{|_|bo+iNw0|3CfWJv%3 literal 0 HcmV?d00001 diff --git a/lobbying-scraper/tests/fixtures/2007e_summ.html.gz b/lobbying-scraper/tests/fixtures/2007e_summ.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..58d40abbe322c67fb869aeb1610dd6a51fc2ebb3 GIT binary patch literal 14101 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

R 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%0A9V0H`EULy-k{ice^r%Yci%iY&!kn2#7ndu;|Kg zjWAf?$o8?5#*A6qyFdIG{^+(C5GZmV^GDJb(9RwJXW3Z8S7XB9 zJ?!?t!Wqn9H3J|1JLr#XTonUE8d$zzlbY_Uxh%&VT7xkb7+bN51!Q&I5s3RNm`E`1 zU2F3vSZT~4Z1rhF@b*b>RKxY1s8AfgNA>;)@2u^SA}55QCzb=hTVMXyZ4A9g5s)R* z;f?%${|ivy|089pwwP@p*J-f2ueRGA-aUrI_-Q4for_qgs3!53 znk2AYV88Ez@sBoEo4Xd(21ieMIsS5z+kVB7X-|sI(S!!WG`MbxbW)o-yv*L)*>0T{5QzC%^vplbcH zXJWhiNMaucJUmk8ZJ*!ZG#n{ZsIHH7idM^5sRhtJo@30u*u+Zx*>L*J?q9*^DKP4H zkEK^eMwJXbp$2_@eGY5Kolo~uDZg*4_Y(ByGcWCR**{3>; z@}}M4S&lnAyFHZOqI3B3F5l;3gpRM%`RFxn><31BU*5uj7uY43NEL>E(?%`5wJbU# zvkS`5FWJdWiFqWGUOARjw|y^ZGJhZo;Qs1EuQd;2^nfRg90Z&sinICfQ36Q22V5-ha=&#Y@h z+r|16M71B3qP;WQuT%Cua)>~D6v)_#=o~f7=`>4#Cheg&{cn(Bk2Trz)kv_lubnAz z%OdZH3mD$e`ob)J`qjcnV6bx-?JX>19L+y%$L&57NTaZlsDIcUk#-oZ|GdP^=S6{; zuF~K#^Q=-!O+R$~RKGT=eV(#k?;bP87$V_Uru=^}MYliL4BAg>|BBRy8@(;w>-yl` zMIHQ=K+0iz9{=F(lON+O`-x@WF7zi*?*yqQUq9*F!4t>E?fcprIf%I`y|R0H@26{a zk9WY(ak~)vON>fJK1m?fe$Vzir%&ppkxy6@met=fcxYl7<{{4___k%fNt$-9<#b)M zvzy;zGpg302O$z#mt%Hg@%09}C7qGLnoy|hG^mWfd;EifWUEF&G76G~ zPAX49vh&+TH-my?XSb^$!43Jc8{<-t>~?eo1>_a4Q0OO}msb9Jdob;qx%~jE*rs!OxAHMmwE0_ zDQ0#WW(Ml=j|c8!>>)vcvt^^e83oQlcQr~?g; z4&dzmcJQ3rp)<`c+ZyhY>j`XsZ@4Ue5xC5L$j?8p zMa!|O_il1xes~qqHCt6clx-JWmTu`rLb|&~&@Z2Q_c zMQzPGr>_hFsFV2WF~ zdDed34<$JRA(^J{!Qy8Ov&_I0vCIIy8NPQZazt{*GHVkNE{9OFAw04}U*#fU5|w3r z9uvJ;T|k59ME`W?Zmfd?4u^tj*S>0A_^f_YLHO+7UIw$e#pMgQx^_M@j{vN$Qm%FA zjXSPU-guR=JGG9cX+7p8W7-Q6ahlC9{UqY7Nvjrxe=V*&V*-wa6;7(Y%ARmWB!xWg zigZJU90?ZDB9bx|e+9wutxq{bK|h}##lvNr7h4e8UAOCWljp1dlC%gNmTI2YbA7Mp z`bPh+Z*=K|)lzz7iHa^d%iP;G+?0i*6fjmcT+P%{+9%HyoNGasYk`?;=<#&S@piPT zR(6y#A~9rX7gpxK(ux0-o`9AfeJRcIUumhA(upFfHPF)5&!w%OOY8l=v|eHhk{&NH zU3^5g&zI3NO#NNWjP&DgjDD>?-~AwV`mAl=Y=k~Q$O&;dh0r+_sLXrLxI66g)cT?% zEps5L`LVM%2f1kyl-1SqTCppk!t-C98O%L|nt~)P@4|fKGJHQ)VC-!2QdYkSfr<|$ z1u8z26sY(l|1Ul%%byT&%enivPq7pFS!Lb>to5J0mD9ARD5@P5YnScEPzRK|AnMSu zcVVB03=IA^cH4+mi>>-d2L$N!9o|a>k+|6L_5ARf4VV?0-mGyii)r^C8aN~4P8sme zbp7qrp6QoLBx%ix{x(%%{5+!~#M#XLeHG=tO2C^mICZ_c4l_r|N4o+gppk+sWK2@b zaB6;s2@LT!Tp+$%SNb3rmr~ykAzrbFgln=i5TT~C|6}qG zoA?v$Z&&+x+^wHn5k7LcH{YM4tJ@~X!C zJ>OeAjnyf!rzhhRn8|6(i@-S*QP(_C@7uSYs3|VB=|3kJOU77e9LtrSZX>#zP=i)M zE|A3Ghmy;CD|S@#NU=y%d}50UgS`PT4s!{u&MfKO)j~)-B^1-1wp;|7#{EZ zwXRURTjP=7xGuWIMgabulmh?59c@aYou$TEq>i?Q>A}<3W>!ex42$z;J7zP`Nk~_w zvO<~Ycuu8Jgjn>^+7a&FuVP74E3wSS8z$SoN<$})e#@48j?tc{+xNa6eC1XiS5+SG z>oHwxA^JzCAN~ILwLjz?_{GN9G`o*d@(pY^7k}&HS(BPKp^{QIpl-f3Kk^v9P!{IR z*cg3ONjGF}uh^2ZYysoJ{wOA)qvG$3)J5C4k6yX@e!~pg`|PeJOHp*1%IE$iBJIu2n1PqaSl^s5z%J7(9J9}$J*G{!{B=Ywz2`;tYYnDD5; zI3+&0lj7VZuXS+C)w=?YqgI^`nlmQHiEPD3<^@dVG zc}jWHZ{VPhjMd#`l$IdeT}+r_<+X&kUoz&diHid6==f@4JV3~+zg(S%KYNb4*ei&> zDcHJp-=xC!YzXO|wlt`Zigh9@-LzhZe_AlhXE;#%i_W#Kxpb4j%=bHdtLi&PBL<ktI!mp+H-P^NrdA?I|;I6{j{+Z`Qwe@MLSu1?R zxsO1paTtJ3a9X&-3?s(-F|0TE2vAKHo5S2`z)k15-N+#KpE8lJ z0egvBh^4Tl3fMY;J3W7^C?>p>Fv9rg zqoZpoq%NqV3ZLEDZ&7I7D(|xJaB)$h`O5A3q3v`;(?qq0FfC<$E*737%D`p#`w1K( zJS!p!L}%pc9T;g@gqk@R>jiV_7tCd$m=jVGLNN!*^6M`Uo5^Z+)YfZsI5H6`UI8HF ze&#|LJ6yeVBD16X9@!Xo)mpZ%Y^6!#+;rK^252O*;u85{`M9F%&WqcHl*U|=TUwF* z>^XjXIcU{@A%()bvSQ{A?)VR04DA}QL_0S5Bl(wVp48!LdL_Q8IJ}esrswnD^q6i&qw4~+90GCys=*Jrh?)f z{wn+#Z>oQILy#`Q09u7guG>Ttgg8T_y0cOR>Y?-#S0|Z$a*gsBlH+C)p{f$HRn=-@ z8^`P>23?E;n#7uy_Z&3e1ApVgQgr+TApbHWmUpTYj9vLhlL)vHZ(eM%MZ(0JUWpw4 zhO3zqoyfcUwSHXWrKY-fnl!z5AP_R8iMv%mJydCoA?kGo2=GzxxW|hcU`BeA=Y^vR zp2+RS%mzVyE*pQ8u1dfAH=BWHJO z5*ITkUyqAgJx#vHh%E=q%sP%rB+VE6NQ@N1s3y!dl(c@YG4d<9f2*?5lhMTED06la zygvq22Wa-kvpTBvwaErB23X&ks9op94&&{bcT0u_(%S?~SY=$Ab#WoS;1DI^Xsr497r=MS)Vh+>z5z!IS4WNDP6YIjKG6$Ai6abIwBWytJ-MfH*vrFE> zMti2~5<8dx%xO!{>l=k%&UdPon=(hGbLICrWB#P-3W5~}?9{3@t7KG19HQ*nA&H5- ze)g+M5x_kZ_{~6|F|z7Pvs|EiHuzau;(^c70s)h>5K-Vz6tQ)3H+&`yI52S=GLvC& zi^Slh)4W2$hc8iYu^xPgf@OqU1l&W|d@giX<;?kBe>}4oA5EJ;n_vK)lFjo-*;t^l z^W%X!v=pK5A!s8Krq|aC3|i=Sm`I@#;%-r&oeK%A=U)E2y>{|-V=pGDM?tHibQKm$ zP54&X&t*?^FB6)cQ44^>0d|B6PL>-DP3&FR*TPW|qK2cw>UeIMBIvngaX4tp zSa4X-mNWUzwI$%Dak#|cUPxKCLo7bT{6|Yg(pM#=9&b1Aw}*(?XrT9^X(CHHGP&DZ zIS-hL?XVztm_&pjB%~F_0to;?Nibk&em;G(stm3ob~*^q8J}08c5&a&eq}q@dNLh+ zr<`-IODR5I1xsWzBuP{hd*mr_NtaRGuK&yYU9ESW4$NI7{Ql8mb-N__Fq0AFZtc*C<8ur;uKG9KW(_zEkhQzP)p&r?}2VlU(uW!_}%)@QXQ zuh`a&J|k18EWY(8+0DMgGGW69iGW&xU?cC*HF!|#L~bt@zz0$cyAQKZ6Tt%Tfog)< zUYf_iTZrerXSJi8;70AS$p4S%N*uhoxS?&OHC>{KuQiDx=)M)HZy7qENBolD`@xq1 z)vlKph1{PkT&#__`Yh`>B|75D!Mtwvw3W$9l^1NchPh@zypIsNYo7EiP-n3`Lx0t- zBYrP_A3@>2BNIa)ey<)$E(f}1DZyApTuq{{Q}oizIhFOAASp6o-biCo_Q6$6@V;yj z^X$vTd6-ifKw4`b$gxuWAl&(+$g4W-MC&_@$1J7O0*F;202E)*mESIKNs^yP?4M_# zG(f@ZfQN#K`VY)ngNpnR)Z6!0Kblo!rs@!UvY000#hQLUYDPDhP%wqhIb#>wl@mnK zTd{3D_Ui+f6;V(Xobh8Na}qzAEFVb&P+?FNKq1e415h=feFN}WOE7ehbdu@oq~8Xj zfa&10pILKuj`zDrd+xEqw-d(`w^aRQr%b!ZkC$5GbV@tfAT zjU4+DF9LGZBu#gTncv}(lBf?oJcLOiSxNL6o(OT=+P`X*B)nUaEIKYKBl>#Fr}X7x`K&k7GQ zr6a}C#9F}jaXzao*5X-Z6cDJ&m;;${x)YIfrwIR`F|X5z)8YC zuChK(&W1#}SSik&=_q-K**X%PtbL~85KM

IHLrC*)Rp>ny0$fNA01Z=Eu+skh>&utO2=;s~m~hl? zJHvrjt9AjAfXFXqsNmI?uLFpD`8sKb{BmTqJhOk45;6V+nt&T99zT;NZuC~T?0+H3 zhZyF)E@R+kS@ni-4%(jR>V&K<{Ox{wxUyQD_b4<>Rxetw*Vwr>)eBk35qU!vJZBl| z^yAn7q}gIkzVQpzrIQG6H>rT3IaA4n8%zbJg3UlSjzSf^n@5MJ#j%I0z zh-T5W=1`t4jaIO4%D0h@e1%L?IhNc)KK<~-bMW)ykP@AjfWS#sdpu+sk)@brTr3J8 zG=c6jfh5UVyAj4EMco&xopt-iAV-xyCPpX!(Cf0mQaMJ`j>4( zFuXCsMtq=q7nJ-j1h913TL&f|j#rGOtzX5iId;9Non98>zn$=r@RS z#cOcKNWBqwdYo_ZeEKraBs^2sLp9#(4DjFy*M!wc{nCtmwGQ?mt!N{F+0*al)>$@l zuW0+1K@%?U&b)P&r%5a^4!!9nn};&@>JD94nvLF<#yC4*7M<`bHAw0ylC4I<;wPB} zjH#Ifh@s3ZH(LdC3(puoH&@aA+?+`Ow7G}C2dH78?pU2Kn$_wP`5^AG2s9{!M#H9d^jZ5~zQxj~oz<+p~OP@>CEDFC=QE zVu+VDr@p(=0(X835tDg3vxXt~jghjmmY{_@3Us}nnO*)k!I|L8q0bzA(hclE?0FAZ zQ(*IM0rVv4o$~{u9Ch8IykW@Ra`jAF%5Ic+FPdv1aG$ z(@xg3Pwfq#9&+EnRtU(DEp|;HK57DVXmQ52y`m|O2vw*cRH1H8{}lQoScb)o-RbE5 zW6WkeAatjNw|xao45&(@lyJM_HH&S&z}~6L9}= z$rEpM(V#l*%2?k1t$xkJ;w_QO_E&@16pKC@p4)*~Sx5SwL(a0Zu^=SkQ!} zv-Xm(`02j9`)ofKq?Y4NuDeWy6DJ~2<6|!I`Dq*M%CZXYC!p%1oFrULSoxYxlS5My zk8vIE!%ihiy{r~|=xPbCSkbM~V>d~hgPkf2awF1U!mkEl1!N|Fr_Q&4;_zi`3H@3Tq9qP1M zh`VVhs;^az(TiS=2&ykfgzPqGc)11LLXQa9ZL_OWSiHH!tQ#m7eCnN!%;ZJ84+H|I z=60Tw7V*NgnM~o&!JHZBM|HkI!J=ySpedj1c57Q+F|F17_3g0s9q8k(VjZbmT9D5I zNtYZRwk^j^9XI~D&8u=2bBBp#CAlC**FW*)i2*Z#f*B*vPV}38u}VVgiXe)5mp(FE zR?M6VNjmX<&3HXopsi^)fn}cT50gC;qkmJcZtEoKTI-O}`A4()xJ~)a?BsKzyw{}(KWi%y8CyH*flxGZ>xhP3m zf{iN?$hwW8g3~9KLlZfY6`8Mp`qr!|O)I%$I%g7Doaau*KdK{ON}4t+wpergz@Pm7 z$C7MdBED2vYnBV+f{1{UM;Fd>ZFrZVb0URbC4-frQ*#-WOk?urQcx29j5pk#lKnFgv_q)queU0xmWWRO2@20li=$2#8;gZ9OerlGo zbw*dIv9AlM2=jG-=-UV$Efre;xNA zlwjZ1io`)nH5rNXMV)mPon2sEK+tt)f@K4Jxh={@DlPCL87Qa281D(x#j9HYM2P+- z*e1wUPs0D~J@52U-Vm6^xD%0oz2)$v+uFkX;4bY))|V);*jNEW^RDtE!5qxJP;rb& z*qU@jr+YE&jH#D%5ZTaFz1Z%lX=UfWc7;?m2J6xHZv{p*2FK}#zajbp0b8bFp_X%f zVhQ$`LZuDLm^z$V>;`V`tj*9HuAz>%@0z|-sG4cgb3{(pSgr-pzZs+Iz1Wel-b}shd~W}*g6N%VGDD7bENcIHFs@xB7sv`2qXYYxANF`M}pBx;N3R5 zF?(YNkS`i6s!}uq6CX`rIts!n3zuF7Trd`lFeFJZh$C|F>xBG*Wk~}BUTa2d>5p|_FVQDPo6cAt+lUzBSO)V>fuvu z(XMnzly3t!ZNiURWGjZT(DA5AR=+fpx<|!)HFj@e#wy!=d z0uVd>;SNb?fLCQ2Jb`z=BXJrW>)IaFk}y%PPV@us&`J^>{|MR~Q(Unnd`sm!V&Wq_ zyvs@c)~=gu;;B|CBI_9yd$Fz{Np+HGBWVFyFGK( z-VI%p0%tUb#7d96AS3&Wc zZCW%fEVTdnKJln-wfVNzo*(19`?lICPI>c2DX&cA(oLgFp6;pI zlA?}V9a%AJ3*o^SBHlT(mG7plXnjMtIHKMb2|Ub%>tGDq84bV^;K)?|C^>#K2Qop?-Qc?G09SsfhtpYkEAaU;kJUDxe`y>4DZG$O16|?%?MFj^~jpA zYg1z-gy%?8X-QASD9?*&uj6#2FDaq+LmpC9r8Mnt3)2iI%iTlXpcJjVq>9lt8LyK~ zZQsBPBbwxJ_PkAM1ng^ht|txZQ04x|s`x%x$7$ffhP4iEwFf_{TTw3MRE|X+%uw(p z$6^dGLXm8%^Q2aKNO38OE1QwHeaetm{t}g$8xixH)a0%~W^!_Z)0`~&lJKnRf$7}? z%ITZO+V?h9S8RI5wjTbqkoUg$;o?4rmVzQ1hnvd2Hs(kElOIbizm?V56R_WJ)HiGH zt*<9+=X|PT^(n8{jkc`j9(K96MU9I|>6;L?q^jJxvi#JyMd>l%QgZBN-0HY6bx8c6 z9Qfehxp}mxnpkMP^g%#Q=P*GheuGjU&ST)TSFe5BjU+5)tUE751wR8L&u}rMnv_A% z^romQm6>4a>xLmsO6etL)=(C4NHGOuIrYK3nc%lebm1(TrNG@1Gp2$<0ax5^%+TYj zI>ab)CBvmJgT`!sd>JlG2oNy$uM+j!2QD45ga=u7BJ#2;mQ&67EII3e@zm^|$QntV zl@g>J4~Z66rZQbRCPo9qpVkJ)lHJW2r+m`tTZtZl-FhOHhGQSF$NVQ^6C(-7@bUE3 z(dTO^v_c9@1==kPn%T=j&RNjEMJ|X@%Kl0nvd+#7?ykb7lojRT)(F8Mjhtd)^P%Z* z`@3eeVZA#X%@9K*u0%F-jAatnO{=w zBP;muAZAu5I(GEkA81vLYM5>oOfyENXF4niw|6P;T`LTo&Q$A=e>l-!0XM2D$%IsS z=94_KRwJ`?$5vwdEEH%&fq#Lo_)_p4qe}5__}q!rPRq1Q-k&9YxWH2LI5KflwK4K7 zF|;+-=#KD7X!G`$?=KT|REV((i)JFe3N92jHbaHJ31On%o)Y}!n`5nBYwEHhu%9G&6+kvvNmyugO{A{YU7$D;q(jpArSf}7&T*dcGF>`;DU8`MUGKecFJkwf z*@y81fk_YU()r$Nx*tGAr6QG4*j^MVd1W5Zn#$8Ch_b>3z0>CP&E9~^qxvRj3hW+5 zT?~`24B<2|?C~i@XS!;fwI?6ceEq82hA^C{23^Snrq_!FS>qy-IaGvTQf!z)sm@&L z%cXuAD$MZU+r7TVvFOzzr?}m_V$pW<{c|-nEqLpR5O_D_x7{?V%@O3qn~{v?J{)gi z+F7PyKgEYLR*n;YXEs+Yg%K4(5<*_D=axE>$QJzQdxW5clwW5jWz}D35giw;dyShr zt86`EmO*uZ)Re<0m=AmDgbZUVNB-H4iH*JY6Me1cB3x=Nh6~ROr%C#4=iOV8?~mf` z1b?}Tnl;h4dD~j*O*#|iMsgf%w~0Sl5p4rRD%23(&?b)EQ_I5tne`wYAL39fpH`?r z%lq;Bv$=H?f?^s+Ww(W_rT}+p7|;Cy{H9}^3Q|8aQ-yE3vMVxgx5dnx*IS&(yl21r zz%X=(K|N$|KGtRROHvM6k^raWEs4!IdoM|Jk;5sFX%a2c(CNv-^{_VxFdNh!6^Sjo z-GVp9=-d0MY!dM_3^e+Xe0g1CN3UTeE`8z#B4%OLF7G;AbwwlXb!EadmmK&EP_w%J zHe%o|vY{M?S$$eq50LJLrjqp7LRM>YCP8*2~L5`n=zhzwNAc9`I?H^pE7<0I#b}t9*%~ zI<9t`b+y$qjYuI!9qM+G4__FJ!1DzT?{-|Qm` zvVi3bLzV07PHh!&AwuY0!&joGX2GnW2wyo1_sV0v3Z5h}l~A9Us>4(vQJw;MsX;V0 z-tn}GuMMb~GSwF0RH;g^ZzU1bZ0L(u(In}NkJkEVKi&SBSyN*q)T|(KWJcy5y=?Ec z_E`o=BB*E|-=>0Dyn+b@k5+me1(z6pjT(_hDc<1rsfgy=c81|d>+C=NJ{&Z7J;JRe zD#oTxkih6}C;&0m*znEBwIRK@9woKVAM;Wz%2V%!Qx+N(KZIio;H1&La&vPF#=?;d z`jB~WQueuE`-|0lJ16mRC@D%O< literal 0 HcmV?d00001 diff --git a/lobbying-scraper/tests/fixtures/2011i_disc.html.gz b/lobbying-scraper/tests/fixtures/2011i_disc.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..84736b4a7b72bb266e2590afe2be52093f53b1c3 GIT binary patch literal 14723 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 literal 0 HcmV?d00001 diff --git a/lobbying-scraper/tests/fixtures/2011i_summ.html.gz b/lobbying-scraper/tests/fixtures/2011i_summ.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..430f6859164b38d83fdcb3f26c29948e32e53d3f GIT binary patch literal 13673 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 H#!vwOkh!!v literal 0 HcmV?d00001 diff --git a/lobbying-scraper/tests/fixtures/2016e_disc.html.gz b/lobbying-scraper/tests/fixtures/2016e_disc.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..279fd59c08a9da29d997a8b8f27f7b27a16ea575 GIT binary patch literal 259811 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

JgffuXq9foVW7fRQ> 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

2e-loi<-iGl=fSA2$f)CyT~Qj$ycm&81sM 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 E0NB*32LJ#7 literal 0 HcmV?d00001 diff --git a/lobbying-scraper/tests/fixtures/2016e_summ.html.gz b/lobbying-scraper/tests/fixtures/2016e_summ.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..a0bc28b85feaced4b245234f19e9e67fe3481aba GIT binary patch literal 24496 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 F{0}B`6uSTb literal 0 HcmV?d00001 diff --git a/lobbying-scraper/tests/fixtures/2024e_disc.html.gz b/lobbying-scraper/tests/fixtures/2024e_disc.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..9074511f56fb9302124d71e06be0f148e40ed7fe GIT binary patch literal 23241 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 literal 0 HcmV?d00001 diff --git a/lobbying-scraper/tests/fixtures/2024i_disc.html.gz b/lobbying-scraper/tests/fixtures/2024i_disc.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..44dddc2ef9027bca17ae6e5ce8fce96ded87f38d GIT binary patch literal 62766 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 literal 0 HcmV?d00001 diff --git a/lobbying-scraper/tests/fixtures/2024i_summ.html.gz b/lobbying-scraper/tests/fixtures/2024i_summ.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..e6dc3b1b651d34dc806be85d3d6349eb4602ea96 GIT binary patch literal 11739 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 literal 0 HcmV?d00001 diff --git a/lobbying-scraper/tests/test_portal_parser.py b/lobbying-scraper/tests/test_portal_parser.py new file mode 100644 index 000000000..03f73aaee --- /dev/null +++ b/lobbying-scraper/tests/test_portal_parser.py @@ -0,0 +1,172 @@ +"""Regression tests for the MA SoS lobbying disclosure parser. + +The portal HTML has four distinct format eras; the parser is the most likely +thing to silently break when the portal changes its markup. These tests parse +committed fixture pages (one per era, employer + individual) and assert known- +correct compensation totals, client/bill counts, era detection, and specific +bug fixes (the "Total amount" summary-row artifact; the "H73;" semicolon bill +separator; hybrid-era Panel1 compensation). + +Fixtures: tests/fixtures/*.html.gz (gzipped real disclosure + summary pages). +""" + +import gzip +import sys +from pathlib import Path + +import pytest +from bs4 import BeautifulSoup + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from portal import ( + _parse_amount, + parse_disclosure_detail, + parse_summary, + year_to_general_court, +) + +FIXTURES = Path(__file__).parent / "fixtures" + + +def _soup(name: str) -> BeautifulSoup: + with gzip.open(FIXTURES / f"{name}.html.gz", "rt", encoding="utf-8") as fh: + return BeautifulSoup(fh.read(), "html.parser") + + +def _comp_total(detail) -> float: + return sum(c.amount for c in detail.compensation if c.amount) + + +# ── Disclosure parsing ──────────────────────────────────────────────────────── + +# (fixture, year, expected_comp, n_clients, n_bills, era_label) +DISCLOSURE_CASES = [ + ("2007e", 2007, 112_500.00, 1, 2, "legacy 2005-2008: entity total under _total_salary_"), + ("2011e", 2011, 641_243.00, 23, 4, "legacy 2009-2013: per-client Compensation received column"), + ("2016e", 2016, 990_474.00, 30, 1357, "hybrid 2014-2018: Panel1 div totals"), + ("2024e", 2024, 115_000.00, 5, 22, "modern 2019+: grdvClientPaidToEntity"), + ("2024i", 2024, 1_095_200.0, 17, 135, "modern 2019+ individual"), + ("2011i", 2011, 18_518.00, 1, 0, "legacy 2009-2013 individual"), +] + + +@pytest.mark.parametrize("fix,year,exp_comp,n_clients,n_bills,era", DISCLOSURE_CASES) +def test_compensation_total_and_counts(fix, year, exp_comp, n_clients, n_bills, era): + detail = parse_disclosure_detail(_soup(f"{fix}_disc"), year) + assert _comp_total(detail) == pytest.approx(exp_comp, abs=1.0), f"{fix} ({era}) comp total" + assert len(detail.compensation) == n_clients, f"{fix} ({era}) client count" + assert len(detail.bills) == n_bills, f"{fix} ({era}) bill count" + + +@pytest.mark.parametrize("fix,year,_c,_n,_b,_e", DISCLOSURE_CASES) +def test_no_total_amount_artifact(fix, year, _c, _n, _b, _e): + """The legacy individual summary row (client_name == 'Total amount') must + never be captured as a real client — that bug inflated 2010-2013 comp rows.""" + detail = parse_disclosure_detail(_soup(f"{fix}_disc"), year) + bad = [ + c for c in detail.compensation + if c.client_name in ("Total amount", "Total", "") + ] + assert not bad, f"{fix} produced summary-row artifacts: {bad}" + + +def test_legacy_2007_uses_total_salary_placeholder(): + """2005-2008 has no per-client comp column; comp falls back to the entity + salary total stored under the _total_salary_ placeholder client.""" + detail = parse_disclosure_detail(_soup("2007e_disc"), 2007) + assert [c.client_name for c in detail.compensation] == ["_total_salary_"] + + +def test_legacy_2011_is_per_client_not_placeholder(): + """2009-2013 has a per-client 'Compensation received' column, so comp is + stored under real client names — never the _total_salary_ placeholder.""" + detail = parse_disclosure_detail(_soup("2011e_disc"), 2011) + names = [c.client_name for c in detail.compensation] + assert "_total_salary_" not in names + assert len(names) == len(set(names)), "per-client compensation should be deduplicated" + + +def test_hybrid_2016_has_nonzero_compensation(): + """2014-2018 compensation comes from Panel1 divs; was silently $0 before fix.""" + detail = parse_disclosure_detail(_soup("2016e_disc"), 2016) + assert _comp_total(detail) == pytest.approx(990_474.00, abs=1.0) + assert len(detail.compensation) == 30 + + +def test_semicolon_bill_separator_parsed(): + """Legacy bill tokens may use 'H73; Title' (semicolon separator) instead of + a space; the bill number must still be parsed correctly.""" + detail = parse_disclosure_detail(_soup("2011e_disc"), 2011) + house_numbers = {b.raw_bill_number for b in detail.bills if b.chamber == "House Bill"} + assert "73" in house_numbers, "H73 (semicolon-separated) should be parsed" + + +def test_modern_individual_per_client_comp(): + """Modern individual registrants report per-client compensation in + grdvClientPaidToEntity — verify it is captured.""" + detail = parse_disclosure_detail(_soup("2024i_disc"), 2024) + assert _comp_total(detail) > 0 + assert all( + c.client_name not in ("Total amount", "") for c in detail.compensation + ) + + +# ── Summary page parsing ────────────────────────────────────────────────────── + +# (fixture, entity_name, year, reg_type, n_disc_urls) +SUMMARY_CASES = [ + ("2007e_summ", "Ventry Associates, LLP", 2007, "Employer", 2), + ("2011e_summ", "ML Strategies, LLC", 2011, "Employer", 7), + ("2024e_summ", "21c, LLC", 2024, "Employer", 2), + ("2024i_summ", "Anthony Arthur Abdelahad", 2024, "Lobbyist", 2), + ("2011i_summ", "Aaron Judd Agulnek", 2011, "Lobbyist", 4), +] + + +@pytest.mark.parametrize("fix,name,year,reg_type,n_urls", SUMMARY_CASES) +def test_summary_metadata(fix, name, year, reg_type, n_urls): + meta = parse_summary(_soup(fix)) + assert meta.entity_name == name + assert meta.year == year + assert meta.reg_type == reg_type + assert len(meta.disclosure_urls) == n_urls + assert all("CompleteDisclosure" in u for u in meta.disclosure_urls) + + +# ── Helper functions ────────────────────────────────────────────────────────── + +def test_parse_amount(): + assert _parse_amount("$1,234.56") == 1234.56 + assert _parse_amount("$0.00") == 0.0 + assert _parse_amount("") is None + assert _parse_amount("N/A") is None + + +def test_year_to_general_court(): + assert year_to_general_court(2003) == 183 + assert year_to_general_court(2004) == 183 + assert year_to_general_court(2005) == 184 + assert year_to_general_court(2023) == 193 + assert year_to_general_court(2025) == 194 + + +# ── Bill ID construction ────────────────────────────────────────────────────── + +def test_bill_ids_in_modern_disclosure(): + """Verify bill_id is correctly constructed for each chamber prefix.""" + detail = parse_disclosure_detail(_soup("2024e_disc"), 2024) + house = [b for b in detail.bills if b.chamber == "House Bill"] + senate = [b for b in detail.bills if b.chamber == "Senate Bill"] + if house: + assert all(b.bill_id and b.bill_id.startswith("H") for b in house) + if senate: + assert all(b.bill_id and b.bill_id.startswith("S") for b in senate) + + +def test_executive_rows_have_null_bill_id(): + """Executive chamber rows must produce null billId so no accidental bill join occurs.""" + detail = parse_disclosure_detail(_soup("2024i_disc"), 2024) + executive = [b for b in detail.bills if b.chamber == "Executive"] + if executive: + assert all(b.bill_id is None for b in executive) From b66576aa506faeae9aa6272572bab481077b7c95 Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 28 Jun 2026 20:56:45 -0400 Subject: [PATCH 012/106] fix: Firestore write path bugs found during dev write test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - writer.py: move firestore import out of TYPE_CHECKING block so firestore.ArrayUnion() is available at runtime (was NameError) - writer.py/scrape.py/reparse_archive.py: strip leading slash from Firestore path constants (SCRAPER_DOC, BACKFILL_DOC, REPARSE_DOC) — db.document('/scrapers/x') raises ValueError: odd path element count - scrape.py: add os import; pass GOOGLE_CLOUD_PROJECT to firestore.Client() so local runs target the correct project rather than the ADC default Verified: 3 registrants / 6 disclosures written to digital-testimony-dev; re-run writes 0 (cursor working). Co-Authored-By: Claude Sonnet 4.6 --- lobbying-scraper/reparse_archive.py | 2 +- lobbying-scraper/scrape.py | 4 +++- lobbying-scraper/writer.py | 9 +++------ 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/lobbying-scraper/reparse_archive.py b/lobbying-scraper/reparse_archive.py index 32f4b1563..266ff1ad6 100644 --- a/lobbying-scraper/reparse_archive.py +++ b/lobbying-scraper/reparse_archive.py @@ -29,7 +29,7 @@ from portal import DisclosureMeta, parse_disclosure_detail, year_from_disc_url from writer import REGISTRANTS_COLLECTION, write_filings -REPARSE_DOC = "/scrapers/lobbyingReparse" +REPARSE_DOC = "scrapers/lobbyingReparse" def _meta_for_disc_url(db: firestore.Client, disc_url: str) -> DisclosureMeta | None: diff --git a/lobbying-scraper/scrape.py b/lobbying-scraper/scrape.py index fb985e05f..657c59603 100644 --- a/lobbying-scraper/scrape.py +++ b/lobbying-scraper/scrape.py @@ -22,6 +22,7 @@ import argparse import hashlib import json +import os import sys from datetime import datetime, timezone @@ -249,7 +250,8 @@ def main() -> None: else: years = list(range(FIRST_YEAR, current_year + 1)) - db = firestore.Client() if not args.dry_run else None + project = os.environ.get("GOOGLE_CLOUD_PROJECT") + db = firestore.Client(project=project) if not args.dry_run else None if args.mode == "weekly": n = run_weekly(db, years, limit=args.limit, dry_run=args.dry_run) diff --git a/lobbying-scraper/writer.py b/lobbying-scraper/writer.py index a6804f401..d49d12424 100644 --- a/lobbying-scraper/writer.py +++ b/lobbying-scraper/writer.py @@ -7,8 +7,8 @@ from __future__ import annotations from datetime import datetime, timezone -from typing import TYPE_CHECKING +from google.cloud import firestore from normalize import normalize_entity_name from portal import ( BillActivity, @@ -20,13 +20,10 @@ year_to_general_court, ) -if TYPE_CHECKING: - from google.cloud import firestore - REGISTRANTS_COLLECTION = "lobbyingRegistrants" FILINGS_COLLECTION = "lobbyingFilings" -SCRAPER_DOC = "/scrapers/lobbying" -BACKFILL_DOC = "/scrapers/lobbyingBackfill" +SCRAPER_DOC = "scrapers/lobbying" +BACKFILL_DOC = "scrapers/lobbyingBackfill" BACKFILL_URLS_COLLECTION = "processedUrls" From 6129130329e78e2a82d10486355f63b04e46afbf Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 29 Jun 2026 17:04:51 -0400 Subject: [PATCH 013/106] docs: update lobbying ingestion doc to reflect Python scraper and all 4 HTML eras MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace TypeScript/ts-node test plan steps with Python equivalents - Document all 4 CompleteDisclosure HTML format eras (2005-2008, 2009-2013, 2014-2018, 2019+) including parser behavior and known quirks - Update Historical Backfill section with GOOGLE_CLOUD_PROJECT and ARCHIVE_RAW=1; remove local environment references - Add partial backfill pattern (--limit 50 across all years) as large-scale dev validation step - Remove stale Function Export section referencing deleted scrapeLobbying function - Update Step 8 (deploy) from Firebase Function commands to Cloud Run job commands - Fix cursor path prose to remove leading slashes; fix jsdom → beautifulsoup4 Co-Authored-By: Claude Sonnet 4.6 --- docs/lobbying-disclosure-ingestion.md | 261 +++++++++++++------------- 1 file changed, 129 insertions(+), 132 deletions(-) diff --git a/docs/lobbying-disclosure-ingestion.md b/docs/lobbying-disclosure-ingestion.md index 862947cd0..2a2e61bf0 100644 --- a/docs/lobbying-disclosure-ingestion.md +++ b/docs/lobbying-disclosure-ingestion.md @@ -257,7 +257,7 @@ in `functions/src/lobbying/types.ts`. (one POST), compares disc URLs against the Firestore cursor, and **exits immediately if nothing is new** (fast path, typically seconds) - When new or updated disclosures are found, fetches and processes them -- Persists a cursor in `/scrapers/lobbying`: +- Persists a cursor in `scrapers/lobbying`: - `processedDiscUrls: string[]` — disc URLs already written; skipped on re-runs - `summaryDiscCache: {[summaryUrl]: string[]}` — maps summary page URLs to @@ -282,15 +282,29 @@ New filings arrive twice a year (semi-annual reporting periods). Between periods, the run completes in under a minute. The backfill script (`--mode backfill`) uses a separate subcollection cursor at -`/scrapers/lobbyingBackfill/processedUrls/{urlHash}` so it does not interfere +`scrapers/lobbyingBackfill/processedUrls/{urlHash}` so it does not interfere with the live scraper state. -### Legacy Format (pre-2013) +### Portal HTML format eras -The portal uses a different HTML layout for filings before ~2013: total salary -is not broken down by client, and all bill activity is in a single table. These -are stored with `clientName: "_total_salary_"` so callers can detect and filter -them. No bill-level compensation amount is available for these years. +The `CompleteDisclosure.aspx` page has changed layout several times. The parser +handles all four eras: + +| Era | Years | Compensation source | Notes | +| -------- | ------------ | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Modern | 2019–present | `grdvClientPaidToEntity` grid | Per-client rows; both employer and individual registrant variants | +| Hybrid | 2014–2018 | `Panel1_N` div blocks | Compensation in collapsible panels; bill activity in a separate grid | +| Legacy B | 2009–2013 | "Compensation received" column in bill-activity table | Per-client amounts; deduplication required (same (client, amount) pair can appear in multiple rows) | +| Legacy A | 2005–2008 | `grdvSalaryPaid` entity-total row | No per-client breakdown; stored under sentinel `clientName: "_total_salary_"` | + +Pre-2009 **individual** lobbyist summary pages link to `RegVersionLobbyist.aspx` +rather than `CompleteDisclosure.aspx`. These produce no disclosure detail and are +skipped — expected portal behavior. Employer/entity registrants use +`CompleteDisclosure.aspx` correctly across all years. + +For Legacy A filings (2005–2008), `clientName: "_total_salary_"` signals to +callers that per-client compensation is unavailable. No bill-level compensation +amount is available for these years. --- @@ -363,22 +377,30 @@ gcloud scheduler jobs create http maple-lobbying-weekly \ ## Historical Backfill Runs `scrape.py --mode backfill` directly. Resumable — the subcollection -cursor at `/scrapers/lobbyingBackfill/processedUrls` tracks progress. -Requires `lobbying-scraper/` deps or the `maple-2025` conda environment. +cursor at `scrapers/lobbyingBackfill/processedUrls` tracks progress. + +Always set `ARCHIVE_RAW=1` for real runs so every fetched page is preserved +for offline reparsing. Create the GCS archive bucket first if it does not +exist (see Step 7 of the test plan). ```bash cd lobbying-scraper # Test a single year with no writes -GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ +GOOGLE_CLOUD_PROJECT=digital-testimony-dev \ + GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ python3 scrape.py --mode backfill --year 2024 --limit 3 --dry-run -# Run a single year for real -GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ +# Run a single year for real (with archiving) +GOOGLE_CLOUD_PROJECT=digital-testimony-dev \ + GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ + ARCHIVE_RAW=1 \ python3 scrape.py --mode backfill --year 2024 -# Full history (2005-present, resumable) -GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ +# Full history (2005–present, resumable, with archiving) +GOOGLE_CLOUD_PROJECT=digital-testimony-dev \ + GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ + ARCHIVE_RAW=1 \ python3 scrape.py --mode backfill ``` @@ -412,16 +434,6 @@ Note: bill-join queries should always filter on `chamber` (or check --- -## Function Export - -Add to `functions/src/index.ts`: - -```typescript -export { scrapeLobbying } from "./lobbying" -``` - ---- - ## Implementation Status | File | Status | Notes | @@ -498,111 +510,58 @@ Testing proceeds from the inside out: unit logic first, then live portal fetches against the real site, then a small Firestore write, then a full backfill year, then steady-state function operation. -### Step 1 — Unit test: normalization +### Step 1 — Unit tests: parser, normalization, bill construction -Run the normalization pipeline against known inputs and verify the outputs match -the reference implementation. +Run the pytest suite against all 4 HTML format eras using committed fixture +pages: ```bash -# In a Node REPL or ts-node session: -conda run -n maple-2025 ts-node -P tsconfig.script.json -e " -const { normalizeEntityName } = require('./functions/src/lobbying/normalize') -console.log(normalizeEntityName('Acme Corp., Inc. d/b/a Acme Consulting')) -// Expected: 'ACME' -console.log(normalizeEntityName('LAN-TEL COMMUNICATIONS, INC.')) -// Expected: 'LAN TEL COMMUNICATIONS' -console.log(normalizeEntityName('Law Office of Jane Smith, LLC')) -// Expected: 'JANE SMITH' -" -``` - -### Step 2 — Unit test: chamber normalization and billId construction - -```bash -conda run -n maple-2025 ts-node -P tsconfig.script.json -e " -const { normalizeChamber, constructBillId } = require('./functions/src/lobbying/portal') -console.log(normalizeChamber('HB')) // House Bill -console.log(normalizeChamber('SB')) // Senate Bill -console.log(normalizeChamber('Executive')) // Executive -console.log(normalizeChamber('FY2024')) // Other -console.log(constructBillId('House Bill', '1234')) // H1234 -console.log(constructBillId('Senate Bill', '567')) // S567 -console.log(constructBillId('House Docket', '89')) // HD89 -console.log(constructBillId('Executive', 'EOEEA')) // null -" -``` - -### Step 3 — Live portal fetch: summary links - -Verify the portal is reachable and returns results for the current year. Use -`--limit 1` to minimize requests. - -```bash -conda run -n maple-2025 ts-node -P tsconfig.script.json -e " -const { makePortalClient, fetchSummaryLinks } = require('./functions/src/lobbying/portal') -const client = makePortalClient() -fetchSummaryLinks(client, 2024).then(urls => { - console.log('Summary links for 2024:', urls.length) - console.log('First URL:', urls[0]) -}).catch(console.error) -" +cd lobbying-scraper +python -m pytest tests/ -v ``` -Expected: ~400–600 URLs, each containing `Summary.aspx`. +Expected: 26 tests pass. Covers compensation totals and client/bill counts for +all eras (2007, 2011, 2016, 2024 — employer and individual), normalization edge +cases, bill ID construction, and specific bug regressions (semicolon bill +separator, "Total amount" artifact row, null billId for executive rows). -### Step 4 — Live portal fetch: summary meta + one disclosure +### Step 2 — Live portal fetch: dry run -Pick the first summary URL from Step 3 and fetch its meta and first disclosure. +Verify the portal is reachable and the parser returns valid data without writing +to Firestore: ```bash -conda run -n maple-2025 ts-node -P tsconfig.script.json -e " -const { makePortalClient, fetchSummaryLinks, fetchDisclosureMeta, fetchDisclosureDetail } = require('./functions/src/lobbying/portal') -async function main() { - const client = makePortalClient() - const [summaryUrl] = await fetchSummaryLinks(client, 2024) - const meta = await fetchDisclosureMeta(client, summaryUrl) - console.log('Meta:', JSON.stringify(meta, null, 2)) - if (meta.disclosureUrls[0]) { - const detail = await fetchDisclosureDetail(client, meta.disclosureUrls[0], 2024) - console.log('Compensation rows:', detail.compensation.length) - console.log('Bill rows:', detail.bills.length) - console.log('First bill:', detail.bills[0]) - } -} -main().catch(console.error) -" +cd lobbying-scraper +GOOGLE_CLOUD_PROJECT=digital-testimony-dev \ + GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ + python3 scrape.py --mode backfill --year 2024 --limit 3 --dry-run ``` -Verify: `meta.entityName` is non-empty, `meta.regType` is `"Lobbyist"` or -`"Employer"`, bill rows have `billId` set correctly for legislative chambers. +Expected: 3 registrants fetched, compensation and bill rows printed, no +Firestore writes. -### Step 5 — Backfill: single year, small limit against dev Firestore +### Step 3 — Firestore write: single year, small limit -Write a small batch to the dev Firestore emulator or dev project. +Write a small batch to the dev project and verify results: ```bash -# Against local emulator: -conda run -n maple-2025 yarn firebase-admin run-script backfillLobbying \ - --env local -- --year 2024 --limit 3 - -# Against dev project (writes real Firestore): -GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ - conda run -n maple-2025 yarn firebase-admin run-script backfillLobbying \ - --env dev -- --year 2024 --limit 3 +cd lobbying-scraper +GOOGLE_CLOUD_PROJECT=digital-testimony-dev \ + GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ + python3 scrape.py --mode backfill --year 2024 --limit 3 ``` -Verify in Firestore console or emulator UI: +Verify in Firestore console: - `lobbyingRegistrants` has 3 documents with `entityName`, `entityNameNorm`, `regType`, `clients`, `generalCourt` - `lobbyingFilings` has documents with `billId` non-null for legislative rows - and null for Executive rows -- `/scrapers/lobbyingBackfill/processedUrls` has entries with `url` and + and `null` for Executive rows +- `scrapers/lobbyingBackfill/processedUrls` has entries with `url` and `processedAt` fields -- Re-running the same command skips already-processed URLs (output shows 0 new - disclosures) +- Re-running skips already-processed URLs (output shows 0 new disclosures) -### Step 6 — Spot-check: bill join +### Step 4 — Spot-check: bill join Pick a `lobbyingFiling` document with a non-null `billId` and a `generalCourt` ≥ 192. Verify the bill exists in MAPLE: @@ -615,53 +574,91 @@ If the bill is found, the join key is correct. If not found, check: (a) whether MAPLE has data for that court, (b) whether the bill number format matches (prefix + integer, no leading zeros). -### Step 7 — Backfill: full current year +### Step 5 — Create GCS archive bucket (once per project) -Once Step 5 passes, run without `--limit` for the current year: +The archive bucket name is derived from `GOOGLE_CLOUD_PROJECT`. Create it once +with the Archive storage class (written once, read rarely): ```bash -GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ - conda run -n maple-2025 yarn firebase-admin run-script backfillLobbying \ - --env dev -- --year 2024 +gsutil mb -p digital-testimony-dev -l us-central1 \ + -c archive gs://digital-testimony-dev-lobbying-archive ``` -Monitor progress via console output. Expected: ~500–600 registrants, ~1,000 -disclosure pages, several thousand filing documents written. +Repeat for prod when running the prod backfill: -### Step 8 — Backfill: full history (2005–present) +```bash +gsutil mb -p digital-testimony-prod -l us-central1 \ + -c archive gs://digital-testimony-prod-lobbying-archive +``` + +Each project uses its own bucket. Dev and prod data are isolated; the Cloud Run +service account for each project only needs access to its own bucket. + +### Step 6 — Backfill: full current year (or partial across all years) + +Always run with `ARCHIVE_RAW=1` so every fetched page is preserved for offline +reparsing. For a large-scale dev test before a full prod run, `--limit N` is +applied per year — e.g. `--limit 50` across all years fetches ~10% of history: + +```bash +cd lobbying-scraper + +# Full current year +GOOGLE_CLOUD_PROJECT=digital-testimony-dev \ + GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ + ARCHIVE_RAW=1 \ + python3 scrape.py --mode backfill --year 2024 + +# ~10% partial across all years (good large-scale dev validation) +GOOGLE_CLOUD_PROJECT=digital-testimony-dev \ + GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ + ARCHIVE_RAW=1 \ + python3 scrape.py --mode backfill --limit 50 +``` + +Expected for full year: ~500–600 registrants, ~1,000 disclosure pages, several +thousand filing documents written. + +### Step 7 — Backfill: full history (2005–present) Run without `--year` to process all years. Can be interrupted and resumed: ```bash -GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ - conda run -n maple-2025 yarn firebase-admin run-script backfillLobbying \ - --env dev +GOOGLE_CLOUD_PROJECT=digital-testimony-dev \ + GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \ + ARCHIVE_RAW=1 \ + python3 scrape.py --mode backfill ``` -Expected runtime: several hours at 1s/request. The subcollection cursor at -`/scrapers/lobbyingBackfill/processedUrls` allows safe interruption and +Expected runtime: several hours at ~1s/request. The subcollection cursor at +`scrapers/lobbyingBackfill/processedUrls` allows safe interruption and resumption. -### Step 9 — Deploy and verify Cloud Function +### Step 8 — Deploy and verify Cloud Run scraper -Deploy the function to the dev project: +Build and push the container image, then update the Cloud Run job: ```bash -conda run -n maple-2025 firebase deploy \ - --only functions:maple:scrapeLobbying \ - --project digital-testimony-dev +cd lobbying-scraper +IMAGE=us-central1-docker.pkg.dev/digital-testimony-dev/maple-lobbying/scraper:latest +docker build -t $IMAGE . && docker push $IMAGE + +gcloud run jobs update maple-lobbying-scraper \ + --image=$IMAGE \ + --project=digital-testimony-dev \ + --region=us-central1 ``` -Trigger a manual run via the Firebase console or: +Trigger a manual run via the Cloud Run console or: ```bash -conda run -n maple-2025 yarn firebase-admin run-script runScrapers \ - --env local --targets scrapeLobbying +gcloud run jobs execute maple-lobbying-scraper \ + --project=digital-testimony-dev \ + --region=us-central1 ``` -Verify: Cloud Function logs show the expected number of new disclosures (should -be near zero if backfill completed, since current+prior year are already -processed). +Verify via Cloud Run logs: the run should find near-zero new disclosures if the +backfill already completed, confirming the weekly fast-path works correctly. --- @@ -674,7 +671,7 @@ processed). | `billId` construction | `{chamberPrefix}{billNumber}` at ingest time | Raw portal data stores chamber and integer separately; the composite is what matches MAPLE's `Bill.id` | | `billId` null for Executive | `null` instead of agency name | Prevents accidental bill lookups; makes join guard explicit at the type level | | Normalized name fields | Store both raw and `*Norm` fields | Raw names preserved for provenance; normalized names used for grouping and matching | -| HTML parser | `jsdom` | Already in `functions/package.json` (used by events scraper); no need to add cheerio | +| HTML parser | `beautifulsoup4` (Python) | Runs in the Cloud Run container alongside the scraper; no JavaScript runtime needed | | Live scraper cursor | Array in `/scrapers/lobbying` doc | ~1,000 URLs/year fits well within the 1 MB Firestore doc limit; simple and atomic with other scraper state | | Backfill cursor | Firestore subcollection `/scrapers/lobbyingBackfill/processedUrls/{urlHash}` | Full 2005-present history (~50,000 URLs) would exceed the 1 MB doc limit; subcollection scales without bound and is durable, inspectable, and resumable from any machine | | Incremental strategy | Skip already-processed disclosure URLs; write docs by logical key (upsert) | Survives function restarts and re-runs without re-fetching already-scraped pages; natural upsert prevents duplicates without an explicit dedup pass | @@ -892,7 +889,7 @@ nothing but the parsed Firestore documents. If parsing logic changes, the full portal must be re-scraped — slow and fragile given the Imperva TLS constraint. Phase 2 adds GCS archiving of raw HTML as a side effect of every portal fetch. -#### Design principles (from the AMEND reference implementation) +#### Design principles The archive is **write-only cold storage**, not a cache. It is fully decoupled from both the incremental cursor (which stays Firestore-only) and the parse From 1ef618a9a5a5aabd6c8d0f719be3273a9ca922c5 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 30 Jun 2026 16:13:24 -0400 Subject: [PATCH 014/106] docs: add real Firestore examples and fix registrantId description - Embed actual registrant and filing documents from dev Firestore to illustrate the data model concretely for reviewers - Correct registrantId description from "slugified" to SHA-256 hash (matches the actual implementation in writer.py) - filingId description likewise updated to SHA-256 hash Co-Authored-By: Claude Sonnet 4.6 --- docs/lobbying-disclosure-ingestion.md | 77 +++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/docs/lobbying-disclosure-ingestion.md b/docs/lobbying-disclosure-ingestion.md index 2a2e61bf0..99c9652a7 100644 --- a/docs/lobbying-disclosure-ingestion.md +++ b/docs/lobbying-disclosure-ingestion.md @@ -42,7 +42,10 @@ record. ### `/lobbyingRegistrants/{registrantId}` -`registrantId` is a slugified `{entityName}_{year}` (stable, dedup-safe). +`registrantId` is a SHA-256 hash (first 40 hex chars) of +`{entityName}_{year}`. Hashing avoids sanitizing arbitrary Unicode and +punctuation in entity names to fit Firestore ID constraints while remaining +stable and dedup-safe across runs. One model covers both individual lobbyists and lobbying firms. A separate model is not needed because the portal search returns both under the same schema, and @@ -51,7 +54,7 @@ worked on which bill. ```typescript interface LobbyingRegistrant { - registrantId: string // "{entityName}_{year}" slugified + registrantId: string // SHA-256 hash of "{entityName}_{year}" entityName: string // firm name or individual lobbyist name (raw portal value) entityNameNorm: string // normalized form; see Normalization section year: number @@ -69,9 +72,42 @@ interface LobbyingClient { } ``` +**Example document** (`lobbyingRegistrants/0f11970cc6f17e35cc02685b794cb63a655c13b3`): + +```json +{ + "registrantId": "0f11970cc6f17e35cc02685b794cb63a655c13b3", + "entityName": "27 South Strategies, LLC", + "entityNameNorm": "27 SOUTH STRATEGIES", + "year": 2024, + "generalCourt": 193, + "regType": "Employer", + "clients": [ + { + "clientName": "The Massachusetts International Festival of the Arts, Inc.", + "clientNameNorm": "MASSACHUSETTS INTERNATIONAL FESTIVAL OF ARTS", + "compensation": 24000.0 + }, + { + "clientName": "Veterinary Emergency Group, LLC", + "clientNameNorm": "VETERINARY EMERGENCY GROUP", + "compensation": 33000.0 + }, + { + "clientName": "Gobrands, Inc", + "clientNameNorm": "GOBRANDS", + "compensation": 60000.0 + } + ], + "disclosureUrls": [ + "https://www.sec.state.ma.us/LobbyistPublicSearch/CompleteDisclosure.aspx?sysvalue=hTpfuAXM..." + ] +} +``` + ### `/lobbyingFilings/{filingId}` -`filingId` is a slugified +`filingId` is a SHA-256 hash (first 40 hex chars) of the logical key `{entityName}_{clientName}_{chamber}_{activityRef}_{generalCourt}`. ```typescript @@ -102,6 +138,41 @@ interface LobbyingFiling { } ``` +**Example documents** — query: `lobbyingFilings` where `generalCourt == 193` and `billId == "H1"`: + +```json +[ + { + "filingId": "0bac34faf2f624083daaaea57ee55f5364d2be29", + "entityName": "Christina Ascolillo", + "entityNameNorm": "CHRISTINA ASCOLILLO", + "clientName": "American Council of Engineering Companies of Massachusetts", + "clientNameNorm": "AMERICAN COUNCIL OF ENGINEERING COMPANIES OF MASSACHUSETTS", + "year": 2023, + "generalCourt": 193, + "chamber": "House Bill", + "billId": "H1", + "activityTitle": "An Act making appropriations for the Fiscal Year 2024...", + "position": "Neutral", + "amount": 0.0 + }, + { + "filingId": "109600251ca8fd279e8bb7562bc9d234c39f63a8", + "entityName": "Christina Ascolillo", + "entityNameNorm": "CHRISTINA ASCOLILLO", + "clientName": "St. Mary's Center for Women and Children, Inc.", + "clientNameNorm": "ST MARY S CENTER FOR WOMEN AND CHILDREN", + "year": 2023, + "generalCourt": 193, + "chamber": "House Bill", + "billId": "H1", + "activityTitle": "An Act making appropriations for the Fiscal Year 2024...", + "position": "Neutral", + "amount": 0.0 + } +] +``` + ### Constructing `billId` from Raw Portal Data The portal stores bill numbers as bare integers and records the chamber From f43d5b013a408fd101aaa0061eba08b9601bbfdb Mon Sep 17 00:00:00 2001 From: Sam DeMarrais Date: Tue, 30 Jun 2026 20:39:22 -0400 Subject: [PATCH 015/106] Cross-compatibility (#2174) --- functions/src/events/types.ts | 10 ++ functions/src/hearings/search.ts | 10 +- .../cleanupHearingVideoFormat.ts | 17 ++++ .../updateHearingVideoFormat.ts | 98 +++++++++++++++++++ 4 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 scripts/firebase-admin/cleanupHearingVideoFormat.ts create mode 100644 scripts/firebase-admin/updateHearingVideoFormat.ts diff --git a/functions/src/events/types.ts b/functions/src/events/types.ts index 4101b41d1..952d6cfe5 100644 --- a/functions/src/events/types.ts +++ b/functions/src/events/types.ts @@ -97,6 +97,13 @@ export const HearingContent = BaseEventContent.extend({ export type HearingListItem = Static export const HearingListItem = Record({ EventId: Number }) +export type Video = Static +export const Video = Record({ + url: String, + title: String, + transcriptionId: String +}) + export type Hearing = Static export const Hearing = BaseEvent.extend({ type: L("hearing"), @@ -104,6 +111,9 @@ export const Hearing = BaseEvent.extend({ videoURL: Optional(String), videoTranscriptionId: Optional(String), videoFetchedAt: Optional(InstanceOf(Timestamp)), + transcriptionIds: Optional(Array(String)), + videos: Optional(Array(Video)), + videosFetchedAt: Optional(InstanceOf(Timestamp)), committeeChairs: Optional(Array(String)) }) diff --git a/functions/src/hearings/search.ts b/functions/src/hearings/search.ts index fe26d0385..b36fe5e2f 100644 --- a/functions/src/hearings/search.ts +++ b/functions/src/hearings/search.ts @@ -57,7 +57,13 @@ export const { }, convert: data => { const hearing = Hearing.check(data) - const { content, startsAt: startsAtTimestamp, id, videoURL } = hearing + const { + content, + startsAt: startsAtTimestamp, + id, + videoURL, + videos + } = hearing const startsAt = startsAtTimestamp.toMillis() const schedule = DateTime.fromMillis(startsAt, { zone: timeZone }) @@ -115,7 +121,7 @@ export const { bill => bill.slug || `${courtNumber}/${bill.number}` ), court: courtNumber, - hasVideo: Boolean(videoURL) + hasVideo: Boolean(videoURL || videos?.length) } } }) diff --git a/scripts/firebase-admin/cleanupHearingVideoFormat.ts b/scripts/firebase-admin/cleanupHearingVideoFormat.ts new file mode 100644 index 000000000..e9a0de5cc --- /dev/null +++ b/scripts/firebase-admin/cleanupHearingVideoFormat.ts @@ -0,0 +1,17 @@ +import { FieldValue } from "../../functions/src/firebase" +import { reformatFactory } from "./updateHearingVideoFormat" +import { Script } from "./types" + +function getVideoFormatCleanup(data: FirebaseFirestore.DocumentData): any { + if (!("videoURL" in data)) { + return null + } + + return { + videoTranscriptionId: FieldValue.delete(), + videoFetchedAt: FieldValue.delete(), + videoURL: FieldValue.delete() + } +} + +export const script: Script = reformatFactory(getVideoFormatCleanup) diff --git a/scripts/firebase-admin/updateHearingVideoFormat.ts b/scripts/firebase-admin/updateHearingVideoFormat.ts new file mode 100644 index 000000000..710ef62c4 --- /dev/null +++ b/scripts/firebase-admin/updateHearingVideoFormat.ts @@ -0,0 +1,98 @@ +import { Record, String } from "runtypes" +import { Script } from "./types" + +const Args = Record({ + hearingId: String.optional() +}) + +export function reformatFactory( + fn: (data: FirebaseFirestore.DocumentData) => any +) { + return async ({ + db, + args + }: { + db: FirebaseFirestore.Firestore + args: any + }) => { + const { hearingId } = Args.check(args) + + // Process a single event by eventId + if (hearingId) { + const snapshot = await db + .collection("events") + .where("type", "==", "hearing") + .where("id", "==", hearingId) + .get() + + if (snapshot.empty || snapshot.docs.length !== 1) { + throw new Error( + `The number of documents matching the event id ${hearingId} must be exactly one` + ) + } + + const doc = snapshot.docs[0] + const modify = fn(doc.data()) + if (modify) { + await doc.ref.update(modify) + } + } else { + const snapshot = await db + .collection("events") + .where("type", "==", "hearing") + .get() + + if (snapshot.empty) { + throw new Error("Hearing backfill failed; no documents were found") + } + + let bulkWriter = db.bulkWriter() + + for (const doc of snapshot.docs) { + console.log(doc.data().id) + const modify = fn(doc.data()) + if (modify) { + bulkWriter.update(doc.ref, modify) + } + } + await bulkWriter.close() + } + + console.log("Video backfill complete") + } +} + +function getVideoFormatUpdate(data: FirebaseFirestore.DocumentData): any { + if ("videos" in data || !("videoURL" in data)) { + return null + } + + const url = data.videoURL + const fetchedAt = data.videoFetchedAt + const transcriptionId = data.videoTranscriptionId + + if (!url || !fetchedAt || !transcriptionId) { + throw new Error( + `In the data for ${data.id}, it is expected that if videoURL exists videoFetchedAt and videoTranscriptionId also exist` + ) + } + + const transcriptionIds = [transcriptionId] + + const videos = [ + { + // Default; not shown + title: data.id, + url, + transcriptionId + } + ] + + return { + videos, + transcriptionIds, + videosFetchedAt: fetchedAt + } +} + +export const script: Script = reformatFactory(getVideoFormatUpdate) From c28e6a16644007e37bf83ba9edc1ff39af0f7953 Mon Sep 17 00:00:00 2001 From: mertbagt <73559781+mertbagt@users.noreply.github.com> Date: Tue, 30 Jun 2026 20:54:10 -0400 Subject: [PATCH 016/106] moar Homepage Tweaks (#2172) * remove squish effect * Browse All Testimony button responsiveness tweaked * revert surface-gradiant * tweaks 3 * removed width: 100vw from peopleSaying class in css * revert old herosection text * cleanup --- components/homepage/HeroSection.tsx | 27 ++++++++++++++++++++++++- components/homepage/Homepage.module.css | 19 ++++++++--------- public/locales/en/homepage.json | 6 +++++- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/components/homepage/HeroSection.tsx b/components/homepage/HeroSection.tsx index bdbc85d8f..4baa35b4b 100644 --- a/components/homepage/HeroSection.tsx +++ b/components/homepage/HeroSection.tsx @@ -1,8 +1,11 @@ import { useTranslation } from "next-i18next" import Image from "react-bootstrap/Image" -import { Internal } from "components/links" + import styles from "./Homepage.module.css" +import { NEWSLETTER_SIGNUP_URL, TRAINING_CALENDAR_URL } from "components/common" +import { Internal } from "components/links" + export default function HeroSection() { const { t } = useTranslation("homepage") @@ -22,6 +25,28 @@ export default function HeroSection() {

{t("hero.title")}

{t("hero.body")}

+

+ {t("hero.body2a")}{" "} + + {t("hero.calendar")} + {" "} + {t("hero.body2b")} +

+

+ + {t("hero.newsletter")} + +

{t("hero.primaryAction")} diff --git a/components/homepage/Homepage.module.css b/components/homepage/Homepage.module.css index 0d508184b..592afb424 100644 --- a/components/homepage/Homepage.module.css +++ b/components/homepage/Homepage.module.css @@ -22,16 +22,15 @@ .heroSection { position: relative; width: 100%; - padding-top: 5rem; - background: var(--maple-surface-base); + background: var(--maple-brand-primary); overflow: hidden; } .hero { display: grid; - grid-template-columns: minmax(23rem, 1.05fr) minmax(24rem, 1.15fr); + grid-template-columns: minmax(30rem, 1.05fr) minmax(30rem, 2fr); align-items: center; - gap: clamp(1.8rem, 4vw, 3.4rem); + gap: clamp(1.8rem, 4vw, 10rem); min-height: 38rem; padding-top: 0rem; padding-bottom: 1rem; @@ -43,7 +42,7 @@ right: 0; bottom: 0; height: 100%; - background-image: url("/skyline_blue.svg"); + background-image: url("/skyline.svg"); background-repeat: no-repeat; background-position: bottom center; background-size: 100% auto; @@ -55,6 +54,7 @@ .heroVisual { position: relative; height: 100%; + width: 100%; min-height: 24rem; display: flex; align-items: flex-end; @@ -78,18 +78,18 @@ } .heroTitle { - color: var(--maple-brand-primary-strong); - font-size: clamp(2.5rem, 3.5vw, 3.8rem); + color: white; + font-size: clamp(2.5rem, 3vw, 3rem); line-height: 1.08; margin: 0 0 1.35rem; } .heroBody { - color: var(--maple-text-body); + color: white; font-size: 1.125rem; line-height: 1.62; margin: 0 0 1.9rem; - max-width: 42rem; + max-width: 50rem; } .heroActions { @@ -440,6 +440,7 @@ .heroVisual { min-height: 20rem; + width: 100%; order: 1; margin-bottom: 1.5rem; display: flex; diff --git a/public/locales/en/homepage.json b/public/locales/en/homepage.json index d426b683a..445da42e1 100644 --- a/public/locales/en/homepage.json +++ b/public/locales/en/homepage.json @@ -26,7 +26,11 @@ }, "hero": { "title": "Your Voice Matters", - "body": "MAPLE makes it easy for anyone to submit and see testimony to the Massachusetts Legislature about the bills that will shape our future.", + "body": "MAPLE makes it easy for anyone to view and submit testimony to the Massachusetts Legislature about the bills that will shape our future.", + "body2a": "New to MAPLE? For extra support, check", + "calendar": "Our Calendar", + "body2b": "and join an upcoming training session!", + "newsletter": "Click Here to Subscribe to Our Newsletter", "primaryAction": "Get Started", "secondaryAction": "Learn More" }, From 1f39cfb55b63561b056e2d2dd1afb5ac1fb70014 Mon Sep 17 00:00:00 2001 From: mertbagt Date: Thu, 2 Jul 2026 15:59:50 -0400 Subject: [PATCH 017/106] update browser tab title --- pages/legislators/[court]/[memberCode].tsx | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/pages/legislators/[court]/[memberCode].tsx b/pages/legislators/[court]/[memberCode].tsx index 5824626c9..d4dc9e441 100644 --- a/pages/legislators/[court]/[memberCode].tsx +++ b/pages/legislators/[court]/[memberCode].tsx @@ -1,8 +1,9 @@ import { GetServerSideProps } from "next" import { serverSideTranslations } from "next-i18next/serverSideTranslations" import { z } from "zod" -import { LegislatorProfilePage } from "components/LegislatorProfile" + import { flags } from "components/featureFlags" +import { LegislatorProfilePage } from "components/LegislatorProfile" import { createPage } from "components/page" const Query = z.object({ @@ -14,10 +15,10 @@ export default createPage<{ court: number memberCode: string }>({ - titleI18nKey: "titles.legislatorProfile", - Page: ({ court, memberCode }) => ( - - ) + titleI18nKey: "navigation.legislator", + Page: ({ court, memberCode }) => { + return + } }) export const getServerSideProps: GetServerSideProps = async ctx => { @@ -38,7 +39,14 @@ export const getServerSideProps: GetServerSideProps = async ctx => { return { props: { ...query.data, - ...(await serverSideTranslations(locale, ["auth", "common", "footer"])) + ...(await serverSideTranslations(locale, [ + "auth", + "common", + "footer", + "legislators", + "profile", + "testimony" + ])) } } } From 90f9273ec0b5a8c938a0b8a77eb5e271c99eb7e1 Mon Sep 17 00:00:00 2001 From: mertbagt Date: Thu, 2 Jul 2026 16:26:34 -0400 Subject: [PATCH 018/106] directory path --- .../LegislatorProfilePage.tsx | 33 +++++++++++++++++-- pages/legislators/[court]/[memberCode].tsx | 6 ++-- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/components/LegislatorProfile/LegislatorProfilePage.tsx b/components/LegislatorProfile/LegislatorProfilePage.tsx index 34a0d4ed8..b32490447 100644 --- a/components/LegislatorProfile/LegislatorProfilePage.tsx +++ b/components/LegislatorProfile/LegislatorProfilePage.tsx @@ -1,8 +1,14 @@ +import { faChevronRight } from "@fortawesome/free-solid-svg-icons" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" import ErrorPage from "next/error" +import { useTranslation } from "next-i18next" import styled from "styled-components" + +import { DistrictTab } from "./DistrictTab" + import { Col, Container, Row, Spinner } from "components/bootstrap" import { useDistrict, useMember } from "components/db" -import { DistrictTab } from "./DistrictTab" +import { Internal } from "components/links" const tabs = [ "Priorities", @@ -14,6 +20,12 @@ const tabs = [ "Votes" ] +const DirectoryPath = styled.div.attrs(props => ({ + className: `align-items-center d-flex flex-nowrap ${props.className}` +}))` + font-size: 12px; +` + const TabButton = styled.button` background: transparent; border: 0; @@ -42,6 +54,7 @@ export function LegislatorProfilePage({ member?.Branch, member?.District ) + const { t } = useTranslation("legislators") if (memberLoading) { return ( @@ -55,8 +68,24 @@ export function LegislatorProfilePage({ return } + console.log("member: ", member) + return ( - + + + + {t("home")} + + + + {/* update with link to legistators search page when online */} +
{t("legislators")}
+ {/* */} + + +
{member.Name}
+
+

{member.Name}

diff --git a/pages/legislators/[court]/[memberCode].tsx b/pages/legislators/[court]/[memberCode].tsx index d4dc9e441..5c6357718 100644 --- a/pages/legislators/[court]/[memberCode].tsx +++ b/pages/legislators/[court]/[memberCode].tsx @@ -16,9 +16,9 @@ export default createPage<{ memberCode: string }>({ titleI18nKey: "navigation.legislator", - Page: ({ court, memberCode }) => { - return - } + Page: ({ court, memberCode }) => ( + + ) }) export const getServerSideProps: GetServerSideProps = async ctx => { From 56e86034ccc95f5c64282fa34674df83cd591df3 Mon Sep 17 00:00:00 2001 From: mertbagt Date: Thu, 2 Jul 2026 20:54:02 -0400 Subject: [PATCH 019/106] legislator header refactored --- .../LegislatorComponents.tsx | 131 ++++++++ .../LegislatorProfilePage.tsx | 306 +++++++++++++++++- components/db/members.ts | 2 + 3 files changed, 433 insertions(+), 6 deletions(-) create mode 100644 components/LegislatorProfile/LegislatorComponents.tsx diff --git a/components/LegislatorProfile/LegislatorComponents.tsx b/components/LegislatorProfile/LegislatorComponents.tsx new file mode 100644 index 000000000..e420630f4 --- /dev/null +++ b/components/LegislatorProfile/LegislatorComponents.tsx @@ -0,0 +1,131 @@ +import { useTranslation } from "next-i18next" +import styled from "styled-components" + +export const formatPhoneNumber = (value: string) => { + if (!value) return value + + const phoneNumber = value.replace(/[^\d]/g, "") + const phoneNumberLength = phoneNumber.length + + // Format as (XXX) XXX-XXXX + if (phoneNumberLength < 4) return phoneNumber + if (phoneNumberLength < 7) { + return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3)}` + } + return ` + (${phoneNumber.slice(0, 3)}) + ${phoneNumber.slice(3, 6)}- + ${phoneNumber.slice(6, 10)} + ` +} + +/** Party Labels **/ + +const DemocraticBubble = styled.div.attrs(props => ({ + className: `${props.className}` +}))` + background: #d1d6e7; + color: #1a3185; + font-size: 11px; + font-weight: 700; + padding: 1px 10px; + border-radius: 999px; + width: max-content; +` + +const DistrictBubble = styled.div.attrs(props => ({ + className: `ms-1 ${props.className}` +}))` + background: #fff3cd; + color: #856404; + font-size: 11px; + font-weight: 700; + padding: 1px 10px; + border-radius: 999px; + width: max-content; +` + +const IndependantBubble = styled.div.attrs(props => ({ + className: `${props.className}` +}))` + background: #bca0dc; + color: #3c1361; + font-size: 11px; + font-weight: 700; + padding: 1px 10px; + border-radius: 999px; + width: max-content; +` + +const RepublicanBubble = styled.div.attrs(props => ({ + className: `${props.className}` +}))` + background: #f29999; + color: #de0100; + font-size: 11px; + font-weight: 700; + padding: 1px 10px; + border-radius: 999px; + width: max-content; +` + +export function DistrictLabel(props: { district: string }) { + return {props.district} +} + +export function PartyLabel(props: { party: string }) { + const { t } = useTranslation("legislators") + + switch (props.party) { + case "Democrat": + return {t("party.democratic")} + case "Republican": + return {t("party.republican")} + default: + return ( + + {props.party} {t("party.party")} + + ) + } +} + +/** Social Media components **/ + +export function Bluesky() { + return ( + + + + ) +} + +export function LinkedIn() { + return ( + + + + ) +} + +export function Twitter() { + return ( + + + + ) +} diff --git a/components/LegislatorProfile/LegislatorProfilePage.tsx b/components/LegislatorProfile/LegislatorProfilePage.tsx index b32490447..9be5b4cf8 100644 --- a/components/LegislatorProfile/LegislatorProfilePage.tsx +++ b/components/LegislatorProfile/LegislatorProfilePage.tsx @@ -4,11 +4,44 @@ import ErrorPage from "next/error" import { useTranslation } from "next-i18next" import styled from "styled-components" +import * as links from "../links" + import { DistrictTab } from "./DistrictTab" +import { + Bluesky, + DistrictLabel, + formatPhoneNumber, + LinkedIn, + PartyLabel, + Twitter +} from "./LegislatorComponents" import { Col, Container, Row, Spinner } from "components/bootstrap" import { useDistrict, useMember } from "components/db" import { Internal } from "components/links" +import { CircleImage } from "components/shared/LabeledIcon" + +import { doc, getDoc } from "firebase/firestore" +import { useCallback, useEffect, useState } from "react" + +import { usePublicProfile } from "../db" +import { firestore } from "../firebase" + +// import { LegislatorSidebar } from "./SidebarComponents/LegislatorSidebar" +// import { LegislatorTabs } from "./TabComponents/LegislatorTabs" + +import { useAuth } from "components/auth" +import { useFlags } from "components/featureFlags" +import { FollowUserButton } from "components/shared/FollowButton" + +type ProfilePlaceholder = { + social?: { + blueSky?: string + linkedIn?: string + twitter?: string + } + website?: string +} const tabs = [ "Priorities", @@ -20,12 +53,80 @@ const tabs = [ "Votes" ] +const ButtonContainer = styled(Col).attrs(props => ({ + className: `col-12 justify-content-md-end ${props.className}`, + md: `3`, + sm: `4` +}))` + width: max-content; +` + const DirectoryPath = styled.div.attrs(props => ({ className: `align-items-center d-flex flex-nowrap ${props.className}` }))` font-size: 12px; ` +const HeaderBlock = styled.div.attrs(props => ({ + className: `d-flex flex-wrap justify-content-between ${props.className}` +}))` + background-color: white; + border: "1px #ced4da solid"; + border-radius: 5px; + margin-top: 8px; + padding: 16px; +` + +const HeaderName = styled.div` + font-size: 26px; + font-weight: 700; + color: #0b0a3e; +` + +const RoleLine = styled.div.attrs(props => ({ + className: `mb-2 ${props.className}` +}))` + color: #6c757d; + font-size: 14px; +` + +const PhoneNum = styled.span` + color: #6c757d; +` + +const SocialLine = styled.div.attrs(props => ({ + className: `d-flex flex-wrap mb-2 ${props.className}` +}))` + font-size: 12px; + text-decoration: none; +` + +const StatBlock = styled(Col).attrs(props => ({ + className: `d-flex col-4 flex-grow-1 ${props.className}`, + md: `2` +}))` + background-color: white; + border: 1px #ced4da solid; + border-radius: 5px; + margin-top: 4px; + padding: 16px; +` + +const StatLine = styled(Row).attrs(props => ({ + className: `text-nowrap ${props.className}` +}))` + font-size: 12px; +` + +const StatNum = styled.div.attrs(props => ({ + className: `mx-auto ${props.className}` +}))` + color: #1a3185; + font-size: 22px; + font-weight: 700; + width: max-content; +` + const TabButton = styled.button` background: transparent; border: 0; @@ -55,6 +156,23 @@ export function LegislatorProfilePage({ member?.District ) const { t } = useTranslation("legislators") + const { user } = useAuth() + + /* replace with profile info for legislators with Maple accounts */ + let profile: ProfilePlaceholder = { + // social: { + // blueSky: "blueskyTest", + // linkedIn: "linkedinTest", + // twitter: "twitterTest" + // }, + // website: "test.com" + social: { + blueSky: "", + linkedIn: "", + twitter: "" + }, + website: "" + } if (memberLoading) { return ( @@ -86,14 +204,190 @@ export function LegislatorProfilePage({
{member.Name}
- + + + {""} + + -

{member.Name}

-

- {member.Branch} - {member.District} -

+ + + {member.Name} + + + + + {member.Branch == "Senate" ? ( + {t("stateSenator")} + ) : ( + {t("stateRepresentative")} + )} + {/* · Town */} + + +
+ + {/* Incumbent Label */} + +
+ + +
+ + {member.EmailAddress} + +
+ + {profile.website ? ( +
+ · + + {profile.website} + +
+ ) : ( +
+ · + + {`malegislature.gov/Legislators/Profile/${member.MemberCode}`} + +
+ )} + + {member.PhoneNumber ? ( +
+ · + {formatPhoneNumber(member.PhoneNumber)} +
+ ) : ( + <> + )} + +
+ {profile?.social?.twitter || + profile?.social?.linkedIn || + profile?.social?.blueSky ? ( + · + ) : ( + <> + )} + + {profile?.social?.twitter ? ( + + + + ) : ( + <> + )} + {profile?.social?.linkedIn ? ( + + + + ) : ( + <> + )} + {profile?.social?.blueSky ? ( + + + + ) : ( + <> + )} +
+
-
+ + + {/* uncomment when legislator Maple accounts are linked to this page + + {user && legislatorAccountId ? ( +
+ +
+ ) : ( + <> + )} */} + + {t("contact")} + +
+ + +
+ + + ? + {t("termsServed")} + + + + + + {member.SponsoredBills.length ? ( + <>{member.SponsoredBills.length} + ) : ( + <>? + )} + + {t("billsSponsored")} + + + + + + {member.CoSponsoredBills.length ? ( + <>{member.CoSponsoredBills.length} + ) : ( + <>? + )} + + {t("cosponsored")} + + + + + ? + {t("fundsRaised")} + + +
+
+ CoSponsoredBills: Array } export type Member = { From 9718b06ceea52ed97a02f453adfebd3e3d9bf0c6 Mon Sep 17 00:00:00 2001 From: mertbagt Date: Sat, 4 Jul 2026 15:16:33 -0400 Subject: [PATCH 020/106] legislator tabs --- .../LegislatorProfilePage.tsx | 85 +++++------ .../LegislatorProfile/LegislatorTabs.tsx | 133 ++++++++++++++++++ .../TabComponents/BillsTab.tsx | 3 + .../{ => TabComponents}/DistrictTab.test.tsx | 0 .../{ => TabComponents}/DistrictTab.tsx | 0 .../TabComponents/DistrictTabOld.tsx | 3 + .../TabComponents/ElectionsTab.tsx | 3 + .../TabComponents/FinanceTab.tsx | 3 + .../TabComponents/PrioritiesTab.tsx | 3 + .../TabComponents/TestimonyTab.tsx | 3 + .../TestimonyTab_PendingProfileLink.tsx | 102 ++++++++++++++ .../TabComponents/VotesTab.tsx | 3 + components/LegislatorProfile/index.ts | 1 - 13 files changed, 294 insertions(+), 48 deletions(-) create mode 100644 components/LegislatorProfile/LegislatorTabs.tsx create mode 100644 components/LegislatorProfile/TabComponents/BillsTab.tsx rename components/LegislatorProfile/{ => TabComponents}/DistrictTab.test.tsx (100%) rename components/LegislatorProfile/{ => TabComponents}/DistrictTab.tsx (100%) create mode 100644 components/LegislatorProfile/TabComponents/DistrictTabOld.tsx create mode 100644 components/LegislatorProfile/TabComponents/ElectionsTab.tsx create mode 100644 components/LegislatorProfile/TabComponents/FinanceTab.tsx create mode 100644 components/LegislatorProfile/TabComponents/PrioritiesTab.tsx create mode 100644 components/LegislatorProfile/TabComponents/TestimonyTab.tsx create mode 100644 components/LegislatorProfile/TabComponents/TestimonyTab_PendingProfileLink.tsx create mode 100644 components/LegislatorProfile/TabComponents/VotesTab.tsx diff --git a/components/LegislatorProfile/LegislatorProfilePage.tsx b/components/LegislatorProfile/LegislatorProfilePage.tsx index 9be5b4cf8..4a0e22df5 100644 --- a/components/LegislatorProfile/LegislatorProfilePage.tsx +++ b/components/LegislatorProfile/LegislatorProfilePage.tsx @@ -6,7 +6,6 @@ import styled from "styled-components" import * as links from "../links" -import { DistrictTab } from "./DistrictTab" import { Bluesky, DistrictLabel, @@ -15,24 +14,15 @@ import { PartyLabel, Twitter } from "./LegislatorComponents" +// import { LegislatorSidebar } from "./SidebarComponents/LegislatorSidebar" +import { LegislatorTabs } from "./LegislatorTabs" +import { useAuth } from "components/auth" import { Col, Container, Row, Spinner } from "components/bootstrap" import { useDistrict, useMember } from "components/db" import { Internal } from "components/links" -import { CircleImage } from "components/shared/LabeledIcon" - -import { doc, getDoc } from "firebase/firestore" -import { useCallback, useEffect, useState } from "react" - -import { usePublicProfile } from "../db" -import { firestore } from "../firebase" - -// import { LegislatorSidebar } from "./SidebarComponents/LegislatorSidebar" -// import { LegislatorTabs } from "./TabComponents/LegislatorTabs" - -import { useAuth } from "components/auth" -import { useFlags } from "components/featureFlags" import { FollowUserButton } from "components/shared/FollowButton" +import { CircleImage } from "components/shared/LabeledIcon" type ProfilePlaceholder = { social?: { @@ -127,20 +117,20 @@ const StatNum = styled.div.attrs(props => ({ width: max-content; ` -const TabButton = styled.button` - background: transparent; - border: 0; - border-bottom: 5px solid transparent; - color: #68707a; - font-size: 1.35rem; - font-weight: 700; - padding: 1.35rem 1.9rem 1.15rem; - - &.active { - border-bottom-color: #18358f; - color: #18358f; - } -` +// const TabButton = styled.button` +// background: transparent; +// border: 0; +// border-bottom: 5px solid transparent; +// color: #68707a; +// font-size: 1.35rem; +// font-weight: 700; +// padding: 1.35rem 1.9rem 1.15rem; + +// &.active { +// border-bottom-color: #18358f; +// color: #18358f; +// } +// ` export function LegislatorProfilePage({ court, @@ -156,7 +146,7 @@ export function LegislatorProfilePage({ member?.District ) const { t } = useTranslation("legislators") - const { user } = useAuth() + // const { user } = useAuth() **uncomment when Following Button in enabled** /* replace with profile info for legislators with Maple accounts */ let profile: ProfilePlaceholder = { @@ -186,7 +176,7 @@ export function LegislatorProfilePage({ return } - console.log("member: ", member) + console.log("district: ", district) return ( @@ -196,7 +186,7 @@ export function LegislatorProfilePage({ - {/* update with link to legistators search page when online */} + {/* update with link to legistators search page when that is created */}
{t("legislators")}
{/* */} @@ -358,25 +348,13 @@ export function LegislatorProfilePage({ - - {member.SponsoredBills.length ? ( - <>{member.SponsoredBills.length} - ) : ( - <>? - )} - + {member.SponsoredBills.length} {t("billsSponsored")} - - {member.CoSponsoredBills.length ? ( - <>{member.CoSponsoredBills.length} - ) : ( - <>? - )} - + {member.CoSponsoredBills.length} {t("cosponsored")} @@ -388,7 +366,20 @@ export function LegislatorProfilePage({
-
+ + + + + {/* */} +
Sidebar
+ + + + {/*
-
+
*/} ) } diff --git a/components/LegislatorProfile/LegislatorTabs.tsx b/components/LegislatorProfile/LegislatorTabs.tsx new file mode 100644 index 000000000..b969ad04c --- /dev/null +++ b/components/LegislatorProfile/LegislatorTabs.tsx @@ -0,0 +1,133 @@ +import { useTranslation } from "next-i18next" +import { TabPane } from "react-bootstrap" +import TabContainer from "react-bootstrap/TabContainer" +import styled from "styled-components" + +import { Container, Nav } from "../bootstrap" + +import { BillsTab } from "./TabComponents/BillsTab" +import { DistrictTab } from "./TabComponents/DistrictTab" +import { ElectionsTab } from "./TabComponents/ElectionsTab" +import { FinanceTab } from "./TabComponents/FinanceTab" +import { PrioritiesTab } from "./TabComponents/PrioritiesTab" +import { TestimonyTab } from "./TabComponents/TestimonyTab" +import { VotesTab } from "./TabComponents/VotesTab" + +import { District } from "components/db" +import { + StyledTabContent, + TabNavWrapper, + TabType +} from "components/EditProfilePage/StyledEditProfileComponents" + +const tabCategory = [ + "priorities", + "bills", + "elections", + "finance", + "district", + "testimony", + "votes" +] +type TabCategories = (typeof tabCategory)[number] + +const TabNavLink = styled(Nav.Link).attrs(props => ({ + className: `rounded-top m-0 p-0 ${props.className}` +}))` + color: #6c757d; + + &.active { + color: #1a3185; + font-weight: bold; + } +` + +const TabNavItem = ({ + tab, + i: i, + className +}: { + tab: TabType + i: number + className?: string +}) => { + return ( + + +

+ {tab.title} +

+
+
+
+ ) +} + +export function LegislatorTabs({ + district, + districtLoading, + tabCategory +}: { + district?: District | undefined + districtLoading?: boolean + tabCategory?: TabCategories +}) { + const { t } = useTranslation("legislators") + + const tabs = [ + { + title: t("tabs.priorities"), + eventKey: "priorities", + content: + }, + { + title: t("tabs.bills"), + eventKey: "bills", + content: + }, + { + title: t("tabs.elections"), + eventKey: "elections", + content: + }, + { + title: t("tabs.finance"), + eventKey: "finance", + content: + }, + { + title: t("tabs.district"), + eventKey: "district", + content: + }, + { + title: t("tabs.testimony"), + eventKey: "testimony", + content: + }, + { + title: t("tabs.votes"), + eventKey: "votes", + content: + } + ] + + return ( + + + + {tabs.map((t, i) => ( + + ))} + + + {tabs.map(t => ( + + {t.content} + + ))} + + + + ) +} diff --git a/components/LegislatorProfile/TabComponents/BillsTab.tsx b/components/LegislatorProfile/TabComponents/BillsTab.tsx new file mode 100644 index 000000000..7ccfc2ebd --- /dev/null +++ b/components/LegislatorProfile/TabComponents/BillsTab.tsx @@ -0,0 +1,3 @@ +export function BillsTab() { + return
- Bills
+} diff --git a/components/LegislatorProfile/DistrictTab.test.tsx b/components/LegislatorProfile/TabComponents/DistrictTab.test.tsx similarity index 100% rename from components/LegislatorProfile/DistrictTab.test.tsx rename to components/LegislatorProfile/TabComponents/DistrictTab.test.tsx diff --git a/components/LegislatorProfile/DistrictTab.tsx b/components/LegislatorProfile/TabComponents/DistrictTab.tsx similarity index 100% rename from components/LegislatorProfile/DistrictTab.tsx rename to components/LegislatorProfile/TabComponents/DistrictTab.tsx diff --git a/components/LegislatorProfile/TabComponents/DistrictTabOld.tsx b/components/LegislatorProfile/TabComponents/DistrictTabOld.tsx new file mode 100644 index 000000000..b2a70fa19 --- /dev/null +++ b/components/LegislatorProfile/TabComponents/DistrictTabOld.tsx @@ -0,0 +1,3 @@ +export function DistrictTab() { + return
- District
+} diff --git a/components/LegislatorProfile/TabComponents/ElectionsTab.tsx b/components/LegislatorProfile/TabComponents/ElectionsTab.tsx new file mode 100644 index 000000000..6fea4cfbf --- /dev/null +++ b/components/LegislatorProfile/TabComponents/ElectionsTab.tsx @@ -0,0 +1,3 @@ +export function ElectionsTab() { + return
- Elections
+} diff --git a/components/LegislatorProfile/TabComponents/FinanceTab.tsx b/components/LegislatorProfile/TabComponents/FinanceTab.tsx new file mode 100644 index 000000000..95f840dbb --- /dev/null +++ b/components/LegislatorProfile/TabComponents/FinanceTab.tsx @@ -0,0 +1,3 @@ +export function FinanceTab() { + return
- Finance
+} diff --git a/components/LegislatorProfile/TabComponents/PrioritiesTab.tsx b/components/LegislatorProfile/TabComponents/PrioritiesTab.tsx new file mode 100644 index 000000000..33c98c008 --- /dev/null +++ b/components/LegislatorProfile/TabComponents/PrioritiesTab.tsx @@ -0,0 +1,3 @@ +export function PrioritiesTab() { + return
- Priorities
+} diff --git a/components/LegislatorProfile/TabComponents/TestimonyTab.tsx b/components/LegislatorProfile/TabComponents/TestimonyTab.tsx new file mode 100644 index 000000000..e00fd7e0d --- /dev/null +++ b/components/LegislatorProfile/TabComponents/TestimonyTab.tsx @@ -0,0 +1,3 @@ +export function TestimonyTab() { + return
- Testimony
+} diff --git a/components/LegislatorProfile/TabComponents/TestimonyTab_PendingProfileLink.tsx b/components/LegislatorProfile/TabComponents/TestimonyTab_PendingProfileLink.tsx new file mode 100644 index 000000000..da1e672a3 --- /dev/null +++ b/components/LegislatorProfile/TabComponents/TestimonyTab_PendingProfileLink.tsx @@ -0,0 +1,102 @@ +import { useMemo } from "react" +import { useTranslation } from "next-i18next" +import styled from "styled-components" + +import { useAuth } from "components/auth" +import { usePublishedTestimonyListing } from "components/db/testimony/usePublishedTestimonyListing" +import { NoResults } from "components/search/NoResults" +import { TestimonyItem } from "components/TestimonyCard/TestimonyItem" + +const DisclaimerBlock = styled.div` + align-items: flex-start; + background-color: #f0f4ff; + border: "1px #d1d6e7 solid"; + border-radius: 5px; + color: #1a3185; + display: flex; + font-size: 13px; + gap: 10px; + line-height: 1.6; + margin-top: 14px; + margin-bottom: 14px; + padding: 12px 16px; +` + +const TestimonyBlock = styled.div` + background-color: white; + border: "1px #ced4da solid"; + border-radius: 5px; + font-size: 11px; + margin-bottom: 14px; + padding: 0px 16px; +` + +function Disclaimer({ fullname }: { fullname?: string }) { + const { t } = useTranslation("legislators") + + return ( + + + + + + +
+ {fullname} {t("canSubmit")} +
+
+ ) +} + +export function Testimony({ + fullname, + pageId +}: { + fullname?: string + pageId?: string +}) { + const { t } = useTranslation("testimony") + const { user } = useAuth() + + const testimony = usePublishedTestimonyListing({ + uid: pageId + }) + + const allTestimonies = useMemo(() => { + const legislatorTestimonies = testimony.items.result ?? [] + + // Combine and sort by publishedAt (newest first), then take 4 most recent + return [...legislatorTestimonies] + .sort((a, b) => b.publishedAt.toMillis() - a.publishedAt.toMillis()) + .slice(0, 4) + }, [testimony.items.result]) + + return ( + <> + + {allTestimonies.length > 0 ? ( +
+ {allTestimonies.map(testimony => ( + + + + ))} +
+ ) : ( + {t("viewTestimony.noTestimonies")} + )} + + ) +} diff --git a/components/LegislatorProfile/TabComponents/VotesTab.tsx b/components/LegislatorProfile/TabComponents/VotesTab.tsx new file mode 100644 index 000000000..3c6625cd2 --- /dev/null +++ b/components/LegislatorProfile/TabComponents/VotesTab.tsx @@ -0,0 +1,3 @@ +export function VotesTab() { + return
- Votes
+} diff --git a/components/LegislatorProfile/index.ts b/components/LegislatorProfile/index.ts index 056729577..01fb5e8c3 100644 --- a/components/LegislatorProfile/index.ts +++ b/components/LegislatorProfile/index.ts @@ -1,2 +1 @@ -export * from "./DistrictTab" export * from "./LegislatorProfilePage" From 81ecbf92d46a785ac560de2123cba14128f76c88 Mon Sep 17 00:00:00 2001 From: mertbagt Date: Sat, 4 Jul 2026 15:26:22 -0400 Subject: [PATCH 021/106] refactor sidebar --- .../LegislatorProfilePage.tsx | 27 +- .../LegislatorProfile/LegislatorSidebar.tsx | 14 + .../SidebarComponents/Biography.tsx | 0 .../SidebarComponents/OtherTestimony.tsx | 0 .../SidebarComponents/UpcomingHearings.tsx | 0 .../legislator/LegislatorComponents.tsx | 131 ------- components/legislator/LegislatorPage.tsx | 356 ------------------ .../SidebarComponents/LegislatorSidebar.tsx | 14 - components/legislator/TabComponents/Bills.tsx | 3 - .../legislator/TabComponents/District.tsx | 3 - .../legislator/TabComponents/Elections.tsx | 3 - .../legislator/TabComponents/Finance.tsx | 3 - .../TabComponents/LegislatorTabs.tsx | 132 ------- .../legislator/TabComponents/Priorities.tsx | 3 - .../legislator/TabComponents/Testimony.tsx | 103 ----- components/legislator/TabComponents/Votes.tsx | 3 - components/legislator/index.ts | 1 - pages/legislators/profile.tsx | 59 --- 18 files changed, 16 insertions(+), 839 deletions(-) create mode 100644 components/LegislatorProfile/LegislatorSidebar.tsx rename components/{legislator => LegislatorProfile}/SidebarComponents/Biography.tsx (100%) rename components/{legislator => LegislatorProfile}/SidebarComponents/OtherTestimony.tsx (100%) rename components/{legislator => LegislatorProfile}/SidebarComponents/UpcomingHearings.tsx (100%) delete mode 100644 components/legislator/LegislatorComponents.tsx delete mode 100644 components/legislator/LegislatorPage.tsx delete mode 100644 components/legislator/SidebarComponents/LegislatorSidebar.tsx delete mode 100644 components/legislator/TabComponents/Bills.tsx delete mode 100644 components/legislator/TabComponents/District.tsx delete mode 100644 components/legislator/TabComponents/Elections.tsx delete mode 100644 components/legislator/TabComponents/Finance.tsx delete mode 100644 components/legislator/TabComponents/LegislatorTabs.tsx delete mode 100644 components/legislator/TabComponents/Priorities.tsx delete mode 100644 components/legislator/TabComponents/Testimony.tsx delete mode 100644 components/legislator/TabComponents/Votes.tsx delete mode 100644 components/legislator/index.ts delete mode 100644 pages/legislators/profile.tsx diff --git a/components/LegislatorProfile/LegislatorProfilePage.tsx b/components/LegislatorProfile/LegislatorProfilePage.tsx index 4a0e22df5..27bbc9434 100644 --- a/components/LegislatorProfile/LegislatorProfilePage.tsx +++ b/components/LegislatorProfile/LegislatorProfilePage.tsx @@ -14,7 +14,7 @@ import { PartyLabel, Twitter } from "./LegislatorComponents" -// import { LegislatorSidebar } from "./SidebarComponents/LegislatorSidebar" +import { LegislatorSidebar } from "./LegislatorSidebar" import { LegislatorTabs } from "./LegislatorTabs" import { useAuth } from "components/auth" @@ -374,32 +374,9 @@ export function LegislatorProfilePage({ /> - {/* */} -
Sidebar
+ - - {/*
- {tabs.map(label => ( - - {label} - - ))} -
-
- -
*/} ) } diff --git a/components/LegislatorProfile/LegislatorSidebar.tsx b/components/LegislatorProfile/LegislatorSidebar.tsx new file mode 100644 index 000000000..db445f89b --- /dev/null +++ b/components/LegislatorProfile/LegislatorSidebar.tsx @@ -0,0 +1,14 @@ +import { Biography } from "./SidebarComponents/Biography" +import { OtherTestimony } from "./SidebarComponents/OtherTestimony" +import { UpcomingHearings } from "./SidebarComponents/UpcomingHearings" + +export function LegislatorSidebar() { + return ( + <> + Sidebar Components + + + + + ) +} diff --git a/components/legislator/SidebarComponents/Biography.tsx b/components/LegislatorProfile/SidebarComponents/Biography.tsx similarity index 100% rename from components/legislator/SidebarComponents/Biography.tsx rename to components/LegislatorProfile/SidebarComponents/Biography.tsx diff --git a/components/legislator/SidebarComponents/OtherTestimony.tsx b/components/LegislatorProfile/SidebarComponents/OtherTestimony.tsx similarity index 100% rename from components/legislator/SidebarComponents/OtherTestimony.tsx rename to components/LegislatorProfile/SidebarComponents/OtherTestimony.tsx diff --git a/components/legislator/SidebarComponents/UpcomingHearings.tsx b/components/LegislatorProfile/SidebarComponents/UpcomingHearings.tsx similarity index 100% rename from components/legislator/SidebarComponents/UpcomingHearings.tsx rename to components/LegislatorProfile/SidebarComponents/UpcomingHearings.tsx diff --git a/components/legislator/LegislatorComponents.tsx b/components/legislator/LegislatorComponents.tsx deleted file mode 100644 index e420630f4..000000000 --- a/components/legislator/LegislatorComponents.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { useTranslation } from "next-i18next" -import styled from "styled-components" - -export const formatPhoneNumber = (value: string) => { - if (!value) return value - - const phoneNumber = value.replace(/[^\d]/g, "") - const phoneNumberLength = phoneNumber.length - - // Format as (XXX) XXX-XXXX - if (phoneNumberLength < 4) return phoneNumber - if (phoneNumberLength < 7) { - return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3)}` - } - return ` - (${phoneNumber.slice(0, 3)}) - ${phoneNumber.slice(3, 6)}- - ${phoneNumber.slice(6, 10)} - ` -} - -/** Party Labels **/ - -const DemocraticBubble = styled.div.attrs(props => ({ - className: `${props.className}` -}))` - background: #d1d6e7; - color: #1a3185; - font-size: 11px; - font-weight: 700; - padding: 1px 10px; - border-radius: 999px; - width: max-content; -` - -const DistrictBubble = styled.div.attrs(props => ({ - className: `ms-1 ${props.className}` -}))` - background: #fff3cd; - color: #856404; - font-size: 11px; - font-weight: 700; - padding: 1px 10px; - border-radius: 999px; - width: max-content; -` - -const IndependantBubble = styled.div.attrs(props => ({ - className: `${props.className}` -}))` - background: #bca0dc; - color: #3c1361; - font-size: 11px; - font-weight: 700; - padding: 1px 10px; - border-radius: 999px; - width: max-content; -` - -const RepublicanBubble = styled.div.attrs(props => ({ - className: `${props.className}` -}))` - background: #f29999; - color: #de0100; - font-size: 11px; - font-weight: 700; - padding: 1px 10px; - border-radius: 999px; - width: max-content; -` - -export function DistrictLabel(props: { district: string }) { - return {props.district} -} - -export function PartyLabel(props: { party: string }) { - const { t } = useTranslation("legislators") - - switch (props.party) { - case "Democrat": - return {t("party.democratic")} - case "Republican": - return {t("party.republican")} - default: - return ( - - {props.party} {t("party.party")} - - ) - } -} - -/** Social Media components **/ - -export function Bluesky() { - return ( - - - - ) -} - -export function LinkedIn() { - return ( - - - - ) -} - -export function Twitter() { - return ( - - - - ) -} diff --git a/components/legislator/LegislatorPage.tsx b/components/legislator/LegislatorPage.tsx deleted file mode 100644 index fbf6b9113..000000000 --- a/components/legislator/LegislatorPage.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import { doc, getDoc } from "firebase/firestore" -import { faChevronRight } from "@fortawesome/free-solid-svg-icons" -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" -import { useTranslation } from "next-i18next" -import ErrorPage from "next/error" -import { useCallback, useEffect, useState } from "react" -import styled from "styled-components" - -import { Col, Container, Row, Spinner } from "../bootstrap" -import { usePublicProfile } from "../db" -import { firestore } from "../firebase" -import * as links from "../links" - -import { - Bluesky, - DistrictLabel, - formatPhoneNumber, - LinkedIn, - PartyLabel, - Twitter -} from "./LegislatorComponents" -import { LegislatorSidebar } from "./SidebarComponents/LegislatorSidebar" -import { LegislatorTabs } from "./TabComponents/LegislatorTabs" - -import { useAuth } from "components/auth" -import { useFlags } from "components/featureFlags" -import { Internal } from "components/links" -import { FollowUserButton } from "components/shared/FollowButton" -import { CircleImage } from "components/shared/LabeledIcon" - -const DirectoryPath = styled.div.attrs(props => ({ - className: `align-items-center d-flex flex-nowrap ${props.className}` -}))` - font-size: 12px; -` - -const ButtonContainer = styled(Col).attrs(props => ({ - className: `col-12 justify-content-md-end ${props.className}`, - md: `3`, - sm: `4` -}))` - width: max-content; -` - -const HeaderBlock = styled.div` - background-color: white; - border: "1px #ced4da solid"; - border-radius: 5px; - margin-top: 8px; - padding: 16px; -` - -const HeaderName = styled.div` - font-size: 26px; - font-weight: 700; - color: #0b0a3e; -` - -const RoleLine = styled.div.attrs(props => ({ - className: `mb-2 ${props.className}` -}))` - color: #6c757d; - font-size: 14px; -` - -const PhoneNum = styled.span` - color: #6c757d; -` - -const SocialLine = styled.div.attrs(props => ({ - className: `d-flex flex-wrap mb-2 ${props.className}` -}))` - font-size: 12px; - text-decoration: none; -` - -const StatBlock = styled(Col).attrs(props => ({ - className: `d-flex col-4 flex-grow-1 ${props.className}`, - md: `2` -}))` - background-color: white; - border: 1px #ced4da solid; - border-radius: 5px; - margin-top: 4px; - padding: 16px; -` - -const StatLine = styled(Row).attrs(props => ({ - className: `text-nowrap ${props.className}` -}))` - font-size: 12px; -` - -const StatNum = styled.div.attrs(props => ({ - className: `mx-auto ${props.className}` -}))` - color: #1a3185; - font-size: 22px; - font-weight: 700; - width: max-content; -` - -export function LegislatorPage(props: { id: string }) { - const { t } = useTranslation("legislators") - const { result: profile, loading } = usePublicProfile(props.id) - const { legislators } = useFlags() - const { user } = useAuth() - - // eventually this should be replaced with a profile prop array that - // contains a list of courts the legislator served on - const viableCourts = "194" - - const [branch, setBranch] = useState("") - const [cosponsoredBills, setCosponsoredBills] = useState>([""]) - const [district, setDistrict] = useState("") - const [email, setEmail] = useState("") - const [party, setParty] = useState("") - const [phoneNumber, setPhoneNumber] = useState("") - const [sponsoredBills, setSponsoredBills] = useState>([""]) - - const memberData = useCallback(async () => { - const member = await getDoc( - doc( - firestore, - `generalCourts/${viableCourts}/members/${profile?.memberId}` - ) - ) - const docData = member.data() - - setBranch(docData?.content.Branch) - setCosponsoredBills(docData?.content.CoSponsoredBills) - setDistrict(docData?.content.District) - setEmail(docData?.content.EmailAddress) - setParty(docData?.content.Party) - setPhoneNumber(docData?.content.PhoneNumber) - setSponsoredBills(docData?.content.SponsoredBills) - }, [district, party, phoneNumber]) - - useEffect(() => { - profile ? memberData() : null - }, [memberData, profile]) - - if (loading) { - return ( - - - - ) - } - if (!legislators) { - return - } - if (!profile) { - return - } - - return ( - - - - {t("home")} - - -
{t("legislators")}
- -
{profile.fullName}
-
- - - - {""} - - - - - {profile.fullName} - - - - - {branch == "Senate" ? ( - {t("stateSenator")} - ) : ( - {t("stateRepresentative")} - )} - {/* · Town */} - - -
- - {/* Incumbent Label */} - -
- - -
- - {email} - -
- - {profile.website ? ( -
- · - - {profile.website} - -
- ) : ( -
- · - - {`malegislature.gov/Legislators/Profile/${profile.memberId}`} - -
- )} - - {phoneNumber ? ( -
- · - {formatPhoneNumber(phoneNumber)} -
- ) : ( - <> - )} - -
- {profile?.social?.twitter || - profile?.social?.linkedIn || - profile?.social?.blueSky ? ( - · - ) : ( - <> - )} - - {profile?.social?.twitter ? ( - - - - ) : ( - <> - )} - {profile?.social?.linkedIn ? ( - - - - ) : ( - <> - )} - {profile?.social?.blueSky ? ( - - - - ) : ( - <> - )} -
-
- - - - {user ? ( -
- -
- ) : ( - <> - )} - - {t("contact")} - -
-
- -
- - - ? - {t("termsServed")} - - - - - - {sponsoredBills?.length ? <>{sponsoredBills.length} : <>?} - - {t("billsSponsored")} - - - - - - {cosponsoredBills?.length ? ( - <>{cosponsoredBills.length} - ) : ( - <>? - )} - - {t("cosponsored")} - - - - - ? - {t("fundsRaised")} - - -
- - - - - - - - - -
- ) -} diff --git a/components/legislator/SidebarComponents/LegislatorSidebar.tsx b/components/legislator/SidebarComponents/LegislatorSidebar.tsx deleted file mode 100644 index 39f77168e..000000000 --- a/components/legislator/SidebarComponents/LegislatorSidebar.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Biography } from "./Biography" -import { OtherTestimony } from "./OtherTestimony" -import { UpcomingHearings } from "./UpcomingHearings" - -export function LegislatorSidebar() { - return ( - <> - Sidebar Components - - - - - ) -} diff --git a/components/legislator/TabComponents/Bills.tsx b/components/legislator/TabComponents/Bills.tsx deleted file mode 100644 index 61d6b5918..000000000 --- a/components/legislator/TabComponents/Bills.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function Bills() { - return
- Bills
-} diff --git a/components/legislator/TabComponents/District.tsx b/components/legislator/TabComponents/District.tsx deleted file mode 100644 index 06025fbff..000000000 --- a/components/legislator/TabComponents/District.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function District() { - return
- District
-} diff --git a/components/legislator/TabComponents/Elections.tsx b/components/legislator/TabComponents/Elections.tsx deleted file mode 100644 index c20356658..000000000 --- a/components/legislator/TabComponents/Elections.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function Elections() { - return
- Elections
-} diff --git a/components/legislator/TabComponents/Finance.tsx b/components/legislator/TabComponents/Finance.tsx deleted file mode 100644 index f1431f05f..000000000 --- a/components/legislator/TabComponents/Finance.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function Finance() { - return
- Finance
-} diff --git a/components/legislator/TabComponents/LegislatorTabs.tsx b/components/legislator/TabComponents/LegislatorTabs.tsx deleted file mode 100644 index 332f41747..000000000 --- a/components/legislator/TabComponents/LegislatorTabs.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { useTranslation } from "next-i18next" -import { TabPane } from "react-bootstrap" -import TabContainer from "react-bootstrap/TabContainer" -import styled from "styled-components" - -import { Container, Nav } from "../../bootstrap" - -import { Bills } from "./Bills" -import { District } from "./District" -import { Elections } from "./Elections" -import { Finance } from "./Finance" -import { Priorities } from "./Priorities" -import { Testimony } from "./Testimony" -import { Votes } from "./Votes" - -import { - StyledTabContent, - TabNavWrapper, - TabType -} from "components/EditProfilePage/StyledEditProfileComponents" - -const tabCategory = [ - "priorities", - "bills", - "elections", - "finance", - "district", - "testimony", - "votes" -] -type TabCategories = (typeof tabCategory)[number] - -const TabNavLink = styled(Nav.Link).attrs(props => ({ - className: `rounded-top m-0 p-0 ${props.className}` -}))` - color: #6c757d; - - &.active { - color: #1a3185; - font-weight: bold; - } -` - -const TabNavItem = ({ - tab, - i: i, - className -}: { - tab: TabType - i: number - className?: string -}) => { - return ( - - -

- {tab.title} -

-
-
-
- ) -} - -export function LegislatorTabs({ - fullname, - pageId, - tabCategory -}: { - fullname?: string - pageId?: string - tabCategory?: TabCategories -}) { - const { t } = useTranslation("legislators") - - const tabs = [ - { - title: t("tabs.priorities"), - eventKey: "priorities", - content: - }, - { - title: t("tabs.bills"), - eventKey: "bills", - content: - }, - { - title: t("tabs.elections"), - eventKey: "elections", - content: - }, - { - title: t("tabs.finance"), - eventKey: "finance", - content: - }, - { - title: t("tabs.district"), - eventKey: "district", - content: - }, - { - title: t("tabs.testimony"), - eventKey: "testimony", - content: - }, - { - title: t("tabs.votes"), - eventKey: "votes", - content: - } - ] - - return ( - - - - {tabs.map((t, i) => ( - - ))} - - - {tabs.map(t => ( - - {t.content} - - ))} - - - - ) -} diff --git a/components/legislator/TabComponents/Priorities.tsx b/components/legislator/TabComponents/Priorities.tsx deleted file mode 100644 index 534f54540..000000000 --- a/components/legislator/TabComponents/Priorities.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function Priorities() { - return
- Priorities
-} diff --git a/components/legislator/TabComponents/Testimony.tsx b/components/legislator/TabComponents/Testimony.tsx deleted file mode 100644 index 6f5c0e40b..000000000 --- a/components/legislator/TabComponents/Testimony.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useMemo } from "react" -import { useTranslation } from "next-i18next" -import styled from "styled-components" - -import { useAuth } from "components/auth" -import { SmartDisclaimer } from "components/bill/SmartDisclaimer" -import { usePublishedTestimonyListing } from "components/db/testimony/usePublishedTestimonyListing" -import { NoResults } from "components/search/NoResults" -import { TestimonyItem } from "components/TestimonyCard/TestimonyItem" - -const DisclaimerBlock = styled.div` - align-items: flex-start; - background-color: #f0f4ff; - border: "1px #d1d6e7 solid"; - border-radius: 5px; - color: #1a3185; - display: flex; - font-size: 13px; - gap: 10px; - line-height: 1.6; - margin-top: 14px; - margin-bottom: 14px; - padding: 12px 16px; -` - -const TestimonyBlock = styled.div` - background-color: white; - border: "1px #ced4da solid"; - border-radius: 5px; - font-size: 11px; - margin-bottom: 14px; - padding: 0px 16px; -` - -function Disclaimer({ fullname }: { fullname?: string }) { - const { t } = useTranslation("legislators") - - return ( - - - - - - -
- {fullname} {t("canSubmit")} -
-
- ) -} - -export function Testimony({ - fullname, - pageId -}: { - fullname?: string - pageId?: string -}) { - const { t } = useTranslation("testimony") - const { user } = useAuth() - - const testimony = usePublishedTestimonyListing({ - uid: pageId - }) - - const allTestimonies = useMemo(() => { - const legislatorTestimonies = testimony.items.result ?? [] - - // Combine and sort by publishedAt (newest first), then take 4 most recent - return [...legislatorTestimonies] - .sort((a, b) => b.publishedAt.toMillis() - a.publishedAt.toMillis()) - .slice(0, 4) - }, [testimony.items.result]) - - return ( - <> - - {allTestimonies.length > 0 ? ( -
- {allTestimonies.map(testimony => ( - - - - ))} -
- ) : ( - {t("viewTestimony.noTestimonies")} - )} - - ) -} diff --git a/components/legislator/TabComponents/Votes.tsx b/components/legislator/TabComponents/Votes.tsx deleted file mode 100644 index ecdfb5f01..000000000 --- a/components/legislator/TabComponents/Votes.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function Votes() { - return
- Votes
-} diff --git a/components/legislator/index.ts b/components/legislator/index.ts deleted file mode 100644 index 13355a639..000000000 --- a/components/legislator/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./LegislatorPage" diff --git a/pages/legislators/profile.tsx b/pages/legislators/profile.tsx deleted file mode 100644 index 2e3b29d76..000000000 --- a/pages/legislators/profile.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useTranslation } from "next-i18next" -import { useEffect, useState } from "react" -import { Spinner } from "react-bootstrap" - -import { LegislatorPage } from "components/legislator" -import { createPage } from "components/page" -import { createGetStaticTranslationProps } from "components/translations" - -export default createPage<{ court: string }>({ - titleI18nKey: "navigation.legislator", - Page: () => { - const { id, loading } = useProfileRouting() - const { t } = useTranslation("profile") - - return ( -
- {loading ? ( -
- -
- ) : id ? ( - - ) : ( -
{t("noLegislator")}
- )} -
- ) - } -}) - -const useProfileRouting = () => { - const [id, setId] = useState("od") - const [loading, setLoading] = useState(true) - - useEffect(() => { - if (window) { - const urlParams = new URLSearchParams(window.location?.search) - const urlid = urlParams.get("id") - - if (typeof urlid === "string") { - setId(urlid) - } - if (urlid !== undefined) { - setLoading(false) - } - } - }, [id]) - - return { id, loading } -} - -export const getStaticProps = createGetStaticTranslationProps([ - "auth", - "common", - "footer", - "legislators", - "profile", - "testimony" -]) From 2e18f8cd10456d25c269d36a8433a2b7611ebc00 Mon Sep 17 00:00:00 2001 From: mertbagt Date: Sat, 4 Jul 2026 16:04:27 -0400 Subject: [PATCH 022/106] tab containers --- .../LegislatorComponents.tsx | 8 +++++++ .../LegislatorProfilePage.tsx | 21 ++----------------- .../LegislatorProfile/LegislatorSidebar.tsx | 1 - .../SidebarComponents/Biography.tsx | 4 +++- .../SidebarComponents/OtherTestimony.tsx | 4 +++- .../SidebarComponents/UpcomingHearings.tsx | 4 +++- .../TabComponents/BillsTab.tsx | 4 +++- .../TabComponents/DistrictTab.tsx | 15 ++++++------- .../TabComponents/DistrictTabOld.tsx | 3 --- .../TabComponents/ElectionsTab.tsx | 4 +++- .../TabComponents/FinanceTab.tsx | 4 +++- .../TabComponents/PrioritiesTab.tsx | 4 +++- .../TabComponents/TestimonyTab.tsx | 4 +++- .../TestimonyTab_PendingProfileLink.tsx | 15 ++++--------- .../TabComponents/VotesTab.tsx | 4 +++- 15 files changed, 47 insertions(+), 52 deletions(-) delete mode 100644 components/LegislatorProfile/TabComponents/DistrictTabOld.tsx diff --git a/components/LegislatorProfile/LegislatorComponents.tsx b/components/LegislatorProfile/LegislatorComponents.tsx index e420630f4..1f25f37ab 100644 --- a/components/LegislatorProfile/LegislatorComponents.tsx +++ b/components/LegislatorProfile/LegislatorComponents.tsx @@ -19,6 +19,14 @@ export const formatPhoneNumber = (value: string) => { ` } +export const TabBlock = styled.div` + background-color: white; + border: 1px #b8c0c9 solid; + border-radius: 5px; + margin-bottom: 10px; + padding: 16px; +` + /** Party Labels **/ const DemocraticBubble = styled.div.attrs(props => ({ diff --git a/components/LegislatorProfile/LegislatorProfilePage.tsx b/components/LegislatorProfile/LegislatorProfilePage.tsx index 27bbc9434..9cf3805c1 100644 --- a/components/LegislatorProfile/LegislatorProfilePage.tsx +++ b/components/LegislatorProfile/LegislatorProfilePage.tsx @@ -61,7 +61,7 @@ const HeaderBlock = styled.div.attrs(props => ({ className: `d-flex flex-wrap justify-content-between ${props.className}` }))` background-color: white; - border: "1px #ced4da solid"; + border: 1px #b8c0c9 solid; border-radius: 5px; margin-top: 8px; padding: 16px; @@ -96,7 +96,7 @@ const StatBlock = styled(Col).attrs(props => ({ md: `2` }))` background-color: white; - border: 1px #ced4da solid; + border: 1px #b8c0c9 solid; border-radius: 5px; margin-top: 4px; padding: 16px; @@ -117,21 +117,6 @@ const StatNum = styled.div.attrs(props => ({ width: max-content; ` -// const TabButton = styled.button` -// background: transparent; -// border: 0; -// border-bottom: 5px solid transparent; -// color: #68707a; -// font-size: 1.35rem; -// font-weight: 700; -// padding: 1.35rem 1.9rem 1.15rem; - -// &.active { -// border-bottom-color: #18358f; -// color: #18358f; -// } -// ` - export function LegislatorProfilePage({ court, memberCode @@ -176,8 +161,6 @@ export function LegislatorProfilePage({ return } - console.log("district: ", district) - return ( diff --git a/components/LegislatorProfile/LegislatorSidebar.tsx b/components/LegislatorProfile/LegislatorSidebar.tsx index db445f89b..96cf42c9d 100644 --- a/components/LegislatorProfile/LegislatorSidebar.tsx +++ b/components/LegislatorProfile/LegislatorSidebar.tsx @@ -5,7 +5,6 @@ import { UpcomingHearings } from "./SidebarComponents/UpcomingHearings" export function LegislatorSidebar() { return ( <> - Sidebar Components diff --git a/components/LegislatorProfile/SidebarComponents/Biography.tsx b/components/LegislatorProfile/SidebarComponents/Biography.tsx index 90cf3f5e0..e190ac443 100644 --- a/components/LegislatorProfile/SidebarComponents/Biography.tsx +++ b/components/LegislatorProfile/SidebarComponents/Biography.tsx @@ -1,3 +1,5 @@ +import { TabBlock } from "../LegislatorComponents" + export function Biography() { - return
- Biography
+ return - Biography } diff --git a/components/LegislatorProfile/SidebarComponents/OtherTestimony.tsx b/components/LegislatorProfile/SidebarComponents/OtherTestimony.tsx index 8b3940014..4cf43f2b2 100644 --- a/components/LegislatorProfile/SidebarComponents/OtherTestimony.tsx +++ b/components/LegislatorProfile/SidebarComponents/OtherTestimony.tsx @@ -1,3 +1,5 @@ +import { TabBlock } from "../LegislatorComponents" + export function OtherTestimony() { - return
- Other Testimony
+ return - Other Testimony } diff --git a/components/LegislatorProfile/SidebarComponents/UpcomingHearings.tsx b/components/LegislatorProfile/SidebarComponents/UpcomingHearings.tsx index 69e68cb05..c3da92690 100644 --- a/components/LegislatorProfile/SidebarComponents/UpcomingHearings.tsx +++ b/components/LegislatorProfile/SidebarComponents/UpcomingHearings.tsx @@ -1,3 +1,5 @@ +import { TabBlock } from "../LegislatorComponents" + export function UpcomingHearings() { - return
- Upcoming Hearings
+ return - Upcoming Hearings } diff --git a/components/LegislatorProfile/TabComponents/BillsTab.tsx b/components/LegislatorProfile/TabComponents/BillsTab.tsx index 7ccfc2ebd..bde7d1cb7 100644 --- a/components/LegislatorProfile/TabComponents/BillsTab.tsx +++ b/components/LegislatorProfile/TabComponents/BillsTab.tsx @@ -1,3 +1,5 @@ +import { TabBlock } from "../LegislatorComponents" + export function BillsTab() { - return
- Bills
+ return - Bills } diff --git a/components/LegislatorProfile/TabComponents/DistrictTab.tsx b/components/LegislatorProfile/TabComponents/DistrictTab.tsx index a00f706b4..10365ca1c 100644 --- a/components/LegislatorProfile/TabComponents/DistrictTab.tsx +++ b/components/LegislatorProfile/TabComponents/DistrictTab.tsx @@ -1,6 +1,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" import { faLocationDot } from "@fortawesome/free-solid-svg-icons" import styled from "styled-components" +import { TabBlock } from "../LegislatorComponents" import type { District } from "components/db" const MapPreview = styled.div` @@ -12,8 +13,8 @@ const MapPreview = styled.div` ` const DistrictCard = styled.section` - border: 1px solid #d9dee5; - border-radius: 8px; + border: 1px #b8c0c9 solid; + border-radius: 5px; overflow: hidden; ` @@ -55,15 +56,11 @@ export function DistrictTab({ loading?: boolean }) { if (loading) { - return
Loading district...
+ return Loading district... } if (!district) { - return ( -
- District details are not available yet. -
- ) + return District details are not available yet. } const chips = subdivisionChips(district) @@ -71,7 +68,7 @@ export function DistrictTab({ return ( <> - +

{district.district} District

diff --git a/components/LegislatorProfile/TabComponents/DistrictTabOld.tsx b/components/LegislatorProfile/TabComponents/DistrictTabOld.tsx deleted file mode 100644 index b2a70fa19..000000000 --- a/components/LegislatorProfile/TabComponents/DistrictTabOld.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function DistrictTab() { - return

- District
-} diff --git a/components/LegislatorProfile/TabComponents/ElectionsTab.tsx b/components/LegislatorProfile/TabComponents/ElectionsTab.tsx index 6fea4cfbf..1bb6bfccf 100644 --- a/components/LegislatorProfile/TabComponents/ElectionsTab.tsx +++ b/components/LegislatorProfile/TabComponents/ElectionsTab.tsx @@ -1,3 +1,5 @@ +import { TabBlock } from "../LegislatorComponents" + export function ElectionsTab() { - return
- Elections
+ return - Elections } diff --git a/components/LegislatorProfile/TabComponents/FinanceTab.tsx b/components/LegislatorProfile/TabComponents/FinanceTab.tsx index 95f840dbb..4c263d26a 100644 --- a/components/LegislatorProfile/TabComponents/FinanceTab.tsx +++ b/components/LegislatorProfile/TabComponents/FinanceTab.tsx @@ -1,3 +1,5 @@ +import { TabBlock } from "../LegislatorComponents" + export function FinanceTab() { - return
- Finance
+ return - Finance } diff --git a/components/LegislatorProfile/TabComponents/PrioritiesTab.tsx b/components/LegislatorProfile/TabComponents/PrioritiesTab.tsx index 33c98c008..000d6d564 100644 --- a/components/LegislatorProfile/TabComponents/PrioritiesTab.tsx +++ b/components/LegislatorProfile/TabComponents/PrioritiesTab.tsx @@ -1,3 +1,5 @@ +import { TabBlock } from "../LegislatorComponents" + export function PrioritiesTab() { - return
- Priorities
+ return - Priorities } diff --git a/components/LegislatorProfile/TabComponents/TestimonyTab.tsx b/components/LegislatorProfile/TabComponents/TestimonyTab.tsx index e00fd7e0d..eb3bc6e04 100644 --- a/components/LegislatorProfile/TabComponents/TestimonyTab.tsx +++ b/components/LegislatorProfile/TabComponents/TestimonyTab.tsx @@ -1,3 +1,5 @@ +import { TabBlock } from "../LegislatorComponents" + export function TestimonyTab() { - return
- Testimony
+ return - Testimony } diff --git a/components/LegislatorProfile/TabComponents/TestimonyTab_PendingProfileLink.tsx b/components/LegislatorProfile/TabComponents/TestimonyTab_PendingProfileLink.tsx index da1e672a3..12b8174fd 100644 --- a/components/LegislatorProfile/TabComponents/TestimonyTab_PendingProfileLink.tsx +++ b/components/LegislatorProfile/TabComponents/TestimonyTab_PendingProfileLink.tsx @@ -2,6 +2,8 @@ import { useMemo } from "react" import { useTranslation } from "next-i18next" import styled from "styled-components" +import { TabBlock } from "../LegislatorComponents" + import { useAuth } from "components/auth" import { usePublishedTestimonyListing } from "components/db/testimony/usePublishedTestimonyListing" import { NoResults } from "components/search/NoResults" @@ -22,15 +24,6 @@ const DisclaimerBlock = styled.div` padding: 12px 16px; ` -const TestimonyBlock = styled.div` - background-color: white; - border: "1px #ced4da solid"; - border-radius: 5px; - font-size: 11px; - margin-bottom: 14px; - padding: 0px 16px; -` - function Disclaimer({ fullname }: { fullname?: string }) { const { t } = useTranslation("legislators") @@ -85,13 +78,13 @@ export function Testimony({ {allTestimonies.length > 0 ? (
{allTestimonies.map(testimony => ( - + - + ))}
) : ( diff --git a/components/LegislatorProfile/TabComponents/VotesTab.tsx b/components/LegislatorProfile/TabComponents/VotesTab.tsx index 3c6625cd2..09e0558a4 100644 --- a/components/LegislatorProfile/TabComponents/VotesTab.tsx +++ b/components/LegislatorProfile/TabComponents/VotesTab.tsx @@ -1,3 +1,5 @@ +import { TabBlock } from "../LegislatorComponents" + export function VotesTab() { - return
- Votes
+ return - Votes } From f4780c115c684b2752295361b188b2556ca5e006 Mon Sep 17 00:00:00 2001 From: mertbagt Date: Sat, 4 Jul 2026 16:11:10 -0400 Subject: [PATCH 023/106] resposive margings --- components/LegislatorProfile/LegislatorProfilePage.tsx | 6 +++--- components/LegislatorProfile/LegislatorTabs.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/LegislatorProfile/LegislatorProfilePage.tsx b/components/LegislatorProfile/LegislatorProfilePage.tsx index 9cf3805c1..2f8d127e2 100644 --- a/components/LegislatorProfile/LegislatorProfilePage.tsx +++ b/components/LegislatorProfile/LegislatorProfilePage.tsx @@ -349,14 +349,14 @@ export function LegislatorProfilePage({
- - + + - + diff --git a/components/LegislatorProfile/LegislatorTabs.tsx b/components/LegislatorProfile/LegislatorTabs.tsx index b969ad04c..40c264453 100644 --- a/components/LegislatorProfile/LegislatorTabs.tsx +++ b/components/LegislatorProfile/LegislatorTabs.tsx @@ -113,7 +113,7 @@ export function LegislatorTabs({ ] return ( - + {tabs.map((t, i) => ( From eae3474c612a6405d95aa97daaf6d843bb02b6e4 Mon Sep 17 00:00:00 2001 From: "Nathan E. Sanders" Date: Tue, 7 Jul 2026 13:32:30 -0400 Subject: [PATCH 024/106] fix: add getStaticProps to token page and wire prod MCP env (#2176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pages/dev/token.tsx was missing getStaticProps, so next-i18next never loaded the common/auth/footer namespaces — Navbar and Footer rendered raw translation keys (e.g. navigation.bills, logInSignUp). Also adds MCP_SERVER_URL to the prod functions env so the mcpProxy Firebase Function can reach the Cloud Run MCP server. Co-authored-by: Claude Sonnet 4.6 --- functions/.env.digital-testimony-prod | 3 ++- pages/dev/token.tsx | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/functions/.env.digital-testimony-prod b/functions/.env.digital-testimony-prod index 7e8fc71cc..dc012488e 100644 --- a/functions/.env.digital-testimony-prod +++ b/functions/.env.digital-testimony-prod @@ -1,2 +1,3 @@ TYPESENSE_API_URL=https://yyd73lsw3h.execute-api.us-east-1.amazonaws.com/search -FUNCTIONS_API_BASE=https://us-central1-digital-testimony-prod.cloudfunctions.net \ No newline at end of file +FUNCTIONS_API_BASE=https://us-central1-digital-testimony-prod.cloudfunctions.net +MCP_SERVER_URL=https://maple-mcp-server-652493556525.us-central1.run.app \ No newline at end of file diff --git a/pages/dev/token.tsx b/pages/dev/token.tsx index 4b5186d80..074bb7012 100644 --- a/pages/dev/token.tsx +++ b/pages/dev/token.tsx @@ -13,6 +13,7 @@ import { useAuth } from "components/auth" import { createPage } from "components/page" +import { createGetStaticTranslationProps } from "components/translations" import React, { useState, useCallback } from "react" function TokenPage() { @@ -146,6 +147,12 @@ export default createPage({ Page: TokenPage }) +export const getStaticProps = createGetStaticTranslationProps([ + "auth", + "common", + "footer" +]) + // ── Styles ──────────────────────────────────────────────────────────────────── const styles: Record = { From 15543fd97de4975df345b723de492f17d582c9b7 Mon Sep 17 00:00:00 2001 From: mertbagt Date: Tue, 7 Jul 2026 19:03:29 -0400 Subject: [PATCH 025/106] added translations --- components/LegislatorProfile/TabComponents/DistrictTab.tsx | 7 +++++-- public/locales/en/legislators.json | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/components/LegislatorProfile/TabComponents/DistrictTab.tsx b/components/LegislatorProfile/TabComponents/DistrictTab.tsx index 10365ca1c..19afb1418 100644 --- a/components/LegislatorProfile/TabComponents/DistrictTab.tsx +++ b/components/LegislatorProfile/TabComponents/DistrictTab.tsx @@ -1,5 +1,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" import { faLocationDot } from "@fortawesome/free-solid-svg-icons" +import { useTranslation } from "next-i18next" import styled from "styled-components" import { TabBlock } from "../LegislatorComponents" import type { District } from "components/db" @@ -55,12 +56,14 @@ export function DistrictTab({ district?: District loading?: boolean }) { + const { t } = useTranslation("legislators") + if (loading) { - return Loading district... + return {t("loading")} } if (!district) { - return District details are not available yet. + return {t("districtDetails")} } const chips = subdivisionChips(district) diff --git a/public/locales/en/legislators.json b/public/locales/en/legislators.json index fecb79040..886f6a1ba 100644 --- a/public/locales/en/legislators.json +++ b/public/locales/en/legislators.json @@ -3,9 +3,11 @@ "canSubmit": "can submit testimony on any bill or ballot question, just like any constituent. Her stance and reasoning are shown exactly as submitted — MAPLE does not edit or editorialize.", "contact": "Contact", "cosponsored": "Cosponsored", + "districtDetails": "District details are not available yet.", "fundsRaised": "Funds Raised", "home": "Home", "legislators": "Legislators", + "loading": "Loading district...", "party": { "democratic": "Democratic Party", "party": "Party", From 5c6bcb1a1de23a5d1f8f436bbf31971d8022fb27 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 7 Jul 2026 20:20:31 -0400 Subject: [PATCH 026/106] refactor: replace _total_salary_ sentinel with legacyTotalCompensation field Pre-2009 filings report compensation as a single entity total with no per-client breakdown. Previously this was stored as a fake client entry with clientName="_total_salary_", which leaked into client browse lists and required filtering at every consumer. Store it instead as legacyTotalCompensation on the registrant document, leaving the clients[] array empty for those filings. Update the TypeScript type and all tests accordingly. Co-Authored-By: Claude Sonnet 4.6 --- functions/src/lobbying/types.ts | 1 + .../__pycache__/portal.cpython-37.pyc | Bin 16091 -> 16141 bytes lobbying-scraper/portal.py | 7 ++++-- .../tests/__pycache__/__init__.cpython-37.pyc | Bin 166 -> 141 bytes ..._portal_parser.cpython-37-pytest-7.4.4.pyc | Bin 17098 -> 18283 bytes lobbying-scraper/tests/test_portal_parser.py | 20 +++++++++++++----- lobbying-scraper/writer.py | 1 + 7 files changed, 22 insertions(+), 7 deletions(-) diff --git a/functions/src/lobbying/types.ts b/functions/src/lobbying/types.ts index 83eaab761..7181fd68d 100644 --- a/functions/src/lobbying/types.ts +++ b/functions/src/lobbying/types.ts @@ -37,6 +37,7 @@ export const LobbyingRegistrant = Record({ generalCourt: Number, regType: Union(Literal("Lobbyist"), Literal("Employer")), clients: Array(LobbyingClient), + legacyTotalCompensation: Null.Or(Number), disclosureUrls: Array(String), fetchedAt: InstanceOf(Timestamp) }) diff --git a/lobbying-scraper/__pycache__/portal.cpython-37.pyc b/lobbying-scraper/__pycache__/portal.cpython-37.pyc index d515175f364a6ecf06fd72e46412580c29dbe6e1..0bc74351acbc0573fde91df55339d5ba00fbce9c 100644 GIT binary patch delta 2722 zcmb7GYitx%6y7_#-7Z^}mOf|~Xz2^vX}j$%1=>>D+LnS~DbNzyf3 zQXUG50gI(aQBeUEFc=dsMH7E$@E5BxZ)AeQKn0O(d$Ct1N#+?@_x46fIl^!ZKX$1`uZagMd7u$mED$WT{PS$EaG)Y;SUqk6EvW# ze>`uZbv8jGyg4y5JF#01Idsrpp_+|Dz zJnC;{Q8=ETHgkPg-(Z-ksegyuV~KG~d;oWw#zn%0*@>pC!bA#>#uE99|7*raKb>44 zvCD9N>M3Tx(aaon1x{zqUhy1?w#$q^L&wK+5f*P-Q+;dWBg*pDW}(_nC>dzE)`a^s zzLA*6{1S&ytqJF>c&;1$5z~ODL#ePXD~+9nZCPb(7o5oY6SMlxw8nYckeQC0MM4|_ z!}|h0EKiSaDyF{CM8a+&NO}bMgmOPIPbQ$qULTdS8LNJb_GCGK76GNU(|U)!HfT-m zaUpw4!ShH<1U)A>?xyo_AZO~-65_2QApdv{g6y$R*WjC+TE8%aY_NPWl^eQZ^!N6v zeBc@ca^=Z;iQkUAl`!^35j6?1+)y&T7U4xRMRrDDG*>Q~$}o{**BOxIt|o!(qLu>S z&U>PO!gZCE$Sw+WETVSnB8*m?=qSQ^5`f)#Ma9{q+HL?Dcyl~-gEU5b;n597;ce#9?L>z~RK`l}Qof)4HX1iQ zH;$cx!lE+&@StNI)0kGLId?cT*O0V%`r!28IDQslMd8e|X4;@M=p1}VYZ!92IqaR5 zHzZ-tY#hB>blk-Tp|>IgXUZ~)$aRrQEQ!rG)^=`1U6A>UZ1XdNA+`;c1gndw#*m-d z3_43CVM(TTuZn1akAjs+2a$u*z2~Tj?)tUhljU}eS`ybSx1p=Nd+mRi&}o}69GYM8 zAQmfv9nZRTBgpWQyA9{UwtLjF2Ih?2b>cQ{Lq!%-E?&nU`b%zGaYif>rYmK8Z^Z8O z-bAiJs4ee*pb&4h{rjQ3uI)km+kV48PPXwQ1V<4zC3WQ&c6MnctBSiyyRwh6Ru5u0ve#>;VJRu%TrpJrvV_eW-5{y!-@30W1nj6;1zF`J1MUSIei zvAlri!s1|!a<}96Esje!J$LXEXf(PiF1kw+UWFu;O|+jk0}ks?SQVQTR@R76Kakd2zG-~b1yNgz0bkK0H+xG#OS8Fma Md{R5jxg^#7FMy0?fB*mh delta 2673 zcmb7GYitx%6y7_#oo;v8WlLLWi?p;|EYn@OU5hP`w%8U5)$%AULQ8d6cZV`$ySu$J zTiz7$L69KALE;O+XEgpGq-uwfTG=2mabmv~0TVl=*9o~9Zk3-3Yecm;a;s*aD1-{NJgq4%>WEWcZ zND!O!RM{pZ4T-j6ybh61K~N;i$zz*&7)pI7Srk6-MT%0$H-ulzE&hJS8^G@`XW_A0 z|6ayI@W$koH6tYEM{wkNm<#_B3$Q7SFF=msa| zfs2767KVcSzGxHj``xOV$|jSl%A0Yi$d+qUT2Bc-c?)v*LW0`~T2a5$yNJjc1Z|Mt zRM3#OK~L(bK~+!eN2cLMaJXGeW`z>(Kzdt%Yr|R1Fpb(C%`o(IN@3`pcgpwm$4Eg$ zgCWZp3;jJ3Ux9osBPdQQ*RYi=XO<^{FTnRmHDlTrnwK*{}hF0#lsenzLjhu6@At(~3Lh&AgUNEPQ&b*7rH3Vx3 z`Uv_FELSFNn7otL>!6^xGDLpzI6(qIk+1A#8`bF527*D#S8k3<7T$r;;*fucG$hMq zq_Y{!$(zNGutr!?qOdPuxMUSei3EGp5nbDCm@!lP9OcLBDNB3{uD0bRb;DeTF5Qjw z7lDq~-&cB;4Uc_OS}n0(!8`pO#^J;173>fAd3r??SwEW-A|XJx-) zznv*>Uo?v1wCNmD8zy5C--{2+J*b(gsckWl@h~wYEs1i%dSr+9$PVku?4GX zy_?`Z1k~C?^!+x?iKYO#(7j^dLFD;ie??vEf5LZ7I5t62y9EKeCPegH0t%d05Z7%x z6^9EIEhWM~a?SE(V%*SFBRez{<6AF4Po+}&D9PI;vApANRMEq5v@$w{UW>e=_o8fv zMCN{~yva9>VU@>+#$lLTHC3k2u|L99_f%8f&a<%MqMT=vvA8DkY2|E4remh?7cIu& zR8>u=oDA7Rr5I0^n})#SWGa`S8UaPO6CDOQc+j&G$yunwcfs*sty|Qa*(Fsx>LAw> zD#uw^J;6UZ!WgAUh3JVeX(d?cc(> zC>w?0x&6sJ@)rnR zMA#M>2+mqJkOMT|@#BplR*x;$Iw4bb62B9{-XP8!7WIdJ19k9|=%Z3*-~Kf1I2{== zQY>duOIdO(lhJUx<`bJvr{$w*t7f=1qNlTl=mXy1sKwWG>sljRY?d949zIa1_DH z8RT@CJO;nd4F{<-R*r6JLxxq*wRlNud!O37qPMk6ZSU$_yu4Sb<|(M1_c&gfr{^`Y zJK@s2ZZ;pf=l9AX3gIt!b^f0fF6MXgGiYl3Dz)4R-H3}>Hh$z!FPIykJLK{e^ghz? z$)q9(HWjl!p}DC-?nV}lnMBj^peP~Qy5hMQwXc%_i3`DZ#W0-TTp(R!u%dakSA2>H zg|)D^c~&=Fz?Sn~V~+Svum{?`w8i}d4-qUQpgY^X$JZm(@9F4V)zho?;Z4Uep$;A) zcpCu%Ey1=rS~U=eOt%x&K_I?JC_+y6p?y8>BT9_%!$gU}dJINdOqK^fwv;=U70Ukr Dj0j^* diff --git a/lobbying-scraper/portal.py b/lobbying-scraper/portal.py index 3a5528898..cf871328e 100644 --- a/lobbying-scraper/portal.py +++ b/lobbying-scraper/portal.py @@ -98,6 +98,7 @@ class DisclosureMeta: class DisclosureDetail: compensation: list[Compensation] = field(default_factory=list) bills: list[BillActivity] = field(default_factory=list) + legacy_total_compensation: Optional[float] = None # ── Derived-value helpers ───────────────────────────────────────────────────── @@ -493,8 +494,10 @@ def parse_disclosure_detail(soup: BeautifulSoup, year: int) -> DisclosureDetail: if amt: total += amt if total: - compensation.append( - Compensation(client_name=LEGACY_TOTAL_CLIENT, amount=total) + return DisclosureDetail( + compensation=compensation, + bills=bills, + legacy_total_compensation=total, ) return DisclosureDetail(compensation=compensation, bills=bills) diff --git a/lobbying-scraper/tests/__pycache__/__init__.cpython-37.pyc b/lobbying-scraper/tests/__pycache__/__init__.cpython-37.pyc index 33712a9e9d1c30a3e9f3117c1aeee0bc732287a3..67ade69f9ae1ba5adf35d541eb89bda023e75139 100644 GIT binary patch delta 42 wcmZ3+*vrW6#LLUY00hc?z7x4Egw6Czatrix6AN-ubqg{JQgbr%Cgz(10M88z)c^nh delta 67 zcmeBWT*k=l#LLUY00bNY_7k}+bo})*@^e%5^HPiTUGkGlb5rw5iuFBwg5#aMo#Wk7 Vb8_;_i!(9{^m7vnawdkE0{}yO6f*z- diff --git a/lobbying-scraper/tests/__pycache__/test_portal_parser.cpython-37-pytest-7.4.4.pyc b/lobbying-scraper/tests/__pycache__/test_portal_parser.cpython-37-pytest-7.4.4.pyc index e18d528fef20c09e455674b6193d29d211ecb5a7..496575575c6adf4c3bff11a411d59b0d5bb30a22 100644 GIT binary patch delta 4613 zcmb_gYiv}<6~1%#{qWitVgn{{6JoEg?Om^*yI}m}Q9{6kxRhjhENkCud+qhz&D?8X z&90V!kpgi7$u!Cjh>EFHsTHD)Hj30g?Vn1ODz#02RFyWWnpCY6R3V}M>?7?tbM5uo zF$7gz>wbIY%-orC&YbhjtbcWt{Ouj$8>*;q3-DR^;KrWP zr9ts4jfy;Bi~3d1)U$WCD^03@QB<0hmU-a{i8d+$B?x0>rBw;Rcazelw8M9^(xG(1 zcZ(v92y$0po?NnQ9B4IW(ycS`bW#nZ6KOS>& z6Y;T3%t#sWWNa)olU6l7ZX{BgY%j7u+fR}r>vQY}oOir0J|KlU=Ort&ZhM;mf9chp zX~BsD2tkBagb+JaahEi)hZWx^T?{<-G^4s3A;LcP`NKueKspnP>$*yfT}AIoqphvH ztJCZnPtlpU(biF9)s-hnglU!i8AGi%>)iX31!He^T(b5++J+Ko#M zAnZZtMd(B5M;JsH0+8KwAIkdyqGD_e9<393A=ZXq5QSVJig-oL30YxE%#jHpCuT`joECGItVLfi zEO)F5NaIAYqJCUlv??~kcE`RbOxyKg=yzoE!Xlxsg6PbGq=_&9WwJ>4x41|Y`)x~& zkh3Zd#hJAx2*@_W1-NOiActi9a|5!6OqYLw?9)u9JlVb))6)xN}l(uTl1xLomvta4sXAGgw?5y5vpcwT^i`12#7=~6+6GP9j?e)xS6OCpTPK*@ z30m1lZ8)MDVKV}+*dCOgSh2+|+?D)#lts}fx(h(|n$`OqR2%{*CzTGfVUkKWJ+h{| zt9)#j9>ghLM0g3|FhZ4(=LoO?+QprQsvEJT7~_tw!>9P7j(Z|ld;|%^3C}Q)hEMQ9 z8#PvFIYC2yr%DM!8cu0yP`1b- z-3IYWdEw{dYL-;mKpl<>YL?78ymebqtXdEs4?wLP|(wbzNPvw892rowgj6>SBO;bFA>X(VmAvMm3$f?|bLzabM>0>}xy3rTaCh%r?3 zTL{w#Nq}hit*@Q4G@U8wm#%!>GzKc>k&Y9E#ib6CW?!{#DcqOZ2^nKwJhwnnOnZLT zZtgsL@chtOGhNE72syLN_VqS@0Ed(uknQG{&!RSg!0n$x=`?$5XXIJ*LdU}k*}Mv_ zRmkQCgc{8JbA``$z97nYaF<<-F&S5-b_4>B+c5lIu!ikzXm!1e4afwuWNM&wfqk#x zVxCWgXDodUK(1K3KPzdyCZoBdC8x||HiJ{RO!v4`XK><`Bhrj}n5Iz8kIxOr8|6o4 z7rW~Z6P*?Pev)Nk<1SLi_B1w=9Gh%xu;=i_I`-Yh9#DS@>U`l}jW;YrW(zF`{i}Uh z6%V*!Pz(|faSIYLs|i$-{Y(zr+QkdquEl0)Qh!WBV|A>WZWl}!X8#V%keh5eI7Tw; zx4~!2DB#Jl!XE_m4S;AR-rbTZeU_?xeUlw{7%NS#b>Z033-mHJFCp;oVf5%lggHT| zUCJbkw^X30h`?{kr%be|nP%soi za^N3|`2^_{hnWIQ>8RcJl!*e_zhXJBS)Lp1jZg+Hs6r<)!#?X9PF*TDZUd zsVrp=!@>WLC9z15+-C1Y#>n-;-y^>!^{=DDBE=(#QiQ4!;1Ngh6#60i%kDRyYUGdD zyFCd)*{A(G;YaMF-2rxZPq>C}ym>1(kA1AkVRoyhuXbb@7GJ=H-UcWc0u$7Z-v^D3 zzr3w@o~&GbIW^&&Ww&0wP3!$wHCZWYgBymfM(w()#SOJ=uP(Cb-fr>(cDJ{g+$j8} z*G|ZJ_WQoWPv!2%>_C660?l7#Ir)Tr*{|oL#l{&Z>QXPLW3xu$+$uLP9ILmE z6$kv`k3es7F5EYObBT|$?jGdan`n2S=!nIX)L1N5bdJs@l14()=^hpt>>#^Ya&Q~` zN8;Mx^}|PTe*EQ5&mxQ>Xb5S9a|l_0XvN6!BS!{bc`degaAf}ooyU%=2-gvAAlzip dp$2l0y)m?Hqgy1)e|Bq)v&QRmvin2Z{tG!!GbR84 delta 3111 zcmb_eZA@F&8NTQG0|UkoMoa)3-wkYYu?_wJY?@HamuZ@ej-_cEXYH-=y~Iw8?VM}U z*nznpNn5q)y7pD1N{uw=)>i5?ZIORW+K*1!&q@1{N!2t@lQOA)s{NVvXR4-dJMX=j zF$_&ini0=EU-v!l`@GM2&;9fF$ggh^+k8hy8^?aXdM1APPtJF2C&)ARyuBnLyJe5; zy<|#yl=jQcok4j-@$B-lD39LcULi@Z?34XBx#Wl(kjL0ll*i>DdydK>In16unLo#g zk=kwYqQQS*JX6?E#`B6gzF0_aZYcSZI{w(%bJF~o1u3EAa)lSvOtv__kt*hDE>n(l z#b5Y){0qX|`hsUriNO12SMO$CR;VDA3Z+y|Qd7AUElZ*~3BNaABuSWTSz>sjjjRXOQHGyd^T$PIhK?fm5c~)MnCtj}c;WMo9}WlEK;5*K&E-_Ft8Q4&UauR= zN{Z4ETp2=$z>jTSTe3Y~EK4a>RcL8C2@h=jok=SWtrzG2ZNIV z1~DO%QiXg$C@PG^*Lr%U(}j(qu%2S<32UkJ6`@oRN*P69iO!bFZ36r0`C=0+d4gIh zP(>D4Lg6#V*X=?&mt{-Gl5Na*RFUMYn$G?XOwK5I3}&1z(`m-S3Z$IyjH(qAeS)oqUpmKz8oJ*X>!he^ z=U@e?g08`9KJ5?~RHekCKkUG)GJ`aL5Qck$^7;Wv4rsX{V~TcZ5&<~ci`C} z2XR4VDCt1QMAMCG}V}RSYM`+O*)Ih-Qn}B!u@qPLS!rnwOMzVn4e}ZW&Z%jX8Yj# zo;*?DpPo&#E&`}{=by*ww<21b!g`dAGzFu1e;^{QTs3kkYO3dG7GG>6kv@yv=fE)% zbE4;aie;4q6`SDfsMyQ3t0Tj_`+&w!Ui%apV+tLEUyPn^U1n3LQ@tG6SuYPk-;F9- zzylkm-(oGXqbVdUQ~y(bHqb=tUVd~H>Is?D@NgilB9@YC>f*Yjm!lZc;_tS3lz#gPr=N)rxByFVJh)Eg@*kHHbq>d-W2a zQ>-0|MO?T90}~>t!qUVlxvr`EXA?U-@xW?ywrf9C>>6SL4q<>5({OMs7`()q+K-}k zL{jIos>a$}#0_6XPb?lWW+wDKhc({{%pQH{;Dwl*yb0CVDyh`&$5suRbFtnQ+|sDm zl}0*sA4wQqJ2m$I+5O8?W8_^JnMsps?e@%Xh+DyZdD?>>t|N3c;25?}Gxz~~clPxo zt=s`$Jll7e9Xx;^z)#}EZY|@6=15_DZh7=2-0fus|IHh;@*~)p>lL-~gQl9khqU}0 z{3wD4bN#lxd}t>x)ME~F)?Zdu#R=1SST zN~7@GrO2R0g_SL;8`QE|Zz-lqnOt^_@@$BDVJPv2D}l`wgr^YJ5M+eQ2t~M`_}cO7 k*sLH_5pE*ff-e&uk^o`Zam>P#fB%_{c8lE#Pb@qB1#ynGa{vGU diff --git a/lobbying-scraper/tests/test_portal_parser.py b/lobbying-scraper/tests/test_portal_parser.py index 03f73aaee..7bf5bb371 100644 --- a/lobbying-scraper/tests/test_portal_parser.py +++ b/lobbying-scraper/tests/test_portal_parser.py @@ -41,8 +41,10 @@ def _comp_total(detail) -> float: # ── Disclosure parsing ──────────────────────────────────────────────────────── # (fixture, year, expected_comp, n_clients, n_bills, era_label) +# expected_comp is the sum of detail.compensation (per-client entries only). +# 2005-2008 era has no per-client breakdown; see test_legacy_2007_total_compensation. DISCLOSURE_CASES = [ - ("2007e", 2007, 112_500.00, 1, 2, "legacy 2005-2008: entity total under _total_salary_"), + ("2007e", 2007, 0.0, 0, 2, "legacy 2005-2008: entity total in legacy_total_compensation"), ("2011e", 2011, 641_243.00, 23, 4, "legacy 2009-2013: per-client Compensation received column"), ("2016e", 2016, 990_474.00, 30, 1357, "hybrid 2014-2018: Panel1 div totals"), ("2024e", 2024, 115_000.00, 5, 22, "modern 2019+: grdvClientPaidToEntity"), @@ -71,11 +73,19 @@ def test_no_total_amount_artifact(fix, year, _c, _n, _b, _e): assert not bad, f"{fix} produced summary-row artifacts: {bad}" -def test_legacy_2007_uses_total_salary_placeholder(): - """2005-2008 has no per-client comp column; comp falls back to the entity - salary total stored under the _total_salary_ placeholder client.""" +def test_legacy_2007_total_compensation(): + """2005-2008 has no per-client comp column; the entity salary total is stored + in legacy_total_compensation with an empty per-client compensation list.""" detail = parse_disclosure_detail(_soup("2007e_disc"), 2007) - assert [c.client_name for c in detail.compensation] == ["_total_salary_"] + assert detail.compensation == [] + assert detail.legacy_total_compensation == pytest.approx(112_500.00, abs=1.0) + + +def test_non_legacy_has_no_legacy_total_compensation(): + """Only 2005-2008 filings set legacy_total_compensation; all other eras leave it None.""" + for fix, year in [("2011e", 2011), ("2016e", 2016), ("2024e", 2024)]: + detail = parse_disclosure_detail(_soup(f"{fix}_disc"), year) + assert detail.legacy_total_compensation is None, f"{fix} should not set legacy_total_compensation" def test_legacy_2011_is_per_client_not_placeholder(): diff --git a/lobbying-scraper/writer.py b/lobbying-scraper/writer.py index d49d12424..fefd81aaf 100644 --- a/lobbying-scraper/writer.py +++ b/lobbying-scraper/writer.py @@ -61,6 +61,7 @@ def write_registrant( "generalCourt": year_to_general_court(meta.year), "regType": meta.reg_type, "clients": clients, + "legacyTotalCompensation": detail.legacy_total_compensation, "disclosureUrls": firestore.ArrayUnion([disc_url]), "fetchedAt": _now(), } From e9bbdcd81653403b384903f9797173d30a92f429 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 7 Jul 2026 20:32:53 -0400 Subject: [PATCH 027/106] test: add writer unit tests and fix types.ts formatting Adds mock-based tests for write_registrant and write_filings covering: - legacyTotalCompensation is None for modern filings - legacyTotalCompensation is set correctly for pre-2009 filings - clients list structure and normalization - no-op guards (empty entity_name, None year) - write_filings bill count and empty-list short-circuit Co-Authored-By: Claude Sonnet 4.6 --- .../__pycache__/normalize.cpython-37.pyc | Bin 1412 -> 1387 bytes lobbying-scraper/tests/test_writer.py | 143 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 lobbying-scraper/tests/test_writer.py diff --git a/lobbying-scraper/__pycache__/normalize.cpython-37.pyc b/lobbying-scraper/__pycache__/normalize.cpython-37.pyc index 94efb861e98ad8f45f5a0ec1065e5d0290dfb499..06bd603aac6b45e259ab6d0ad3296bc153a7bcbd 100644 GIT binary patch delta 45 zcmZqSe$B=0#LLUY00hc?z8ksSS%eMsOL7bJa}x`4QgsV53sQ43^ETJ9urUGv{ILvI delta 70 zcmaFO)xyo~#LLUY00e%Wb{o0fS#-SgGxBp&_487T^ dict: + """Return the dict passed to the most recent doc_ref.set() call.""" + assert doc_ref.set.called, "set() was never called" + args, kwargs = doc_ref.set.call_args + return args[0] + + +def _meta(entity_name="Acme Lobbying LLC", year=2024, reg_type="Employer"): + return DisclosureMeta(entity_name=entity_name, year=year, reg_type=reg_type) + + +# ── write_registrant ────────────────────────────────────────────────────────── + + +def test_modern_registrant_has_null_legacy_total(): + """Modern filings with per-client compensation must write legacyTotalCompensation=None.""" + db, doc_ref = _make_db() + detail = DisclosureDetail( + compensation=[ + Compensation(client_name="Client A", amount=50_000.0), + Compensation(client_name="Client B", amount=30_000.0), + ], + bills=[], + legacy_total_compensation=None, + ) + with patch("writer.firestore.ArrayUnion", side_effect=lambda x: x): + write_registrant(db, _meta(), detail, "https://example.com/disc") + + data = _captured_data(doc_ref) + assert data["legacyTotalCompensation"] is None + assert len(data["clients"]) == 2 + assert data["clients"][0]["clientName"] == "Client A" + assert data["clients"][1]["clientName"] == "Client B" + + +def test_legacy_registrant_writes_legacy_total(): + """Pre-2009 filings with no per-client breakdown must write legacyTotalCompensation.""" + db, doc_ref = _make_db() + detail = DisclosureDetail( + compensation=[], + bills=[], + legacy_total_compensation=112_500.0, + ) + with patch("writer.firestore.ArrayUnion", side_effect=lambda x: x): + write_registrant(db, _meta(year=2007), detail, "https://example.com/disc") + + data = _captured_data(doc_ref) + assert data["legacyTotalCompensation"] == pytest.approx(112_500.0) + assert data["clients"] == [] + + +def test_clients_list_has_correct_fields(): + """Each client entry must carry clientName, clientNameNorm, and compensation.""" + db, doc_ref = _make_db() + detail = DisclosureDetail( + compensation=[Compensation(client_name="ML Strategies, LLC", amount=75_000.0)], + bills=[], + ) + with patch("writer.firestore.ArrayUnion", side_effect=lambda x: x): + write_registrant(db, _meta(), detail, "https://example.com/disc") + + clients = _captured_data(doc_ref)["clients"] + assert len(clients) == 1 + assert clients[0]["clientName"] == "ML Strategies, LLC" + assert clients[0]["clientNameNorm"] == "ML STRATEGIES" + assert clients[0]["compensation"] == 75_000.0 + + +def test_registrant_skipped_when_entity_name_empty(): + """write_registrant must be a no-op when entity_name is empty.""" + db, doc_ref = _make_db() + write_registrant(db, _meta(entity_name=""), DisclosureDetail(), "https://x.com") + doc_ref.set.assert_not_called() + + +def test_registrant_skipped_when_year_none(): + """write_registrant must be a no-op when year is None.""" + db, doc_ref = _make_db() + meta = DisclosureMeta(entity_name="Acme", year=None, reg_type="Employer") + write_registrant(db, meta, DisclosureDetail(), "https://x.com") + doc_ref.set.assert_not_called() + + +# ── write_filings ───────────────────────────────────────────────────────────── + + +def test_write_filings_returns_count(): + """write_filings must return the number of documents written.""" + db = MagicMock() + batch = MagicMock() + db.batch.return_value = batch + db.collection.return_value.document.return_value = MagicMock() + + detail = DisclosureDetail( + compensation=[], + bills=[ + BillActivity("Client A", "House Bill", "100", "H100", "An Act", "Support", None), + BillActivity("Client A", "Senate Bill", "200", "S200", "An Act", "Oppose", None), + ], + ) + count = write_filings(db, _meta(), detail) + assert count == 2 + assert batch.commit.called + + +def test_write_filings_returns_zero_when_no_bills(): + """write_filings must return 0 and not touch Firestore when bills list is empty.""" + db = MagicMock() + detail = DisclosureDetail(compensation=[], bills=[]) + count = write_filings(db, _meta(), detail) + assert count == 0 + db.batch.assert_not_called() From 0e03c1075d3cfb25edc1049d565ee4af3002d908 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 9 Jul 2026 22:04:13 -0400 Subject: [PATCH 028/106] fix: track reparse progress via GCS metadata instead of Firestore array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storing processed blob names in a single Firestore document via ArrayUnion risks hitting the 1MB document size limit at full MA SoS archive scale (~15k–25k blobs × 54 bytes each). Instead, set reparse-processed=true directly on each blob's GCS object metadata — no size limit, zero extra reads (blob.reload() already fetches metadata), and state is collocated with the data. Adds unit tests covering _is_processed and _mark_processed, including the metadata-preservation invariant (existing keys are not clobbered). Co-Authored-By: Claude Sonnet 4.6 --- lobbying-scraper/reparse_archive.py | 26 +++---- .../tests/test_reparse_archive.py | 78 +++++++++++++++++++ 2 files changed, 89 insertions(+), 15 deletions(-) create mode 100644 lobbying-scraper/tests/test_reparse_archive.py diff --git a/lobbying-scraper/reparse_archive.py b/lobbying-scraper/reparse_archive.py index 266ff1ad6..5b43d69c1 100644 --- a/lobbying-scraper/reparse_archive.py +++ b/lobbying-scraper/reparse_archive.py @@ -13,8 +13,8 @@ GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json \\ python3 reparse_archive.py [--limit N] [--dry-run] -Progress is tracked in Firestore at /scrapers/lobbyingReparse so the run is -resumable: restarting skips blobs already marked as processed. +Progress is tracked via GCS object metadata (reparse-processed=true) so the +run is resumable: restarting skips blobs already marked as processed. """ from __future__ import annotations @@ -24,12 +24,13 @@ from bs4 import BeautifulSoup from google.cloud import firestore, storage +from google.cloud.storage import Blob import archive from portal import DisclosureMeta, parse_disclosure_detail, year_from_disc_url from writer import REGISTRANTS_COLLECTION, write_filings -REPARSE_DOC = "scrapers/lobbyingReparse" +_PROCESSED_META_KEY = "reparse-processed" def _meta_for_disc_url(db: firestore.Client, disc_url: str) -> DisclosureMeta | None: @@ -51,18 +52,13 @@ def _meta_for_disc_url(db: firestore.Client, disc_url: str) -> DisclosureMeta | ) -def _is_processed(db: firestore.Client, blob_name: str) -> bool: - doc = db.document(REPARSE_DOC).get() - if not doc.exists: - return False - return blob_name in doc.to_dict().get("processedBlobs", []) +def _is_processed(blob: Blob) -> bool: + return (blob.metadata or {}).get(_PROCESSED_META_KEY) == "true" -def _mark_processed(db: firestore.Client, blob_name: str) -> None: - db.document(REPARSE_DOC).set( - {"processedBlobs": firestore.ArrayUnion([blob_name])}, - merge=True, - ) +def _mark_processed(blob: Blob) -> None: + blob.metadata = {**(blob.metadata or {}), _PROCESSED_META_KEY: "true"} + blob.patch() def run(limit: int | None, dry_run: bool) -> None: @@ -90,7 +86,7 @@ def run(limit: int | None, dry_run: bool) -> None: skipped += 1 continue - if db is not None and _is_processed(db, blob.name): + if _is_processed(blob): skipped += 1 continue @@ -124,7 +120,7 @@ def run(limit: int | None, dry_run: bool) -> None: if not dry_run and db is not None and meta is not None: write_filings(db, meta, detail) - _mark_processed(db, blob.name) + _mark_processed(blob) processed += 1 diff --git a/lobbying-scraper/tests/test_reparse_archive.py b/lobbying-scraper/tests/test_reparse_archive.py new file mode 100644 index 000000000..d18b855fe --- /dev/null +++ b/lobbying-scraper/tests/test_reparse_archive.py @@ -0,0 +1,78 @@ +"""Unit tests for reparse_archive progress-tracking helpers. + +These tests mock the GCS Blob object so no network access is required. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from reparse_archive import _is_processed, _mark_processed + + +def _make_blob(metadata: dict | None = None) -> MagicMock: + blob = MagicMock() + blob.metadata = metadata + return blob + + +class TestIsProcessed: + def test_none_metadata(self): + assert not _is_processed(_make_blob(None)) + + def test_empty_metadata(self): + assert not _is_processed(_make_blob({})) + + def test_unrelated_key(self): + assert not _is_processed(_make_blob({"source-url": "https://example.com"})) + + def test_wrong_value(self): + assert not _is_processed(_make_blob({"reparse-processed": "false"})) + + def test_processed(self): + assert _is_processed(_make_blob({"reparse-processed": "true"})) + + def test_processed_with_other_keys(self): + assert _is_processed( + _make_blob({"source-url": "https://example.com", "reparse-processed": "true"}) + ) + + +class TestMarkProcessed: + def test_sets_flag_on_none_metadata(self): + blob = _make_blob(None) + _mark_processed(blob) + assert blob.metadata["reparse-processed"] == "true" + blob.patch.assert_called_once() + + def test_sets_flag_on_empty_metadata(self): + blob = _make_blob({}) + _mark_processed(blob) + assert blob.metadata["reparse-processed"] == "true" + blob.patch.assert_called_once() + + def test_preserves_existing_keys(self): + blob = _make_blob({"source-url": "https://example.com"}) + _mark_processed(blob) + assert blob.metadata["source-url"] == "https://example.com" + assert blob.metadata["reparse-processed"] == "true" + blob.patch.assert_called_once() + + def test_idempotent(self): + blob = _make_blob({"reparse-processed": "true"}) + _mark_processed(blob) + assert blob.metadata["reparse-processed"] == "true" + blob.patch.assert_called_once() + + def test_original_metadata_not_mutated(self): + original = {"source-url": "https://example.com"} + blob = _make_blob(original) + _mark_processed(blob) + # The dict spread creates a new dict, so original is unchanged + assert "reparse-processed" not in original From c8ba40df5e05a74462477e7efd21f853171f961a Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 9 Jul 2026 22:13:22 -0400 Subject: [PATCH 029/106] fix: get year from Firestore registrant doc instead of parsing URL year_from_disc_url only handles modern portal URLs with a readable FilingYear= query parameter. Older archived blobs use encrypted sysvalue= tokens, causing every blob to be skipped with "cannot extract year from URL". The registrant doc looked up by _meta_for_disc_url already carries the year field, so use meta.year instead. Dry-run mode now reads from Firestore (reads were always safe) but still skips all writes. Co-Authored-By: Claude Sonnet 4.6 --- lobbying-scraper/reparse_archive.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/lobbying-scraper/reparse_archive.py b/lobbying-scraper/reparse_archive.py index 5b43d69c1..3ea536de5 100644 --- a/lobbying-scraper/reparse_archive.py +++ b/lobbying-scraper/reparse_archive.py @@ -27,7 +27,7 @@ from google.cloud.storage import Blob import archive -from portal import DisclosureMeta, parse_disclosure_detail, year_from_disc_url +from portal import DisclosureMeta, parse_disclosure_detail from writer import REGISTRANTS_COLLECTION, write_filings _PROCESSED_META_KEY = "reparse-processed" @@ -66,7 +66,7 @@ def run(limit: int | None, dry_run: bool) -> None: bucket_name = archive._get_bucket_name() bucket = gcs.bucket(bucket_name) - db: firestore.Client | None = None if dry_run else firestore.Client() + db = firestore.Client() blobs = list(bucket.list_blobs(prefix="raw_html/")) print(f"Found {len(blobs)} archived pages") @@ -90,24 +90,21 @@ def run(limit: int | None, dry_run: bool) -> None: skipped += 1 continue - year = year_from_disc_url(url) - if year is None: - print(f" SKIP {blob.name}: cannot extract year from {url!r}") + meta = _meta_for_disc_url(db, url) + if meta is None: + print(f" SKIP {blob.name}: no registrant found for {url!r}") skipped += 1 continue - meta: DisclosureMeta | None = None - if db is not None: - meta = _meta_for_disc_url(db, url) - if meta is None: - print(f" SKIP {blob.name}: no registrant found for {url!r}") - skipped += 1 - continue + if meta.year is None: + print(f" SKIP {blob.name}: registrant doc has no year") + skipped += 1 + continue try: html = blob.download_as_text(encoding="utf-8") soup = BeautifulSoup(html, "html.parser") - detail = parse_disclosure_detail(soup, year) + detail = parse_disclosure_detail(soup, meta.year) except Exception as exc: print(f" ERROR parsing {url}: {exc}") errors += 1 @@ -118,7 +115,7 @@ def run(limit: int | None, dry_run: bool) -> None: f" {len(detail.bills)} bills" ) - if not dry_run and db is not None and meta is not None: + if not dry_run: write_filings(db, meta, detail) _mark_processed(blob) From 3f839385acfff49e4d4e417cb179f1ca5df937c4 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 9 Jul 2026 22:20:40 -0400 Subject: [PATCH 030/106] chore: ignore Python pycache/bytecode and remove tracked .pyc files Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 +++ .../__pycache__/normalize.cpython-37.pyc | Bin 1387 -> 0 bytes .../__pycache__/portal.cpython-37.pyc | Bin 16141 -> 0 bytes .../tests/__pycache__/__init__.cpython-37.pyc | Bin 141 -> 0 bytes ...st_portal_parser.cpython-37-pytest-7.4.4.pyc | Bin 18283 -> 0 bytes 5 files changed, 3 insertions(+) delete mode 100644 lobbying-scraper/__pycache__/normalize.cpython-37.pyc delete mode 100644 lobbying-scraper/__pycache__/portal.cpython-37.pyc delete mode 100644 lobbying-scraper/tests/__pycache__/__init__.cpython-37.pyc delete mode 100644 lobbying-scraper/tests/__pycache__/test_portal_parser.cpython-37-pytest-7.4.4.pyc diff --git a/.gitignore b/.gitignore index 7301e0ec2..f2e2b9fb1 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,9 @@ infra/dev/typesense-secrets.yml infra/prod/typesense-secrets.yml .python-version +__pycache__/ +*.pyc +*.pyo /*.history diff --git a/lobbying-scraper/__pycache__/normalize.cpython-37.pyc b/lobbying-scraper/__pycache__/normalize.cpython-37.pyc deleted file mode 100644 index 06bd603aac6b45e259ab6d0ad3296bc153a7bcbd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1387 zcmZuw&u^SY6lP$5;@!k`;>4}gcGO6zaq3<5UbVq2aggw`f_1E{Tp^Yj+eEuS254=B zr5t+hsfV`eVSVhM(SL(udg{Nhr@p~n$A=7>H{ZNB@8jbQZ?D^x1sG3$J$UnHLlFMu zo7Lz7crL69$xi@?FcAnM;tb8jiHJCsX3|8W(xOIcRC-gt5+{vnSHD7&W`!DnS`}&n zYLgae6Pc`$4(XD6`b%xHMkGg2KFj`rm+N5~ zq!&2!<`jn!n|re$_tGE=aU8^S7KF4X%Y%SXKgDsxQXHM&lPvW4k))q6zdwtn(~BTH z?XS{lFHL&bNokzmd6uMjO0gHmvw#vDgmQWoB$%FiKo~JX8GirnH|(Ez;VF>d^o-uP zV-}IjrwJy($q8jNO!0OQ`Vr$=6dYw~oTWYaKVDM>m1|xYMrkEI8RlOdC(j1Q$xi>- zUS-uEz|q?&xO-2naIPIsw@p(kOmkEiLyb?%9$B_J)(6E38rHDFg*hG+rg32K;7BV* zkf|I`^XBn%{89%X0cqYf)gx@ZcwuNdEcrckIKZmoSegN(PTqNXch|m+=J(a{*s_QE zYpjkjaO9np<2m20!s8ENARejqc&OV>{$O;tXBrwFSzuq|ecL)5HYY(Fa zvkp_^zw`n?AtWt=99S2^m2intv=9j@(3N-zb|DdwNF1MUk=j7`as5){-r~mlws$0? zHOP>zGt&A8TxHiuFloOSHX2$`U;-CAg{yuaBK+GRb9W6H{=3qO@>>rkX@ zS~eMtXP!^XwhMQlnmo{S;VmC@6=8j(4s-rt)!##4iyAIV2Vx`7-8B26tivC&I zh|idp(7y%tIe3@f_S1RXpL_9)?yj2iw)=JypLw9=P8Y@(<>qZqy3jw+=q_*cC%~3` z0Y*SIcv`3fkBruTB*8uB2>zH-~ng(eHTyk{vKLu_Ly`dmykO%1D+( J^zo-r`yWJzd`tiU diff --git a/lobbying-scraper/__pycache__/portal.cpython-37.pyc b/lobbying-scraper/__pycache__/portal.cpython-37.pyc deleted file mode 100644 index 0bc74351acbc0573fde91df55339d5ba00fbce9c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16141 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 Date: Fri, 10 Jul 2026 01:13:34 -0400 Subject: [PATCH 031/106] Redesign the Learn section around a hub page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the Figma prototype (maple-learn-tab) into maple's stack: react-bootstrap and styled-components rather than the prototype's Tailwind, and @mui/icons-material rather than adding lucide-react. New: /learn hub page with four cards /learn/testimony consolidates the four testimony tabs into one page Reworked: /learn/legislative-process sticky journey rail + chapter accordion /why-use-maple/[slug] persona cards, hero and checklist; existing copy /learn/ai-tools header/breadcrumb only — MCP setup docs untouched The four retired tab slugs 301 to /learn/testimony. The navbar keeps its Learn dropdown, repointed at the new pages; /learn is reachable directly and via the breadcrumb. Notes: - IntroductionIcon had a cubic path command with four parameters instead of six, which Chrome rejected outright; the trailing L point is now the curve endpoint. - /why-use-maple persona switching is a shallow push: getStaticProps already loads all three namespaces, so the per-click data fetch was pure latency. - The process rail tracks the navbar's live bottom edge, because the navbar does not stay pinned at every breakpoint. - Fixed a 404 link to the MA legislative process document. - Deletes code superseded by the consolidation; Tabs.tsx last, once both of its importers were gone. Known: axe reports one serious color-contrast violation — a tinted stage colour on a closed chapter number cannot reach AA against white for green or orange. Left as designed pending a palette decision. --- components/AdditionalResources.tsx | 135 ++- .../CommunicatingWithLegislatorsContent.tsx | 90 -- components/Footer/Footer.tsx | 5 +- .../LearnComponents.tsx | 201 ----- .../RoleOfTestimony/RoleOfTestimonyCard.tsx | 45 - .../TestimonyCardComponents.tsx | 118 --- components/Legislative/Legislative.tsx | 61 -- components/Navbar.tsx | 10 +- components/NavbarComponents.tsx | 2 +- components/Tabs/Tabs.tsx | 70 -- components/learn/AiTools/AiTools.tsx | 464 +++++----- components/learn/Hub/LearnHub.tsx | 150 ++++ components/learn/LearnBreadcrumb.tsx | 62 ++ components/learn/LearnHeader.tsx | 72 ++ components/learn/LearnLayout.tsx | 41 + .../learn/Process/LegislativeProcess.tsx | 805 +++++++++++++++++ components/learn/Testimony/AboutTestimony.tsx | 456 ++++++++++ components/learn/WhyUseMaple/WhyUseMaple.tsx | 308 +++++++ components/learn/icons/Blobs.tsx | 181 ++++ components/learn/icons/ProcessStageIcons.tsx | 836 ++++++++++++++++++ components/learn/icons/WhyItMattersIcons.tsx | 74 ++ components/learn/icons/index.ts | 19 + components/learn/icons/svgPaths.ts | 33 + components/learn/palette.ts | 38 + components/publish/WriteTestimony.tsx | 2 +- next.config.js | 17 +- pages/learn/[slug].tsx | 108 --- pages/learn/ai-tools.tsx | 13 +- pages/learn/index.tsx | 23 +- pages/learn/legislative-process.tsx | 17 +- pages/learn/testimony.tsx | 15 + pages/why-use-maple/[slug].tsx | 75 +- public/locales/en/common.json | 4 +- public/locales/en/footer.json | 8 +- public/locales/en/learn.json | 202 +++++ public/locales/en/learnComponents.json | 133 --- .../CommunicatingWithLegislators.stories.tsx | 15 - styles/bootstrap.scss | 21 +- 38 files changed, 3726 insertions(+), 1203 deletions(-) delete mode 100644 components/CommunicatingWithLegislators/CommunicatingWithLegislatorsContent.tsx delete mode 100644 components/LearnTestimonyComponents/LearnComponents.tsx delete mode 100644 components/LearnTestimonyComponents/RoleOfTestimony/RoleOfTestimonyCard.tsx delete mode 100644 components/LearnTestimonyComponents/TestimonyCardComponents.tsx delete mode 100644 components/Legislative/Legislative.tsx delete mode 100644 components/Tabs/Tabs.tsx create mode 100644 components/learn/Hub/LearnHub.tsx create mode 100644 components/learn/LearnBreadcrumb.tsx create mode 100644 components/learn/LearnHeader.tsx create mode 100644 components/learn/LearnLayout.tsx create mode 100644 components/learn/Process/LegislativeProcess.tsx create mode 100644 components/learn/Testimony/AboutTestimony.tsx create mode 100644 components/learn/WhyUseMaple/WhyUseMaple.tsx create mode 100644 components/learn/icons/Blobs.tsx create mode 100644 components/learn/icons/ProcessStageIcons.tsx create mode 100644 components/learn/icons/WhyItMattersIcons.tsx create mode 100644 components/learn/icons/index.ts create mode 100644 components/learn/icons/svgPaths.ts create mode 100644 components/learn/palette.ts delete mode 100644 pages/learn/[slug].tsx create mode 100644 pages/learn/testimony.tsx create mode 100644 public/locales/en/learn.json delete mode 100644 stories_hold/pages/education/CommunicatingWithLegislators.stories.tsx diff --git a/components/AdditionalResources.tsx b/components/AdditionalResources.tsx index 1e6170ad8..cd7f1b9f9 100644 --- a/components/AdditionalResources.tsx +++ b/components/AdditionalResources.tsx @@ -1,48 +1,117 @@ -import { Container } from "react-bootstrap" import { useTranslation, Trans } from "next-i18next" -import * as links from "components/links" +import styled from "styled-components" -const AdditionalResources = () => { - const content = [ - { - i18nKey: "find_legislator", - href: "https://malegislature.gov/Search/FindMyLegislator" - }, - { - i18nKey: "legislative_doc", - href: "https://www.mass.gov/doc/the-legislative-process-0/download" - }, - { - i18nKey: "legal_services", - href: "https://www.masslegalservices.org/content/legislative-process-massachusetts-0" +const Section = styled.section` + margin-top: 3rem; + + h2 { + font-family: var(--maple-font-heading); + font-weight: 700; + color: var(--bs-blue); + font-size: clamp(1.5rem, 4vw, 1.875rem); + margin-bottom: 0.75rem; + } + + .intro { + color: var(--maple-text-body); + line-height: 1.7; + margin-bottom: 1.5rem; + } + + ul { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin: 0; + padding: 0; + list-style: none; + } + + li { + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + padding: 1.25rem 1.5rem; + transition: box-shadow 0.2s ease; + + &:hover { + box-shadow: var(--maple-shadow-hover); + } + } + + p { + color: var(--maple-text-body); + line-height: 1.7; + margin: 0; + } + + a { + color: var(--bs-blue); + font-weight: 600; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + @media (prefers-reduced-motion: reduce) { + li { + transition: none; } - ] + } +` + +const content = [ + { + i18nKey: "find_legislator", + href: "https://malegislature.gov/Search/FindMyLegislator" + }, + { + i18nKey: "legislative_doc", + href: "https://www.mass.gov/doc/the-legislative-process" + }, + { + i18nKey: "legal_services", + href: "https://www.masslegalservices.org/content/legislative-process-massachusetts-0" + } +] + +const AdditionalResources = () => { const { t } = useTranslation("learnComponents") return ( - -

+
+ {/* h2, not h1: the page already has one, and this is a subsection of it. */} +

{t("legislative.additional_resources")} -

-

{t("legislative.resources_intro")}

- - {content.map(({ i18nKey, href }) => ( -
-
-

+

+

{t("legislative.resources_intro")}

+ +
    + {content.map(({ i18nKey, href }) => ( +
  • +

    + {/* A bare anchor, not links.External: that helper appends a + trailing space, which shows up as "the process ." here. */} ]} + components={[ + // eslint-disable-next-line jsx-a11y/anchor-has-content + + ]} />

    -
-
- ))} - + + ))} + + ) } diff --git a/components/CommunicatingWithLegislators/CommunicatingWithLegislatorsContent.tsx b/components/CommunicatingWithLegislators/CommunicatingWithLegislatorsContent.tsx deleted file mode 100644 index cc667c3d4..000000000 --- a/components/CommunicatingWithLegislators/CommunicatingWithLegislatorsContent.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Row, Col } from "../bootstrap" -import Image from "react-bootstrap/Image" -import styled from "styled-components" -import { useTranslation } from "next-i18next" - -const StyledImage = styled(Image)` - width: 12.5rem; - /* bootstrap: medium > 768px */ - @media (max-width: 48em) { - display: flex; - margin-left: auto; - margin-right: auto; - } - - /* bootstrap: small > 576px */ - @media (max-width: 36em) { - width: 10rem; - } - - /* bootstrap: xs < 576px - this break at 464px */ - @media (max-width: 26em) { - width: 8rem; - } -` - -const WritingContent = () => ( - - -

- {useTranslation("learnComponents").t( - "communicating.testifyInWriting.content" - )} -

- - - - -
-) - -const OralContent = () => ( - - - - - -

- {useTranslation("learnComponents").t( - "communicating.testifyOrally.content" - )} -

- -
-) - -const WriteOrCallContent = () => ( - - -

- {useTranslation("learnComponents").t( - "communicating.writeOrCall.content" - )} -

- - - - -
-) - -export { WritingContent, OralContent, WriteOrCallContent } diff --git a/components/Footer/Footer.tsx b/components/Footer/Footer.tsx index ce5063afa..98969fb18 100644 --- a/components/Footer/Footer.tsx +++ b/components/Footer/Footer.tsx @@ -189,7 +189,7 @@ const LearnLinks = () => { const { t } = useTranslation(["footer", "common"]) return ( <> - + {t("links.learnWriting")} @@ -198,6 +198,9 @@ const LearnLinks = () => { {t("links.learnWhy")} + + {t("links.learnAi")} + ) } diff --git a/components/LearnTestimonyComponents/LearnComponents.tsx b/components/LearnTestimonyComponents/LearnComponents.tsx deleted file mode 100644 index 0802524e8..000000000 --- a/components/LearnTestimonyComponents/LearnComponents.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { useTranslation } from "next-i18next" -import { Container, Card, Row, Col } from "../bootstrap" -import RoleOfTestimonyCard from "./RoleOfTestimony/RoleOfTestimonyCard" -import BasicsOfTestimonyCard from "./BasicsOfTestimony/BasicsOfTestimonyCard" -import { TestimonyCardList } from "./TestimonyCardComponents" -import styled from "styled-components" -import { - WritingContent, - OralContent, - WriteOrCallContent -} from "components/CommunicatingWithLegislators/CommunicatingWithLegislatorsContent" - -const StyledContainer = styled(Container)` - p { - letter-spacing: -0.625px; - } -` - -const StyledCardBody = styled(Card.Body)` - letter-spacing: -0.625px; - line-height: 2.05rem; -` - -const BasicsSrcAlt = [ - { - src: "who.svg", - alt: "who.title" - }, - { - src: "what.svg", - alt: "what.title" - }, - { - src: "when.svg", - alt: "when.title" - }, - { - src: "where.svg", - alt: "where.title" - }, - { - src: "why.svg", - alt: "Why" - } -] - -const RoleSrcAlt = [ - { - src: "speaker-with-thumbs.svg", - alt: "Speaker with thumbs" - }, - { - src: "speaker-with-leg.svg", - alt: "Speaker with documents" - }, - { - src: "speaker-with-pen.svg", - alt: "Speaker with pen" - } -] - -const WriteSrcAlt = [ - { - src: "leg-with-clock.svg", - alt: "" - }, - { - src: "leg-with-lightbulb.svg", - alt: "" - }, - { - src: "writing.svg", - alt: "" - }, - { - src: "opinions.svg", - alt: "" - }, - { - src: "respect-with-blob.svg", - alt: "" - } -] - -const Basics = () => { - const { t } = useTranslation("learnComponents") - return ( - -

{t("basics.title")}

-

{t("basics.intro")}

- {BasicsSrcAlt.map((value, index) => ( - - ))} -
- ) -} -const Role = () => { - const { t } = useTranslation("learnComponents") - return ( - -

{t("role.title")}

-

{t("role.intro")}

- {RoleSrcAlt.map((value, index) => ( - - ))} -
- ) -} - -const Write = () => { - interface WriteContentItem { - title: string - paragraphs: string[] - src: string - alt: string - } - const { t } = useTranslation("learnComponents") - const writeContent = t("write.content", { - returnObjects: true - }) as WriteContentItem[] - - const mergedContent = WriteSrcAlt.map((item, index) => ({ - ...item, - ...writeContent[index] - })) - - return ( - -

{t("write.title")}

-

{t("write.intro")}

- -
- ) -} - -const CommunicatingWithLegislators = () => { - const { t } = useTranslation("learnComponents") - - const CommWithLegCard = ({ - title, - children - }: { - title: string - children: JSX.Element - }): JSX.Element => { - return ( - - - {title} - - - {children} - - - ) - } - - return ( - - - -

- {t("communicating.title")} -

-

{t("communicating.intro")}

- - - - - - - - - - - - - -
-
- ) -} - -export { Basics, Role, Write, CommunicatingWithLegislators } diff --git a/components/LearnTestimonyComponents/RoleOfTestimony/RoleOfTestimonyCard.tsx b/components/LearnTestimonyComponents/RoleOfTestimony/RoleOfTestimonyCard.tsx deleted file mode 100644 index 8b8a09888..000000000 --- a/components/LearnTestimonyComponents/RoleOfTestimony/RoleOfTestimonyCard.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Col, Image, Row } from "../../bootstrap" -import clsx from "clsx" - -export type RoleOfTestimonyCardProps = { - title: string - index: number - alt: string - paragraph: string - src: string -} - -const RoleOfTestimonyCard = ({ - title, - index, - paragraph, - src -}: RoleOfTestimonyCardProps) => { - return ( - - - - - -

{title}

-

{paragraph}

- -
- ) -} - -export default RoleOfTestimonyCard diff --git a/components/LearnTestimonyComponents/TestimonyCardComponents.tsx b/components/LearnTestimonyComponents/TestimonyCardComponents.tsx deleted file mode 100644 index b2ac582a8..000000000 --- a/components/LearnTestimonyComponents/TestimonyCardComponents.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import styled from "styled-components" -import { Card, Col, Container, Image, Row } from "../bootstrap" - -export type TestimonyCardContent = { - title: string - paragraphs: string[] - src: string - alt: string -} - -export const TestimonyCardList = ({ - contents, - shouldAlternateImages = false -}: { - contents: TestimonyCardContent[] - shouldAlternateImages: boolean -}) => { - return ( -
- {contents.map((value, index) => ( - - ))} -
- ) -} - -export type TestimonyCardProps = { - content: TestimonyCardContent - shouldAlternateImage: boolean -} - -const StyledCard = styled(Card)` - margin: 8rem 5rem; - - @media (max-width: 48em) { - margin: 8rem 3rem; - } -` -const StyledHeader = styled(Card.Header)` - width: max-content; - transform: translate(-3rem, -40%); - - &:first-child { - border-radius: 0 5rem 5rem 0; - } - @media (max-width: 48em) { - transform: translate(-2rem, -40%); - } - @media (max-width: 36em) { - transform: translate(-1.5rem, -40%); - } -` -const TestimonyCard = ({ - content, - shouldAlternateImage -}: TestimonyCardProps) => { - return ( - - - {content.title} - - - - ) -} - -const StyledImage = styled(Image)` - @media (max-width: 36em) { - width: 12rem; - } - @media (max-width: 29em) { - width: 10rem; - } -` - -const TestimonyCardContent = ({ - content, - shouldAlternateImage -}: TestimonyCardProps) => { - return ( - - - - -
- -
- - - {content.paragraphs.map((paragraph, index) => ( -

- {paragraph} -

- ))} - -
-
-
- ) -} diff --git a/components/Legislative/Legislative.tsx b/components/Legislative/Legislative.tsx deleted file mode 100644 index 6430eca8e..000000000 --- a/components/Legislative/Legislative.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Container } from "react-bootstrap" -import { TestimonyCardList } from "components/LearnTestimonyComponents/TestimonyCardComponents" -import { useTranslation } from "next-i18next" - -const LegislativeContent = [ - { - src: "speaker-podium.svg", - alt: "" - }, - { - src: "mic-with-testify.svg", - alt: "" - }, - { - src: "doc-with-arrows-to-people.svg", - alt: "" - }, - { - src: "leg-with-lightbulb.svg", - alt: "" - }, - { - src: "speaker-with-leg.svg", - alt: "" - }, - { - src: "opinions.svg", - alt: "" - }, - { - src: "respect-with-blob.svg", - alt: "" - }, - { - src: "speaker-with-pen.svg", - alt: "" - } -] - -const Legislative = () => { - const { t } = useTranslation("learnComponents") - - return ( - -

- {t("legislative.title")} -

-

{t("legislative.intro")}

- ({ - ...value, - title: t(`legislative.content.${index}.title`), - paragraphs: [t(`legislative.content.${index}.paragraph`)] - }))} - shouldAlternateImages={false} - /> -
- ) -} - -export default Legislative diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 3c68cdd44..3657254a9 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -12,23 +12,23 @@ import { NavbarLinkBallotQuestions, DESKTOP_NAV_ITEM_CLASS, NavbarLinkAI, - NavbarLinkAiTools, NavbarLinkBills, + NavbarLinkAiTools, + NavbarLinkEffective, NavbarLinkHearings, + NavbarLinkProcess, + NavbarLinkWhyUse, NavbarLinkEditProfile, - NavbarLinkEffective, NavbarLinkFAQ, NavbarLinkGoals, NavbarLinkInTheNews, NavbarLinkLogo, NavbarLinkNewsfeed, - NavbarLinkProcess, NavbarLinkSignOut, NavbarLinkSupport, NavbarLinkTeam, NavbarLinkTestimony, - NavbarLinkViewProfile, - NavbarLinkWhyUse + NavbarLinkViewProfile } from "./NavbarComponents" const MobileCollapse = styled(Navbar.Collapse)` diff --git a/components/NavbarComponents.tsx b/components/NavbarComponents.tsx index a378acaed..59dce95b9 100644 --- a/components/NavbarComponents.tsx +++ b/components/NavbarComponents.tsx @@ -231,7 +231,7 @@ export const NavbarLinkEffective: React.FC< return ( diff --git a/components/Tabs/Tabs.tsx b/components/Tabs/Tabs.tsx deleted file mode 100644 index 8de01195e..000000000 --- a/components/Tabs/Tabs.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { FC } from "react" -import clsx from "clsx" - -type TabsProps = { - tabs: { - label: string - index: number - Component: FC> - }[] - selectedTab: number - onClick: (index: number) => void - orientation?: "horizontal" | "vertical" - className?: string -} - -/** - * Available Props - * @param className string - * @param tab Array of object - * @param selectedTab number - * @param onClick Function to set the active tab - * @param orientation Tab orientation Vertical | Horizontal - */ -const Tabs: FC> = ({ - className = "tabs-component", - tabs = [], - selectedTab = 0, - onClick, - orientation = "horizontal" -}) => { - const Panel = tabs && tabs.find(tab => tab.index === selectedTab) - - return ( -
-
- {tabs.map(tab => ( - - ))} -
-
- {Panel && } -
-
- ) -} -export default Tabs diff --git a/components/learn/AiTools/AiTools.tsx b/components/learn/AiTools/AiTools.tsx index 5c8d6fe6e..65cd4e907 100644 --- a/components/learn/AiTools/AiTools.tsx +++ b/components/learn/AiTools/AiTools.tsx @@ -4,12 +4,13 @@ import { Container, Row, Col } from "../../bootstrap" import { DescrContainer, Divider, - PageDescr, - PageTitle, SectionContainer, SectionTitle } from "../../shared/CommonComponents" import { Internal } from "../../links" +import LearnBreadcrumb from "../LearnBreadcrumb" +import LearnHeader from "../LearnHeader" +import LearnLayout from "../LearnLayout" const ExampleBox = styled.div` background: var(--maple-surface-raised); @@ -61,205 +62,209 @@ const ExampleLabel = styled.div` export const AiTools = () => { const { t } = useTranslation("aiTools") + const { t: tLearn } = useTranslation("learn") + // Header, subhead and breadcrumb follow the Learn section's styling. The body + // below is unchanged: it hosts the MCP setup instructions that + // mcp-server/auth.ts and functions/src/mcp/proxy.ts point users to. return ( - - - - {t("title")} - - - - - {t("description")} - - + + + + + {/* What is it */} + + + + {t("section1.title")} + + {t("section1.desc1")} + + + {t("section1.desc2Pre")}{" "} + +
+ {t("section1.desc2LinkText")} + + + {t("section1.desc2Post")} + + + + - {/* What is it */} - - - - {t("section1.title")} - - {t("section1.desc1")} - - - {t("section1.desc2Pre")}{" "} - - - {t("section1.desc2LinkText")} - - - {t("section1.desc2Post")} - - - - - - {/* What you can do */} - - - - {t("section2.title")} - - {t("section2.intro")} - - -
    -
  • - {t("section2.item1Bold")} {t("section2.item1Main")} -
  • -
  • - {t("section2.item2Bold")} {t("section2.item2Main")} -
  • -
  • - {t("section2.item3Bold")} {t("section2.item3Main")} -
  • -
  • - {t("section2.item4Bold")} {t("section2.item4Main")} -
  • -
  • - {t("section2.item5Bold")} {t("section2.item5Main")} -
  • -
  • - {t("section2.item6Bold")} {t("section2.item6Main")} -
  • -
  • - {t("section2.item7Bold")} {t("section2.item7Main")} -
  • -
-
-
- -
- - {/* Examples */} - - - - {t("section3.title")} - - {t("section3.intro")} - -
- {t("section3.example1Label")} - - “{t("section3.example1Text")}” - - - {t("section3.example2Label")} - - “{t("section3.example2Text")}” - - - {t("section3.example3Label")} - - “{t("section3.example3Text")}” - - - {t("section3.example4Label")} - - “{t("section3.example4Text")}” - -
-
- -
- - {/* How to get started */} - - - - {t("section4.title")} - - {t("section4.intro")} - - - 1 - - {t("section4.step1Bold")} {t("section4.step1Pre")}{" "} - {t("section4.step1LinkText")} - {t("section4.step1Post")} - - - - - 2 - - {t("section4.step2Bold")} {t("section4.step2Intro")} -
    -
  • - {t("section4.step2item1Bold")}{" "} - {t("section4.step2item1Tag")} {t("section4.step2item1Pre")}{" "} - - {t("section4.step2item1Link1")} - - {t("section4.step2item1Mid")}{" "} - - {t("section4.step2item1Link2")} - - {t("section4.step2item1Post")} + {/* What you can do */} + + + + {t("section2.title")} + + {t("section2.intro")} + + +
      +
    • + {t("section2.item1Bold")} {t("section2.item1Main")} +
    • +
    • + {t("section2.item2Bold")} {t("section2.item2Main")} +
    • +
    • + {t("section2.item3Bold")} {t("section2.item3Main")}
    • -
    • - {t("section4.step2item2Bold")}{" "} - {t("section4.step2item2Tag")} {t("section4.step2item2Pre")}{" "} - - {t("section4.step2item2LinkText")} - {" "} - {t("section4.step2item2Post")} +
    • + {t("section2.item4Bold")} {t("section2.item4Main")} +
    • +
    • + {t("section2.item5Bold")} {t("section2.item5Main")} +
    • +
    • + {t("section2.item6Bold")} {t("section2.item6Main")}
    • - {t("section4.step2item3Bold")}{" "} - {t("section4.step2item3Main")} + {t("section2.item7Bold")} {t("section2.item7Main")}
    - - - - - 3 - - {t("section4.step3Bold")} {t("section4.step3Pre")}{" "} - - {t("section4.step3LinkText")} - {" "} - {t("section4.step3Post")} - - - - - 4 - - {t("section4.step4Bold")} {t("section4.step4Pre")}{" "} - YOUR_TOKEN_HERE {t("section4.step4Post")} - - -
    -
    {`{
    +              
    +            
    +          
    +        
    +
    +        {/* Examples */}
    +        
    +          
    +            
    +              {t("section3.title")}
    +              
    +                {t("section3.intro")}
    +              
    +              
    + {t("section3.example1Label")} + + “{t("section3.example1Text")}” + + + {t("section3.example2Label")} + + “{t("section3.example2Text")}” + + + {t("section3.example3Label")} + + “{t("section3.example3Text")}” + + + {t("section3.example4Label")} + + “{t("section3.example4Text")}” + +
    +
    + +
    + + {/* How to get started */} + + + + {t("section4.title")} + + {t("section4.intro")} + + + 1 + + {t("section4.step1Bold")} {t("section4.step1Pre")}{" "} + + {t("section4.step1LinkText")} + + {t("section4.step1Post")} + + + + + 2 + + {t("section4.step2Bold")} {t("section4.step2Intro")} + + + + + + 3 + + {t("section4.step3Bold")} {t("section4.step3Pre")}{" "} + + {t("section4.step3LinkText")} + {" "} + {t("section4.step3Post")} + + + + + 4 + + {t("section4.step4Bold")} {t("section4.step4Pre")}{" "} + YOUR_TOKEN_HERE {t("section4.step4Post")} + + +
    +
    {`{
       "mcpServers": {
         "maple": {
           "type": "http",
    @@ -270,43 +275,44 @@ export const AiTools = () => {
         }
       }
     }`}
    -
    - - - 5 - - {t("section4.step5Bold")} {t("section4.step5Post")} - - - - {t("section4.needHelp")} {t("section4.needHelpPost")}{" "} - - info@mapletestimony.org - - . - -
    - -
    +
    + + + 5 + + {t("section4.step5Bold")} {t("section4.step5Post")} + + + + {t("section4.needHelp")} {t("section4.needHelpPost")}{" "} + + info@mapletestimony.org + + . + +
    + +
    - {/* Privacy */} - - - - {t("section5.title")} - - {t("section5.desc1")} - - - {t("section5.desc2Pre")}{" "} - - {t("section5.desc2LinkText")} - - - - - - + {/* Privacy */} + + + + {t("section5.title")} + + {t("section5.desc1")} + + + {t("section5.desc2Pre")}{" "} + + {t("section5.desc2LinkText")} + + + + + + + ) } diff --git a/components/learn/Hub/LearnHub.tsx b/components/learn/Hub/LearnHub.tsx new file mode 100644 index 000000000..a5705ce08 --- /dev/null +++ b/components/learn/Hub/LearnHub.tsx @@ -0,0 +1,150 @@ +import { useTranslation } from "next-i18next" +import styled from "styled-components" +import { Internal } from "../../links" +import LearnHeader from "../LearnHeader" +import LearnLayout from "../LearnLayout" +import { + ArrowRightIcon, + BookOpenIcon, + BotIcon, + ScaleIcon, + UsersIcon +} from "../icons" +import { CRIMSON, GREEN, NAVY, ORANGE } from "../palette" + +type HubCard = { + id: string + title: string + desc: string + href: string +} + +const CARD_STYLE: Record = + { + testimony: { color: NAVY, Icon: BookOpenIcon }, + process: { color: GREEN, Icon: ScaleIcon }, + why: { color: ORANGE, Icon: UsersIcon }, + ai: { color: CRIMSON, Icon: BotIcon } + } + +const CardLink = styled(Internal)` + display: flex; + flex-direction: column; + gap: 1rem; + height: 100%; + padding: 2rem; + background: var(--maple-surface-base); + border: 1px solid var(--maple-surface-border); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + text-decoration: none; + transition: box-shadow 0.2s ease, border-color 0.2s ease; + + &:hover { + box-shadow: var(--maple-shadow-hover); + border-color: transparent; + text-decoration: none; + } + + &:focus-visible { + outline: 2px solid var(--bs-blue); + outline-offset: 3px; + } + + .glyph { + width: 3.5rem; + height: 3.5rem; + border-radius: var(--maple-radius-xl); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + } + + h2 { + font-family: var(--maple-font-heading); + font-weight: 700; + font-size: 1.3125rem; + color: #1a1a2e; + margin: 0 0 0.5rem; + } + + .desc { + color: var(--maple-text-muted); + font-size: 0.9375rem; + line-height: 1.6; + margin: 0; + } + + .explore { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: auto; + padding-top: 0.5rem; + font-size: 0.9375rem; + font-weight: 700; + color: var(--bs-blue); + } + + .explore svg { + transition: transform 0.2s ease; + } + + &:hover .explore svg { + transform: translateX(4px); + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + .explore svg { + transition: none; + transform: none; + } + } +` + +export const LearnHub = () => { + const { t } = useTranslation("learn") + const cards = t("hub.cards", { returnObjects: true }) as HubCard[] + + return ( + + + +
    + {cards.map(card => { + const { color, Icon } = CARD_STYLE[card.id] + return ( +
    + + + +
    +

    {card.title}

    +

    {card.desc}

    +
    + + {t("hub.explore")} + +
    +
    + ) + })} +
    +
    + ) +} + +export default LearnHub diff --git a/components/learn/LearnBreadcrumb.tsx b/components/learn/LearnBreadcrumb.tsx new file mode 100644 index 000000000..193f0dfce --- /dev/null +++ b/components/learn/LearnBreadcrumb.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from "next-i18next" +import styled from "styled-components" +import { Internal } from "../links" +import { ChevronRightIcon } from "./icons" + +const Nav = styled.nav` + margin-bottom: 1rem; + + ol { + display: flex; + align-items: center; + gap: 0.25rem; + margin: 0; + padding: 0; + list-style: none; + font-size: 0.875rem; + } + + a { + /* --maple-text-muted fails AA on the tinted Learn surface. */ + color: var(--maple-text-body); + text-decoration: none; + + &:hover { + color: var(--bs-blue); + text-decoration: underline; + } + } + + .separator { + color: var(--maple-text-muted); + font-size: 1rem; + } + + .current { + color: var(--bs-blue); + font-weight: 700; + } +` + +/** "Learn > {section}" trail shown at the top of each Learn sub-page. */ +export const LearnBreadcrumb = ({ section }: { section: string }) => { + const { t } = useTranslation("learn") + + return ( + + ) +} + +export default LearnBreadcrumb diff --git a/components/learn/LearnHeader.tsx b/components/learn/LearnHeader.tsx new file mode 100644 index 000000000..50f8aa035 --- /dev/null +++ b/components/learn/LearnHeader.tsx @@ -0,0 +1,72 @@ +import styled from "styled-components" + +const Eyebrow = styled.p` + font-size: 0.8125rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + /* 50% failed AA against the tinted Learn surface. */ + color: color-mix(in srgb, var(--bs-blue) 78%, transparent); + margin-bottom: 0.5rem; +` + +const Title = styled.h1` + font-family: var(--maple-font-heading); + font-weight: 700; + color: var(--bs-blue); + line-height: 1.15; + margin-bottom: 0.75rem; + /* Scale down on narrow viewports rather than wrapping to four lines. */ + font-size: clamp(1.75rem, 6vw, var(--learn-title-size, 2.375rem)); +` + +const Subhead = styled.p` + color: var(--maple-text-body); + max-width: var(--learn-subhead-max-width, 34rem); + font-size: 1rem; + line-height: 1.6; + margin-bottom: 0; +` + +/** + * Eyebrow + display heading + subhead, shared by the Learn hub and its + * sub-pages. `eyebrow` is omitted on sub-pages, which show a breadcrumb instead. + * + * `subheadMaxWidth` defaults to a readable measure. Pass "none" to let the + * subhead run the full width of the page column. + */ +export const LearnHeader = ({ + eyebrow, + title, + subhead, + titleSize = "2.375rem", + subheadMaxWidth +}: { + eyebrow?: string + title: React.ReactNode + subhead?: React.ReactNode + titleSize?: string + subheadMaxWidth?: string +}) => ( +
    + {eyebrow && {eyebrow}} + + {title} + + {subhead && ( + + {subhead} + + )} +
    +) + +export default LearnHeader diff --git a/components/learn/LearnLayout.tsx b/components/learn/LearnLayout.tsx new file mode 100644 index 000000000..417808c76 --- /dev/null +++ b/components/learn/LearnLayout.tsx @@ -0,0 +1,41 @@ +import { PropsWithChildren } from "react" +import styled from "styled-components" + +type Width = "narrow" | "medium" | "wide" + +const maxWidths: Record = { + narrow: "48rem", // 768px — process page + medium: "56rem", // 896px — testimony page + wide: "64rem" // 1024px — hub +} + +const Page = styled.div` + background-color: var(--maple-surface-learn); + min-height: 100vh; +` + +const Inner = styled.div<{ $width: Width }>` + max-width: ${p => maxWidths[p.$width]}; + margin: 0 auto; + padding: 3.5rem 2rem; + + @media (max-width: 36rem) { + padding: 2rem 1rem; + } +` + +/** + * Page shell shared by every page in the Learn section: the section background + * and a width-constrained column. Maple's Navbar and Footer are applied + * separately by `applyLayout` in components/page.tsx. + */ +export const LearnLayout = ({ + width = "medium", + children +}: PropsWithChildren<{ width?: Width }>) => ( + + {children} + +) + +export default LearnLayout diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx new file mode 100644 index 000000000..c00469119 --- /dev/null +++ b/components/learn/Process/LegislativeProcess.tsx @@ -0,0 +1,805 @@ +import { useTranslation } from "next-i18next" +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState +} from "react" +import styled from "styled-components" +import AdditionalResources from "../../AdditionalResources" +import { Internal } from "../../links" +import LearnBreadcrumb from "../LearnBreadcrumb" +import LearnHeader from "../LearnHeader" +import LearnLayout from "../LearnLayout" +import { + CheckIcon, + ChevronDownIcon, + ChevronUpIcon, + ExternalLinkIcon +} from "../icons" +import { STAGE_ICONS } from "../icons/ProcessStageIcons" +import { STAGE_COLORS, TINT, alpha } from "../palette" + +type Stat = { value: string; label: string; href?: string } +type Chapter = { + num: string + title: string + lead: string + body: string + stat?: Stat + callout?: string +} + +const prefersReducedMotion = () => + typeof window !== "undefined" && + !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches + +const rowId = (i: number) => `learn-process-stage-${i}` +const panelId = (i: number) => `learn-process-panel-${i}` +const headerId = (i: number) => `learn-process-header-${i}` + +/* ── Sticky journey rail ─────────────────────────────────────── */ + +const RailWrapper = styled.div` + position: sticky; + /* Measured at runtime — see the effect in LegislativeProcess. Falls back to a + sensible constant before the first layout pass. */ + top: var(--maple-navbar-height, 6rem); + z-index: 2; + margin-inline: -2rem; + padding: 0.5rem 2rem 1rem; + background-color: var(--maple-surface-learn); + + @media (max-width: 36rem) { + margin-inline: -1rem; + padding: 0.5rem 1rem 0.75rem; + } +` + +const RailCard = styled.div` + position: relative; + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + padding: 1.25rem 1.5rem; + + /* The rail scrolls horizontally on narrow screens and its scrollbar is + hidden, so fade the trailing edge to signal there is more to see. */ + @media (max-width: 34rem) { + &::after { + content: ""; + position: absolute; + inset-block: 1px; + right: 1px; + width: 2.5rem; + pointer-events: none; + border-radius: 0 var(--maple-radius-xl) var(--maple-radius-xl) 0; + background: linear-gradient( + to right, + transparent, + var(--maple-surface-base) + ); + } + } +` + +const RailTrack = styled.ol` + position: relative; + display: flex; + justify-content: space-between; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; + + /* The connecting line sits behind the nodes, inset so it doesn't poke out. */ + &::before { + content: ""; + position: absolute; + top: 1.6875rem; + left: 1.5rem; + right: 1.5rem; + height: 2px; + background: var(--bs-gray-200, #f3f4f6); + z-index: 0; + } + + /* Six nodes don't fit below ~34rem. Scroll the journey rather than wrap it, + so the line keeps reading as a single sequence. */ + @media (max-width: 34rem) { + justify-content: flex-start; + overflow-x: auto; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + &::before { + right: auto; + width: max(100%, 30rem); + } + } +` + +const RailNode = styled.li` + position: relative; + z-index: 1; + flex: 0 0 auto; + + button { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + width: 4.5rem; + padding: 0; + background: none; + border: 0; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--bs-blue); + outline-offset: 3px; + border-radius: var(--maple-radius-md); + } + } + + .node { + width: 3.375rem; + height: 3.375rem; + border-radius: var(--maple-radius-pill); + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--maple-shadow-sm); + transition: background 0.2s ease, border-color 0.2s ease, + transform 0.2s ease; + } + + .label { + font-size: 0.6875rem; + font-weight: 700; + line-height: 1.2; + text-align: center; + transition: color 0.2s ease; + } + + @media (prefers-reduced-motion: reduce) { + .node { + transition: none; + transform: none !important; + } + } +` + +const HeaderAnchor = styled.div` + scroll-margin-top: calc(var(--maple-navbar-height) + 0.75rem); +` + +const ExpandAllButton = styled.button` + display: inline; + padding: 0; + margin: 0.5rem 0 0; + background: none; + border: 0; + color: var(--bs-blue); + font-weight: 400; + text-decoration: underline; + cursor: pointer; + + &:hover { + text-decoration: none; + } + + &:focus-visible { + outline: 2px solid var(--bs-blue); + outline-offset: 2px; + border-radius: var(--maple-radius-sm); + } +` + +/* A `position: sticky` element can only stick within its containing block. + Scoping the rail to the chapters means it releases and scrolls away once the + last chapter passes, rather than hovering over Additional Resources. */ +const StickyScope = styled.div` + position: relative; +` + +/* ── Accordion ───────────────────────────────────────────────── */ + +const Row = styled.div` + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + overflow: hidden; + + /* Smooth-scroll targets must clear the sticky navbar and the sticky rail + when they align to the top, and keep a little air when they align to the + bottom (scrollIntoView block: "nearest"). */ + scroll-margin-top: calc( + var(--maple-navbar-height) + var(--learn-rail-height, 10rem) + 1rem + ); + scroll-margin-bottom: 1rem; +` + +const RowHeader = styled.h3` + margin: 0; + + button { + width: 100%; + display: flex; + align-items: center; + gap: 1.25rem; + padding: 1.25rem 1.75rem; + text-align: left; + background: none; + border: 0; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: color-mix( + in srgb, + var(--bs-gray-100, #f9fafb) 60%, + transparent + ); + } + + &:focus-visible { + outline: 2px solid var(--bs-blue); + outline-offset: -2px; + } + } + + .num { + font-family: var(--maple-font-heading); + font-weight: 900; + font-size: 2.25rem; + line-height: 1; + flex-shrink: 0; + width: 3rem; + transition: color 0.2s ease; + } + + .spine { + width: 4px; + align-self: stretch; + border-radius: var(--maple-radius-pill); + flex-shrink: 0; + transition: background 0.2s ease; + } + + .text { + flex: 1; + min-width: 0; + } + + .title { + font-family: var(--maple-font-heading); + font-weight: 700; + color: var(--maple-text-strong); + font-size: 1.125rem; + line-height: 1.35; + margin: 0; + } + + .lead { + color: var(--maple-text-muted); + font-size: 0.875rem; + line-height: 1.35; + margin: 0.125rem 0 0; + } + + .chevron { + flex-shrink: 0; + width: 2rem; + height: 2rem; + border-radius: var(--maple-radius-pill); + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; + } + + @media (max-width: 36rem) { + button { + gap: 0.75rem; + padding: 1rem; + } + .num { + font-size: 1.75rem; + width: 2.25rem; + } + } +` + +const Panel = styled.div` + padding: 0 1.75rem 1.75rem; + + @keyframes learn-panel-in { + from { + opacity: 0; + transform: translateY(-0.375rem); + } + to { + opacity: 1; + transform: none; + } + } + + .inner { + display: flex; + flex-direction: column; + gap: 1rem; + padding-top: 1rem; + padding-left: 5.5rem; + animation: learn-panel-in 0.24s ease both; + animation-delay: var(--learn-panel-delay, 0ms); + } + + @media (prefers-reduced-motion: reduce) { + .inner { + animation: none; + } + } + + .body { + color: var(--maple-text-body); + line-height: 1.7; + white-space: pre-line; + margin: 0; + } + + .stat { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 0; + border-top: 1px solid var(--maple-surface-border); + } + + .stat-value { + font-family: var(--maple-font-heading); + font-weight: 700; + font-size: 1.875rem; + } + + .stat-label { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-size: 0.875rem; + color: var(--maple-text-muted); + text-decoration: none; + + &:hover { + color: var(--bs-blue); + text-decoration: underline; + } + } + + .callout { + border-radius: var(--maple-radius-lg); + padding: 1rem 1.25rem; + display: flex; + align-items: flex-start; + gap: 0.75rem; + + p { + font-weight: 600; + font-size: 0.875rem; + line-height: 1.7; + margin: 0; + } + } + + @media (max-width: 36rem) { + padding: 0 1rem 1.25rem; + .inner { + padding-left: 0; + } + } +` + +const StatLabel = ({ stat }: { stat: Stat }) => { + if (!stat.href) return {stat.label} + + if (stat.href.startsWith("/")) + return ( + + {stat.label} + + ) + + return ( + + {stat.label} + + + ) +} + +const AccordionRow = ({ + index, + chapter, + color, + isOpen, + isActive, + onToggle, + stagger = 0 +}: { + index: number + chapter: Chapter + color: string + isOpen: boolean + isActive: boolean + onToggle: () => void + /** Index used to stagger the open animation when several panels open at once. */ + stagger?: number +}) => ( + + + + + + + +) + +export const LegislativeProcess = () => { + const { t } = useTranslation("learn") + const chapters = t("process.chapters", { returnObjects: true }) as Chapter[] + const stages = t("process.stages", { returnObjects: true }) as string[] + + const [activeIdx, setActiveIdx] = useState(0) + const [openIdx, setOpenIdx] = useState(0) + // "Expand all" is a temporary override. Any interaction with a rail node or a + // chapter header drops back to the normal one-at-a-time accordion. + const [expandAll, setExpandAll] = useState(false) + + const isOpen = (i: number) => expandAll || openIdx === i + + const railRef = useRef(null) + const trackRef = useRef(null) + const scopeRef = useRef(null) + // Set when a rail node is clicked, so that expanding a row from its own + // header does not also yank the page around. + const pendingScrollRef = useRef(null) + // Set when "expand all" is pressed, so the subhead is brought to the top. + const pendingHeaderScrollRef = useRef(false) + const headerRef = useRef(null) + + // The rail must sit directly beneath whatever remains of the site navbar. + // + // A one-off height measurement is not enough: the navbar's `sticky-top` does + // not always keep it pinned (it scrolls away on the mobile navbar, and its + // stickiness can be defeated by an ancestor), which would leave the rail + // floating with a strip of page content visible above it. So the offset + // tracks the navbar's *current* bottom edge on scroll, clamped at zero, and + // the rail hugs the very top once the navbar has scrolled out of view. + useLayoutEffect(() => { + const scope = scopeRef.current + if (!scope) return + + let frame = 0 + const publishNavOffset = () => { + const nav = document.querySelector(".main-navbar") + const bottom = nav ? nav.getBoundingClientRect().bottom : 0 + const offset = Math.max(0, bottom) + scope.style.setProperty("--maple-navbar-height", `${offset}px`) + } + const schedule = () => { + if (frame) return + frame = requestAnimationFrame(() => { + frame = 0 + publishNavOffset() + }) + } + + publishNavOffset() + window.addEventListener("scroll", schedule, { passive: true }) + window.addEventListener("resize", schedule) + + const nav = document.querySelector(".main-navbar") + const observer = new ResizeObserver(schedule) + if (nav) observer.observe(nav) + + return () => { + if (frame) cancelAnimationFrame(frame) + window.removeEventListener("scroll", schedule) + window.removeEventListener("resize", schedule) + observer.disconnect() + } + }, []) + + // Publish the rail's real height so rows can reserve scroll-margin beneath it. + // Measured rather than assumed, because the rail grows when stage labels wrap. + // It is written to the shared ancestor: custom properties inherit down the + // tree, and the rows are siblings of the rail, not its children. + useLayoutEffect(() => { + const rail = railRef.current + const scope = scopeRef.current + if (!rail || !scope) return + const publish = () => + scope.style.setProperty("--learn-rail-height", `${rail.offsetHeight}px`) + publish() + const observer = new ResizeObserver(publish) + observer.observe(rail) + return () => observer.disconnect() + }, []) + + useEffect(() => { + const target = pendingScrollRef.current + if (target === null) return + pendingScrollRef.current = null + + const el = document.getElementById(rowId(target)) + if (!el) return + + const reduceMotion = prefersReducedMotion() + // "nearest" scrolls the least distance that brings the whole card into + // view. A card below the fold rises just far enough to be fully visible + // rather than jumping to the top of the viewport; a card above the fold + // aligns to its top, where scroll-margin-top clears the navbar and rail. + el.scrollIntoView({ + behavior: reduceMotion ? "auto" : "smooth", + block: "nearest" + }) + }, [openIdx]) + + useEffect(() => { + if (!expandAll || !pendingHeaderScrollRef.current) return + pendingHeaderScrollRef.current = false + const el = headerRef.current + if (!el) return + el.scrollIntoView({ + behavior: prefersReducedMotion() ? "auto" : "smooth", + block: "start" + }) + }, [expandAll]) + + // On narrow screens the rail overflows horizontally. Keep the active node + // visible, whichever way the stage was selected. + useEffect(() => { + const track = trackRef.current + if (!track || track.scrollWidth <= track.clientWidth) return + + const node = track.children[activeIdx] as HTMLElement | undefined + if (!node) return + + const reduceMotion = prefersReducedMotion() + track.scrollTo({ + left: node.offsetLeft - (track.clientWidth - node.offsetWidth) / 2, + behavior: reduceMotion ? "auto" : "smooth" + }) + }, [activeIdx]) + + const selectStage = useCallback((i: number) => { + pendingScrollRef.current = i + setExpandAll(false) + setActiveIdx(i) + setOpenIdx(i) + }, []) + + const toggleChapter = useCallback( + (i: number) => { + if (expandAll) { + // Leaving expand-all: keep the chapter the reader just reached for. + setExpandAll(false) + setOpenIdx(i) + setActiveIdx(i) + return + } + setOpenIdx(prev => { + const next = prev === i ? null : i + if (next !== null) setActiveIdx(next) + return next + }) + }, + [expandAll] + ) + + // Expand-only. Any rail node or chapter header returns to the normal + // one-at-a-time accordion. + const expandAllSections = useCallback(() => { + setExpandAll(true) + pendingHeaderScrollRef.current = true + }, []) + + return ( + // "medium" rather than "narrow": the h1 wraps at 48rem. + +
    + + + + {t("process.subhead")}{" "} + panelId(i)).join(" ")} + > + {t("process.expandAll")} + + + } + /> + + + + + + + + + +
    + {chapters.map((chapter, i) => ( + toggleChapter(i)} + stagger={expandAll ? i : 0} + /> + ))} +
    +
    + + +
    +
    + ) +} + +export default LegislativeProcess diff --git a/components/learn/Testimony/AboutTestimony.tsx b/components/learn/Testimony/AboutTestimony.tsx new file mode 100644 index 000000000..3dd7e7c37 --- /dev/null +++ b/components/learn/Testimony/AboutTestimony.tsx @@ -0,0 +1,456 @@ +import { useTranslation } from "next-i18next" +import { useEffect, useRef, useState } from "react" +import styled from "styled-components" +import LearnBreadcrumb from "../LearnBreadcrumb" +import LearnHeader from "../LearnHeader" +import LearnLayout from "../LearnLayout" +import { ChevronDownIcon, ChevronUpIcon } from "../icons" +import { + HowBlob, + WhatBlob, + WhenBlob, + WhereBlob, + WhoBlob, + WhyBlob +} from "../icons/Blobs" +import { WHY_ICONS } from "../icons/WhyItMattersIcons" + +type CardData = { badge: string; headline: string; body: string } +type Matter = { title: string; body: string } +type StepData = { + title: string + body: string + linkLabel?: string + linkText?: string + href?: string +} +type Tip = { label: string; body: string } + +const BADGES: Record JSX.Element> = { + who: WhoBlob, + what: WhatBlob, + when: WhenBlob, + where: WhereBlob +} + +const Card = styled.div` + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + overflow: hidden; + height: 100%; + + .head { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1.5rem 1.5rem 1rem; + } + + h2 { + font-weight: 700; + color: var(--maple-text-strong); + font-size: 1rem; + line-height: 1.2; + margin: 0; + } + + .body { + padding: 0 1.5rem 1.5rem; + color: var(--maple-text-muted); + line-height: 1.45; + margin: 0; + } +` + +const MatterRow = styled.div` + display: flex; + align-items: flex-start; + gap: 1.25rem; + padding: 1.125rem 1.5rem; + border: 1px solid var(--maple-surface-border); + border-radius: var(--maple-radius-lg); + + & + & { + margin-top: 0.5rem; + } + + .title { + font-weight: 600; + color: #1c2b3a; + line-height: 1.5; + margin: 0 0 0.25rem; + } + + .text { + color: var(--maple-text-muted); + line-height: 1.45; + margin: 0; + } +` + +const NumBadge = styled.span` + flex-shrink: 0; + width: 1.75rem; + height: 1.75rem; + border-radius: var(--maple-radius-pill); + background: var(--maple-surface-learn); + border: 1px solid #cccfdd; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--maple-font-heading); + font-size: 1rem; + line-height: 1; + color: #000; +` + +const Step = styled.div` + display: flex; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1.5rem; + + &:last-child { + margin-bottom: 0; + } + + .title { + font-weight: 600; + color: #1c2b3a; + line-height: 1.5; + margin: 0 0 0.25rem; + } + + .text { + color: var(--maple-text-muted); + line-height: 1.45; + margin: 0 0 0.75rem; + } + + a { + color: var(--bs-blue); + text-decoration: underline; + } +` + +const TipsPanel = styled.div` + border: 1px solid rgba(28, 43, 58, 0.12); + border-radius: var(--maple-radius-lg); + overflow: hidden; + background-color: #fdfaf5; + + button { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 1rem; + text-align: left; + background: none; + border: 0; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--bs-blue); + outline-offset: -2px; + } + } + + .toggle-label { + font-weight: 600; + color: #1c2b3a; + font-size: 0.875rem; + line-height: 1.5; + margin: 0; + } + + .content { + padding: 0 1rem 0.75rem; + } + + .intro { + color: var(--maple-text-muted); + font-size: 0.875rem; + line-height: 1.6; + margin-bottom: 0.75rem; + } + + .tip { + padding: 0.5rem 0; + } + + .tip-label { + font-weight: 600; + color: #1c2b3a; + font-size: 0.875rem; + line-height: 1.5; + margin: 0; + } + + .tip-body { + color: var(--maple-text-muted); + font-size: 0.875rem; + line-height: 1.6; + margin: 0; + } +` + +const Panel = styled.div` + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + overflow: hidden; + + .head { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1.5rem 1.5rem 1rem; + } + + h2 { + font-weight: 700; + color: var(--maple-text-strong); + font-size: 1rem; + line-height: 1.2; + margin: 0; + } + + .body { + padding: 0 1.5rem 1.5rem; + } + + .lede { + color: var(--maple-text-muted); + line-height: 1.45; + } +` + +/* The HOW panel fades in as the reader reaches "Ready to get started?". + It is only ever hidden once JS has mounted and confirmed the reader has not + asked for reduced motion, so no-JS and reduced-motion visitors always see it + immediately and screen readers never lose it. */ +const Reveal = styled.div<{ $hidden: boolean }>` + opacity: ${p => (p.$hidden ? 0 : 1)}; + transform: ${p => (p.$hidden ? "translateY(2rem)" : "none")}; + transition: opacity 0.85s ease-out, transform 0.85s ease-out; + + @media (prefers-reduced-motion: reduce) { + opacity: 1; + transform: none; + transition: none; + } +` + +const ReadyToStart = styled.p` + font-weight: 700; + color: #2f3031; + font-size: 1.125rem; + text-align: center; + margin: 1.5rem 0; +` + +const Tips = ({ + toggle, + intro, + tips +}: { + toggle: string + intro: string + tips: Tip[] +}) => { + const [open, setOpen] = useState(false) + return ( + + + + + ) +} + +const useRevealOnScroll = () => { + const sentinelRef = useRef(null) + // Starts visible: only enhanced once we know JS is running. + const [hidden, setHidden] = useState(false) + + useEffect(() => { + const reduceMotion = window.matchMedia?.( + "(prefers-reduced-motion: reduce)" + ).matches + if (reduceMotion || typeof IntersectionObserver === "undefined") return + + const sentinel = sentinelRef.current + if (!sentinel) return + + // If the reader has already scrolled to the sentinel (deep link, refresh + // mid-page), leave the section visible rather than hiding it to animate. + const TRIGGER_POINT = 0.45 // sentinel must reach 45% up the viewport + if ( + sentinel.getBoundingClientRect().top < + window.innerHeight * TRIGGER_POINT + ) + return + + setHidden(true) + const observer = new IntersectionObserver( + entries => { + if (entries.some(e => e.isIntersecting)) { + setHidden(false) + observer.disconnect() + } + }, + // Negative bottom margin shrinks the observed area, so the reveal waits + // until the sentinel has travelled well up the viewport. + { rootMargin: "0px 0px -55% 0px" } + ) + observer.observe(sentinel) + return () => observer.disconnect() + }, []) + + return { sentinelRef, hidden } +} + +export const AboutTestimony = () => { + const { t } = useTranslation("learn") + const { sentinelRef, hidden } = useRevealOnScroll() + + const cards = t("testimony.cards", { returnObjects: true }) as CardData[] + const matters = t("testimony.why.matters", { + returnObjects: true + }) as Matter[] + const steps = t("testimony.how.steps", { returnObjects: true }) as StepData[] + const tips = t("testimony.tips", { returnObjects: true }) as Tip[] + + return ( + + + + + {/* WHO / WHAT / WHEN / WHERE */} +
    + {cards.map(card => { + const Badge = BADGES[card.badge] + return ( +
    + +
    + +

    {card.headline}

    +
    +

    {card.body}

    +
    +
    + ) + })} +
    + + {/* WHY */} + +
    + +

    {t("testimony.why.headline")}

    +
    +
    +

    {t("testimony.why.body")}

    +

    + {t("testimony.why.mattersHeading")} +

    + {matters.map((item, i) => { + const Icon = WHY_ICONS[i] + return ( + + +
    +

    {item.title}

    +

    {item.body}

    +
    +
    + ) + })} +
    +
    + + + {t("testimony.readyToStart")} + + + {/* HOW */} + + +
    + +

    {t("testimony.how.headline")}

    +
    +
    +

    {t("testimony.how.intro")}

    + + {steps.map((step, i) => ( + + +
    +

    {step.title}

    +

    {step.body}

    + + {step.href && ( +

    + {step.linkLabel}{" "} + + {step.linkText} + +

    + )} + + {i === 0 && ( + + )} +
    +
    + ))} +
    +
    +
    +
    + ) +} + +export default AboutTestimony diff --git a/components/learn/WhyUseMaple/WhyUseMaple.tsx b/components/learn/WhyUseMaple/WhyUseMaple.tsx new file mode 100644 index 000000000..fe4017e99 --- /dev/null +++ b/components/learn/WhyUseMaple/WhyUseMaple.tsx @@ -0,0 +1,308 @@ +import { useTranslation } from "next-i18next" +import styled from "styled-components" +import LearnBreadcrumb from "../LearnBreadcrumb" +import LearnHeader from "../LearnHeader" +import LearnLayout from "../LearnLayout" +import { CheckIcon, MegaphoneIcon, ScaleIcon, UsersIcon } from "../icons" +import { GREEN, NAVY, ORANGE, alpha } from "../palette" + +/** + * "Who Uses MAPLE" from the Figma prototype: three persona cards above a tinted + * hero and a two-column "What we offer" checklist. + * + * The copy is maple's own. The prototype's `personas` array was derived from + * these namespaces — its taglines are `callToAction.title`, its "why" text is + * `callToAction.bodytext`, and its feature bullets are the `benefits.*.title` + * values — so nothing here is newly written. + * + * Routing is unchanged: each persona keeps its own URL under /why-use-maple. + */ + +export type Persona = { + slug: string + ns: string + color: string + Icon: typeof UsersIcon + benefits: string[] + /** callToAction body keys, in render order. */ + body: string[] +} + +export const PERSONAS: Persona[] = [ + { + slug: "for-individuals", + ns: "forindividuals", + color: NAVY, + Icon: UsersIcon, + benefits: [ + "youMatter", + "multipleSides", + "trustedOrgs", + "anyLanguage", + "stayInformed" + ], + body: ["bodytext"] + }, + { + slug: "for-orgs", + ns: "fororgs", + color: GREEN, + Icon: MegaphoneIcon, + benefits: [ + "reach", + "connect", + "language", + "coordinate", + "seeEveryone", + "curateHistory", + "legislativeResearch", + "changeNorms", + "signUp" + ], + body: ["bodytext"] + }, + { + slug: "for-legislators", + ns: "forlegislators", + color: ORANGE, + Icon: ScaleIcon, + benefits: [ + "constituents", + "seeTestimony", + "simplifyTestimony", + "languageAccess", + "advancedStatistics" + ], + body: ["bodytextOne", "bodytextTwo"] + } +] + +const PersonaGrid = styled.div` + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; + margin-bottom: 1.25rem; + + @media (max-width: 48rem) { + grid-template-columns: 1fr; + } +` + +const PersonaCard = styled.button<{ $color: string; $active: boolean }>` + border-radius: var(--maple-radius-lg); + padding: 0.875rem 1rem; + text-align: left; + border: 2px solid ${p => (p.$active ? p.$color : "transparent")}; + background-color: ${p => + p.$active ? p.$color : "var(--maple-surface-base)"}; + box-shadow: var(--maple-shadow-sm); + cursor: pointer; + transition: background-color 0.2s ease, border-color 0.2s ease; + + .glyph { + display: block; + margin-bottom: 0.25rem; + color: ${p => (p.$active ? "#fff" : "var(--bs-gray-400, #9ca3af)")}; + } + + .label { + font-weight: 700; + font-size: 0.8125rem; + margin: 0; + color: ${p => (p.$active ? "#fff" : "#374151")}; + } + + &:hover { + border-color: ${p => p.$color}; + } + + &:focus-visible { + outline: 2px solid var(--bs-blue); + outline-offset: 3px; + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +` + +const Detail = styled.div` + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + overflow: hidden; +` + +const Hero = styled.div<{ $color: string }>` + padding: 1.5rem 1.75rem; + background-color: ${p => alpha(p.$color, 5)}; + + .tagline { + font-family: var(--maple-font-heading); + font-weight: 900; + font-size: 1.25rem; + line-height: 1.3; + color: ${p => p.$color}; + margin-bottom: 0.5rem; + } + + .why { + color: var(--maple-text-body); + font-size: 0.9375rem; + line-height: 1.6; + margin-bottom: 0; + } + + .why + .why { + margin-top: 0.75rem; + } + + @media (max-width: 36rem) { + padding: 1.25rem; + } +` + +const Offer = styled.div` + padding: 2.25rem 2rem 2.5rem; + + h2 { + font-family: var(--maple-font-heading); + font-weight: 700; + color: var(--maple-text-strong); + font-size: 1.375rem; + margin-bottom: 1.5rem; + } + + ul { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.125rem 1.5rem; + margin: 0; + padding: 0; + list-style: none; + + @media (max-width: 48rem) { + grid-template-columns: 1fr; + } + } + + li { + display: flex; + align-items: flex-start; + gap: 0.75rem; + } + + .tick { + flex-shrink: 0; + margin-top: 0.125rem; + width: 1.5rem; + height: 1.5rem; + border-radius: var(--maple-radius-pill); + display: flex; + align-items: center; + justify-content: center; + } + + .feature { + color: var(--maple-text-body); + font-size: 1rem; + line-height: 1.55; + } + + @media (max-width: 36rem) { + padding: 1.25rem; + } +` + +export const WhyUseMaple = ({ + slug, + onSelect +}: { + slug: string + onSelect: (slug: string) => void +}) => { + const { t } = useTranslation([ + "learn", + "forindividuals", + "fororgs", + "forlegislators" + ]) + + const active = PERSONAS.find(p => p.slug === slug) ?? PERSONAS[0] + + return ( + + + + + + {PERSONAS.map(persona => { + const isActive = persona.slug === active.slug + return ( + onSelect(persona.slug)} + $color={persona.color} + $active={isActive} + > + + ) + })} + + + + +

    {t(`${active.ns}:callToAction.title`)}

    + {active.body.map(key => ( +

    + {t(`${active.ns}:callToAction.${key}`)} +

    + ))} +
    + + +

    {t(`${active.ns}:benefits.header`)}

    +
      + {active.benefits.map(key => ( +
    • + + + {t(`${active.ns}:benefits.${key}.title`)} + +
    • + ))} +
    +
    +
    +
    + ) +} + +export default WhyUseMaple diff --git a/components/learn/icons/Blobs.tsx b/components/learn/icons/Blobs.tsx new file mode 100644 index 000000000..1c7db48ba --- /dev/null +++ b/components/learn/icons/Blobs.tsx @@ -0,0 +1,181 @@ +import styled from "styled-components" +import svgPaths from "./svgPaths" + +// The WHO/WHAT/WHEN/WHERE/WHY/HOW badges from the Figma prototype. +// +// The prototype positioned each shape with Tailwind's `hypot()` sizing and +// `container-type: size`, which is how Figma expresses a rotated path inside a +// bounding box. A rotated reaches the same result without Tailwind. + +const Badge = styled.div<{ $w: number; $h: number }>` + position: relative; + flex-shrink: 0; + width: ${p => p.$w}px; + height: ${p => p.$h}px; + + svg { + position: absolute; + display: block; + } + + .label { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-weight: 900; + font-size: 12.45px; + line-height: 1; + letter-spacing: 1.5px; + color: #fff; + white-space: nowrap; + padding-left: 1.5px; + } +` + +type BlobProps = { + label: string + color: string + path: string + viewBox: string + /** Outer badge box. */ + w: number + h: number + /** The shape's own box, positioned within the badge. */ + shape: { w: number; h: number; left: number; top: number } + rotate?: number + /** + * Rotating a shape grows its bounding box, so a rotated blob would otherwise + * render noticeably larger than an unrotated one. Rotated blobs are centred + * in the badge and scaled down. Note the scale is not a pure bounding-box + * match: a rotated bbox includes empty diagonal corners, so matching bbox + * width would leave the visible shape smaller than its unrotated siblings. + */ + scale?: number +} + +const Blob = ({ + label, + color, + path, + viewBox, + w, + h, + shape, + rotate, + scale +}: BlobProps) => { + const transform = rotate + ? `translate(-50%, -50%) rotate(${rotate}deg) scale(${scale ?? 1})` + : undefined + + return ( + + + {label} + + ) +} + +export const WhoBlob = () => ( + +) + +export const WhatBlob = () => ( + +) + +export const WhenBlob = () => ( + +) + +export const WhereBlob = () => ( + +) + +export const WhyBlob = () => ( + +) + +export const HowBlob = () => ( + +) diff --git a/components/learn/icons/ProcessStageIcons.tsx b/components/learn/icons/ProcessStageIcons.tsx new file mode 100644 index 000000000..e22066b61 --- /dev/null +++ b/components/learn/icons/ProcessStageIcons.tsx @@ -0,0 +1,836 @@ +// Legislative-process stage icons, exported verbatim from the Figma prototype +// (maple-learn-tab/src/app/sections/ProcessSection.tsx). +// +// Each SVG's internal mask ids are namespaced per stage so that all six can be +// rendered in the same document without colliding. The icons are decorative: +// the visible stage label provides the accessible name. +// +// One repair vs. the prototype: IntroductionIcon carried a cubic command with +// four parameters instead of six (`C x1 y1 x2 y2 L x y`). Chrome rejected the +// whole `d` attribute and dropped the remainder of that path. The trailing L +// point is now the curve's endpoint, which is what the geometry implies. + +import React from "react" + +export const IntroductionIcon = () => ( + +) + +export const CommitteeReferralIcon = () => ( + +) + +export const PublicHearingIcon = () => ( + +) + +export const CommitteeReportIcon = () => ( + +) + +export const FloorDebateIcon = () => ( + +) + +export const GovernorActionIcon = () => ( + +) + +/** Ordered to match the six stages in the process rail. */ +export const STAGE_ICONS = [ + IntroductionIcon, + CommitteeReferralIcon, + PublicHearingIcon, + CommitteeReportIcon, + FloorDebateIcon, + GovernorActionIcon +] diff --git a/components/learn/icons/WhyItMattersIcons.tsx b/components/learn/icons/WhyItMattersIcons.tsx new file mode 100644 index 000000000..1ee9cda33 --- /dev/null +++ b/components/learn/icons/WhyItMattersIcons.tsx @@ -0,0 +1,74 @@ +import svgPaths from "./svgPaths" + +// The three "Why it Matters" icons on /learn/testimony, taken verbatim from the +// Figma prototype. Decorative: each is paired with a visible heading. +// +// These use a parchment/navy/ochre palette of their own rather than the brand +// colors, so the hexes stay literal. + +const size = { width: 40, height: 40 } +const a11y = { "aria-hidden": true, focusable: "false" } as const + +export const IconAgenda = () => ( + + + + + + +) + +export const IconInsight = () => ( + + + + + +) + +export const IconChanges = () => ( + + + + + +) + +export const WHY_ICONS = [IconAgenda, IconInsight, IconChanges] diff --git a/components/learn/icons/index.ts b/components/learn/icons/index.ts new file mode 100644 index 000000000..6e1a9bbb3 --- /dev/null +++ b/components/learn/icons/index.ts @@ -0,0 +1,19 @@ +// Icon aliases for the Learn section. +// +// The Figma prototype was built against lucide-react. Maple already ships +// @mui/icons-material, so the prototype's icons are mapped onto MUI equivalents +// here rather than adding a second icon library. Deep imports keep tree-shaking +// working. Swapping icon sets later means editing only this file. + +export { default as BookOpenIcon } from "@mui/icons-material/MenuBook" +// Bullhorn, for the Organizations persona. +export { default as MegaphoneIcon } from "@mui/icons-material/Campaign" +export { default as ScaleIcon } from "@mui/icons-material/Balance" +export { default as UsersIcon } from "@mui/icons-material/Groups" +export { default as BotIcon } from "@mui/icons-material/SmartToy" +export { default as ArrowRightIcon } from "@mui/icons-material/ArrowForward" +export { default as CheckIcon } from "@mui/icons-material/Check" +export { default as ExternalLinkIcon } from "@mui/icons-material/OpenInNew" +export { default as ChevronUpIcon } from "@mui/icons-material/KeyboardArrowUp" +export { default as ChevronDownIcon } from "@mui/icons-material/KeyboardArrowDown" +export { default as ChevronRightIcon } from "@mui/icons-material/ChevronRight" diff --git a/components/learn/icons/svgPaths.ts b/components/learn/icons/svgPaths.ts new file mode 100644 index 000000000..8cd3ef312 --- /dev/null +++ b/components/learn/icons/svgPaths.ts @@ -0,0 +1,33 @@ +// SVG path data for the Learn section's decorative blob badges and +// "why it matters" icons. Copied verbatim from the Figma prototype at +// maple-learn-tab/src/imports/RedesignLearnTab/svg-00izgm1xmm.ts +// +// Do not hand-edit: regenerate from the design export if the shapes change. + +export default { + p107a080: + "M7.33333 12.6667C10.2789 12.6667 12.6667 10.2789 12.6667 7.33333C12.6667 4.38781 10.2789 2 7.33333 2C4.38781 2 2 4.38781 2 7.33333C2 10.2789 4.38781 12.6667 7.33333 12.6667Z", + p1f124780: + "M15.0006 55.5237C-7.60404 55.177 -0.2398 25.4037 8.55088 12.8335C14.0072 5.03118 26.4299 0 34.6327 0C45.9175 0 44.9995 15.6705 55.1716 15.6705C69.8645 10.9024 74.3756 35.1512 63.224 43.8204C52.0724 52.4895 49.8465 66.7478 40.9893 65.4821C24.1262 63.0722 37.6051 55.8705 15.0006 55.5237Z", + p1f789000: + "M8.79496 59.4576C-13.7076 45.3061 14.0729 40.3717 14.0733 26.2199C14.0736 12.0681 11.5727 3.14063 29.5746 1.1887C47.5764 -0.763238 51.6589 -2.00737 58.1428 11.7359C61.2661 25.204 65.175 34.8839 58.8952 41.9109C52.6154 48.9378 45.9886 52.3718 39.8833 58.3902C33.7781 64.4087 26.797 70.7788 8.79496 59.4576Z", + p226cc7c0: "M13.9996 14.0001L11.0996 11.1001", + p276c5200: + "M10.0012 62.2137C-12.5014 48.0621 10.001 41.2304 10.0013 27.0786C10.0017 12.9269 14.5017 2.1917 32.5035 0.239764C50.5054 -1.71217 71.2288 8.53542 71.2289 20.7351C71.2289 30.0065 57.0994 32.7392 50.8196 39.7662C44.5398 46.7931 51.867 49.526 47.6801 59.7737C41.5748 65.7922 28.0033 73.5349 10.0012 62.2137Z", + p33cd4800: + "M23 22H17C16.4477 22 16 22.4477 16 23V31C16 31.5523 16.4477 32 17 32H23C23.5523 32 24 31.5523 24 31V23C24 22.4477 23.5523 22 23 22Z", + p38984180: + "M15.0022 50.0736C-7.60486 49.7831 -0.239825 24.8413 8.55179 14.3109C14.0087 7.77467 36.695 -4.31778 42.1625 1.57133C48.5679 8.47051 45.0043 16.6875 55.1774 16.6875C69.872 12.6932 74.3835 33.007 63.2308 40.2694C52.078 47.5318 50.198 58.3698 38.1349 58.3698C28.4351 58.3698 37.6092 50.3641 15.0022 50.0736Z", + p38dd600: + "M7.66718 39.2328C-5.57009 20.9043 0.27363 12.9213 10.5766 10.5446C25.433 7.11757 22.3179 -3.39192 40.1597 1.09792C49.2892 3.39533 46.5803 10.5446 57.2022 12.9213C67.9826 19.7762 68.7609 34.5632 59.4217 39.2328C47.5178 45.1847 52.2228 54.6035 40.1597 54.6035C30.4598 54.6035 12.7259 46.2372 7.66718 39.2328Z", + p3a03a000: + "M8 30V18L20 8L32 18V30C32 30.5304 31.7893 31.0391 31.4142 31.4142C31.0391 31.7893 30.5304 32 30 32H10C9.46957 32 8.96086 31.7893 8.58579 31.4142C8.21071 31.0391 8 30.5304 8 30Z", + p3d8d7100: + "M31 8H9C7.34315 8 6 9.34315 6 11V29C6 30.6569 7.34315 32 9 32H31C32.6569 32 34 30.6569 34 29V11C34 9.34315 32.6569 8 31 8Z", + p5075f20: + "M20 22C21.1046 22 22 21.1046 22 20C22 18.8954 21.1046 18 20 18C18.8954 18 18 18.8954 18 20C18 21.1046 18.8954 22 20 22Z", + pad44800: + "M20 34C27.732 34 34 27.732 34 20C34 12.268 27.732 6 20 6C12.268 6 6 12.268 6 20C6 27.732 12.268 34 20 34Z", + pe310000: + "M30 34C33.3137 34 36 31.3137 36 28C36 24.6863 33.3137 22 30 22C26.6863 22 24 24.6863 24 28C24 31.3137 26.6863 34 30 34Z" +} diff --git a/components/learn/palette.ts b/components/learn/palette.ts new file mode 100644 index 000000000..764cb01d2 --- /dev/null +++ b/components/learn/palette.ts @@ -0,0 +1,38 @@ +// Stage palette for the Learn section. +// +// The Figma prototype hardcoded NAVY #1A3185, GREEN #3D9922, ORANGE #FF8600 and +// CRIMSON #C71E32. Those are byte-identical to $blue/$green/$orange/$red in +// styles/bootstrap.scss, so we reference the emitted custom properties instead +// of repeating the hexes. + +export const NAVY = "var(--bs-blue)" +export const GREEN = "var(--bs-green)" +export const ORANGE = "var(--bs-orange)" +export const CRIMSON = "var(--bs-red)" + +/** + * Opaque tints used for a completed rail node's fill. These are design values + * from the prototype, not a computed percentage of the base color, so they are + * kept as literals rather than derived with color-mix. + */ +export const TINT: Record = { + [NAVY]: "#e1e4ef", + [GREEN]: "#e5f1e2", + [ORANGE]: "#ffefdd", + [CRIMSON]: "#f8e1e4" +} + +/** + * Translucent variant of a stage color. + * + * The prototype appended hex alpha to a hex string (`color + "40"`). That does + * not work on a `var(--bs-blue)` reference, so the same ratios are expressed as + * percentages through color-mix. + * + * 0x40 -> 25%, 0x28 -> 16%, 0x18 -> 9%, 0x12 -> 7% + */ +export const alpha = (color: string, percent: number) => + `color-mix(in srgb, ${color} ${percent}%, transparent)` + +/** Ordered stage colors, cycling navy -> green -> orange -> crimson. */ +export const STAGE_COLORS = [NAVY, GREEN, ORANGE, CRIMSON, NAVY, GREEN] diff --git a/components/publish/WriteTestimony.tsx b/components/publish/WriteTestimony.tsx index 859785f08..df322708a 100644 --- a/components/publish/WriteTestimony.tsx +++ b/components/publish/WriteTestimony.tsx @@ -84,7 +84,7 @@ export const WriteTestimony = styled(props => { } components={[ // eslint-disable-next-line react/jsx-key - + ]} /> diff --git a/next.config.js b/next.config.js index c8980b67e..8cdcb373b 100644 --- a/next.config.js +++ b/next.config.js @@ -17,7 +17,22 @@ const config = { module.exports = { ...config, async redirects() { - return [redirectFirebaseAuthHandlers()] + return [ + redirectFirebaseAuthHandlers(), + // The four Learn testimony tabs were consolidated into /learn/testimony. + // These slugs were linked from the footer and from external sites, so + // they redirect permanently rather than 404. + ...[ + "testimony-basics", + "role-of-testimony", + "writing-effective-testimony", + "communicating-with-legislators" + ].map(slug => ({ + source: `/learn/${slug}`, + destination: "/learn/testimony", + permanent: true + })) + ] }, async rewrites() { const rewrites = [ diff --git a/pages/learn/[slug].tsx b/pages/learn/[slug].tsx deleted file mode 100644 index 491619fb4..000000000 --- a/pages/learn/[slug].tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { useRouter } from "next/router" -import { createPage } from "../../components/page" -import { - Basics, - Role, - Write, - CommunicatingWithLegislators -} from "../../components/LearnTestimonyComponents/LearnComponents" -import Tabs from "../../components/Tabs/Tabs" -import { GetStaticPaths, GetStaticProps } from "next/types" -import { ParsedUrlQuery } from "querystring" -import { serverSideTranslations } from "next-i18next/serverSideTranslations" - -type TabsType = { - label: string - slug: string - index: number - Component: React.FC<{}> -} - -type Props = { - slug: string -} - -interface IParams extends ParsedUrlQuery { - slug: string -} - -const tabs: TabsType[] = [ - { - label: "Testimony Basics", - slug: "testimony-basics", - index: 1, - Component: Basics - }, - { - label: "The Role of Testimony", - slug: "role-of-testimony", - index: 2, - Component: Role - }, - { - label: "Writing Effective Testimony", - slug: "writing-effective-testimony", - index: 3, - Component: Write - }, - { - label: "Communicating with Legislators", - slug: "communicating-with-legislators", - index: 4, - Component: CommunicatingWithLegislators - } -] - -export default createPage<{ params: IParams }>({ - titleI18nKey: "learn", - Page: props => { - const router = useRouter() - - const { slug } = props.params as Props - - const [selectedTab] = tabs.filter(t => t.slug === slug) - - return ( - { - const [indexedTab] = tabs.filter(t => t.index === index) - if (indexedTab !== selectedTab) { - router.push(`/learn/${indexedTab.slug}`) - } - }} - tabs={tabs} - /> - ) - } -}) - -export const getStaticPaths: GetStaticPaths = async () => { - // Get the paths we want to prerender based on posts - // In production environments, prerender all pages - // (slower builds, but faster initial page load) - const paths = tabs.map(tab => ({ - params: { slug: tab.slug } - })) - - // { fallback: false } means other routes should 404 - return { paths, fallback: false } -} - -export const getStaticProps: GetStaticProps = async context => { - const params = context.params as IParams - const locale = context.locale ?? context.defaultLocale ?? "en" - - return { - props: { - params, - ...(await serverSideTranslations(locale, [ - "auth", - "common", - "footer", - "testimony", - "learnComponents" - ])) - } - } -} diff --git a/pages/learn/ai-tools.tsx b/pages/learn/ai-tools.tsx index da5b50b00..06dc0156c 100644 --- a/pages/learn/ai-tools.tsx +++ b/pages/learn/ai-tools.tsx @@ -1,22 +1,19 @@ -import { Container } from "../../components/bootstrap" import { createPage } from "../../components/page" import { createGetStaticTranslationProps } from "components/translations" import AiTools from "components/learn/AiTools/AiTools" +// Route and body are load-bearing: mcp-server/auth.ts and +// functions/src/mcp/proxy.ts send external MCP users to +// https://mapletestimony.org/learn/ai-tools for setup instructions. export default createPage({ titleI18nKey: "titles.ai_tools", - Page: () => { - return ( - - - - ) - } + Page: () => }) export const getStaticProps = createGetStaticTranslationProps([ "auth", "common", "footer", + "learn", "aiTools" ]) diff --git a/pages/learn/index.tsx b/pages/learn/index.tsx index a80f82b3c..07f9e3cf5 100644 --- a/pages/learn/index.tsx +++ b/pages/learn/index.tsx @@ -1,12 +1,15 @@ -import { useRouter } from "next/router" -import { useEffect } from "react" +import { createPage } from "../../components/page" +import LearnHub from "components/learn/Hub/LearnHub" +import { createGetStaticTranslationProps } from "components/translations" -export default function Page() { - const router = useRouter() +export default createPage({ + titleI18nKey: "titles.learn_hub", + Page: () => +}) - useEffect(() => { - router.push("/learn/testimony-basics") - }) - - return null -} +export const getStaticProps = createGetStaticTranslationProps([ + "auth", + "common", + "footer", + "learn" +]) diff --git a/pages/learn/legislative-process.tsx b/pages/learn/legislative-process.tsx index 76c892b74..c533a3369 100644 --- a/pages/learn/legislative-process.tsx +++ b/pages/learn/legislative-process.tsx @@ -1,26 +1,17 @@ -import AdditionalResources from "components/AdditionalResources" -import { Container } from "../../components/bootstrap" import { createPage } from "../../components/page" -import Legislative from "components/Legislative/Legislative" +import LegislativeProcess from "components/learn/Process/LegislativeProcess" import { createGetStaticTranslationProps } from "components/translations" -// TODO: Change export default createPage({ titleI18nKey: "titles.legislative_process", - Page: () => { - return ( - - - - - ) - } + Page: () => }) +// `learnComponents` stays: AdditionalResources reads its legislative.* keys. export const getStaticProps = createGetStaticTranslationProps([ "auth", "common", "footer", - "testimony", + "learn", "learnComponents" ]) diff --git a/pages/learn/testimony.tsx b/pages/learn/testimony.tsx new file mode 100644 index 000000000..ad65dbfdf --- /dev/null +++ b/pages/learn/testimony.tsx @@ -0,0 +1,15 @@ +import { createPage } from "../../components/page" +import AboutTestimony from "components/learn/Testimony/AboutTestimony" +import { createGetStaticTranslationProps } from "components/translations" + +export default createPage({ + titleI18nKey: "titles.about_testimony", + Page: () => +}) + +export const getStaticProps = createGetStaticTranslationProps([ + "auth", + "common", + "footer", + "learn" +]) diff --git a/pages/why-use-maple/[slug].tsx b/pages/why-use-maple/[slug].tsx index f0a2fe36b..f21512d2b 100644 --- a/pages/why-use-maple/[slug].tsx +++ b/pages/why-use-maple/[slug].tsx @@ -1,20 +1,10 @@ import { useRouter } from "next/router" import { createPage } from "../../components/page" -import ForIndividuals from "../../components/about/ForIndividuals/ForIndividuals" -import ForOrgs from "../../components/about/ForOrgs/ForOrgs" -import ForLegislators from "../../components/about/ForLegislators/ForLegislators" -import Tabs from "../../components/Tabs/Tabs" +import WhyUseMaple, { PERSONAS } from "components/learn/WhyUseMaple/WhyUseMaple" import { GetStaticPaths, GetStaticProps } from "next/types" import { ParsedUrlQuery } from "querystring" import { serverSideTranslations } from "next-i18next/serverSideTranslations" -type TabsType = { - label: string - slug: string - index: number - Component: React.FC<{}> -} - type Props = { slug: string } @@ -23,61 +13,40 @@ interface IParams extends ParsedUrlQuery { slug: string } -const tabs: TabsType[] = [ - { - label: "For Individuals", - slug: "for-individuals", - index: 1, - Component: ForIndividuals - }, - { - label: "For Organizations", - slug: "for-orgs", - index: 2, - Component: ForOrgs - }, - { - label: "For Legislators", - slug: "for-legislators", - index: 3, - Component: ForLegislators - } -] - export default createPage<{ params: IParams }>({ titleI18nKey: "titles.why_use_maple", Page: props => { const router = useRouter() - const { slug } = props.params as Props - - const [selectedTab] = tabs.filter(t => t.slug === slug) + // After a shallow navigation `props.params` is stale, so read the live + // route. It falls back to the build-time param for the first render. + const slug = + (router.query.slug as string | undefined) ?? (props.params as Props).slug + // Each persona keeps its own URL, so all three stay independently linkable + // and server-render with the right persona selected. + // + // The push is shallow: getStaticProps already loads all three persona + // namespaces for every slug, so re-fetching `/_next/data/.json` on + // each click would buy nothing and just add latency. return ( - { - const [indexedTab] = tabs.filter(t => t.index === index) - if (indexedTab !== selectedTab) { - router.push(`/why-use-maple/${indexedTab.slug}`) - } + { + if (next !== slug) + router.push(`/why-use-maple/${next}`, undefined, { shallow: true }) }} - tabs={tabs} /> ) } }) export const getStaticPaths: GetStaticPaths = async () => { - // Get the paths we want to prerender based on posts - // In production environments, prerender all pages - // (slower builds, but faster initial page load) - const paths = tabs.map(tab => ({ - params: { slug: tab.slug } - })) - - // { fallback: false } means other routes should 404 - return { paths, fallback: false } + return { + paths: PERSONAS.map(p => ({ params: { slug: p.slug } })), + // { fallback: false } means other routes should 404 + fallback: false + } } export const getStaticProps: GetStaticProps = async context => { @@ -91,7 +60,7 @@ export const getStaticProps: GetStaticProps = async context => { "auth", "common", "footer", - "testimony", + "learn", "forindividuals", "forlegislators", "fororgs" diff --git a/public/locales/en/common.json b/public/locales/en/common.json index ea6789c8f..ec1f29d0f 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -199,7 +199,9 @@ "policies": "Policies", "unsubscribe": "Unsubscribe", "why_use_maple": "Why Use Maple?", - "ai_tools": "AI Research Tools" + "ai_tools": "AI Research Tools", + "learn_hub": "Learn", + "about_testimony": "How Testimony Works" }, "user_updates": "User Updates", "view": "View", diff --git a/public/locales/en/footer.json b/public/locales/en/footer.json index 1423b0ee9..f827543b8 100644 --- a/public/locales/en/footer.json +++ b/public/locales/en/footer.json @@ -6,9 +6,10 @@ "resources": "Resources" }, "links": { - "learnWriting": "To Write Effective Testimony", + "learnWriting": "About Testimony", "learnProcess": "About the Legislative Process", "learnWhy": "Why Use MAPLE", + "learnAi": "AI Research Tools", "supportMaple": "Support MAPLE", "ourMission": "Mission & Goals", "team": "Team", @@ -16,10 +17,9 @@ "mapleAI": "How MAPLE Uses AI", "socials": { "instagram": "Follow MAPLE on Intagram", - "linkedin":"Follow MAPLE on LinkedIn", - "twitter":"Follow MAPLE on Twitter" + "linkedin": "Follow MAPLE on LinkedIn", + "twitter": "Follow MAPLE on Twitter" } - }, "legal": { "disclaimer": "MAPLE is an independent project, not affiliated with the Massachusetts legislature", diff --git a/public/locales/en/learn.json b/public/locales/en/learn.json new file mode 100644 index 000000000..dcd547483 --- /dev/null +++ b/public/locales/en/learn.json @@ -0,0 +1,202 @@ +{ + "breadcrumbLabel": "Breadcrumb", + "hub": { + "eyebrow": "Learn", + "title": "Your civic engagement guide", + "subhead": "Everything you need to understand how to make your voice heard in Massachusetts.", + "explore": "Explore", + "cards": [ + { + "id": "testimony", + "title": "About Testimony", + "desc": "Learn what testimony is, how to write it, and why it matters.", + "href": "/learn/testimony" + }, + { + "id": "process", + "title": "Legislative Process", + "desc": "Understand how a bill becomes a law in Massachusetts.", + "href": "/learn/legislative-process" + }, + { + "id": "why", + "title": "Why Use MAPLE", + "desc": "Whether you're a citizen, org, or legislator — MAPLE is for you.", + "href": "/why-use-maple/for-individuals" + }, + { + "id": "ai", + "title": "AI Research Tools", + "desc": "Search MAPLE's database with a conversational AI assistant.", + "href": "/learn/ai-tools" + } + ] + }, + "testimony": { + "breadcrumb": "About Testimony", + "title": "How Testimony Works", + "subhead": "All laws passed by state legislatures should consider feedback from residents and stakeholders. In Massachusetts, submitting written testimony to the legislature is one of the most direct ways to be heard.", + "cards": [ + { + "badge": "who", + "headline": "Anyone can submit testimony", + "body": "Legislators value testimony most when it comes from their own constituents. Testimony from MA residents is typically directed to both the committee responsible for the bill and the legislators representing your district." + }, + { + "badge": "what", + "headline": "Make it distinctive and relevant", + "body": "Write your own text and explain why you are personally interested in an issue. Form letters still count, but an individual, personalized letter will always have greater impact." + }, + { + "badge": "when", + "headline": "Before the committee hearing date", + "body": "Committees generally accept testimony up until the public hearing date for a bill. You can use bill pages on this site to identify relevant dates. Submit before the hearing for the greatest impact." + }, + { + "badge": "where", + "headline": "By email to committee chairs", + "body": "MAPLE makes it easy to find a bill you want to testify on and generate an email — which you fully control — to send to the relevant committee personnel." + } + ], + "why": { + "headline": "To shape the laws that govern your life", + "body": "If you don't share your perspective, it may not be taken into account when policymakers make decisions about the laws that govern all our lives. By speaking up, you can make the laws of Massachusetts work better for all of us. MAPLE is designed to make it more accessible.", + "mattersHeading": "Why it Matters:", + "matters": [ + { + "title": "Your voice shapes the legislative agenda", + "body": "It can guide what topics and bills the legislature considers, and how they decide to act and vote on each one." + }, + { + "title": "You give legislators real-world insight", + "body": "Testimony informs legislators of the benefits or negative consequences of proposed policies — context that doesn't always appear in the official record." + }, + { + "title": "You can recommend specific changes", + "body": "You can suggest concrete improvements to legislation, whether you generally support or oppose a bill. Specificity makes your testimony more actionable." + } + ] + }, + "readyToStart": "Ready to get started?", + "how": { + "headline": "To make your voice heard", + "intro": "Step through the process from picking a topic important to you, reading bills and ballot questions that impact you, deciding your own stance, and clicking through to submit. Once you are ready, here are your options:", + "steps": [ + { + "title": "Testify in writing", + "body": "Written testimony should target bills being considered by the legislature. All Committees should formally accept testimony on each bill in the time between the start of the legislative session and the public hearing of that bill." + }, + { + "title": "Testify in person", + "body": "You can attend a public hearing for a bill of interest to you and sign up for a slot to speak before the Committee.", + "linkLabel": "Check the calendar:", + "linkText": "malegislature.gov/Events/Calendar/", + "href": "https://malegislature.gov/Events/Calendar/" + }, + { + "title": "Call your legislator", + "body": "You can contact your legislators any time by looking up their contact information on the MA Legislature website. Your voice will probably carry the most weight with the House and Senate representatives of your own district, but you are free to contact Committee Chairs or any other member of the legislature with your opinions. You could request a meeting in person.", + "linkLabel": "Find your legislator's number:", + "linkText": "malegislature.gov/Search/FindMyLegislator", + "href": "https://malegislature.gov/Search/FindMyLegislator" + } + ] + }, + "tipsToggle": "Tips for writing effective testimony", + "tipsIntro": "Clearly outline what bill you are testifying about, whether you support or oppose it, why you are concerned about the issue, what policy you would like to see enacted, and what legislative district you live in.", + "tips": [ + { + "label": "Be Timely", + "body": "Written testimony should target bills being considered by the legislature. All Committees should formally accept testimony on each bill in the time between the start of the legislative session and the public hearing of that bill." + }, + { + "label": "Be Original", + "body": "Legislators receive a lot of form letters. These communications are important, but almost always an individual and personalized letter will have greater impact." + }, + { + "label": "Be Informative", + "body": "Whether you're a longtime advocate or first-time testifier, your testimony is important. Explain why you are concerned about an issue and why you think one policy choice would be better than another." + }, + { + "label": "Be Direct", + "body": "State whether you support or oppose a bill. Be clear and specific about the policies you want to see. You don't need to know specific legal precedents or legislative language." + }, + { + "label": "Be Respectful", + "body": "No matter how strongly you hold your position, there may be people of good intent who feel differently. Respectful testimony will carry more weight with legislators — especially those you may need to persuade." + } + ] + }, + "aiTools": { + "breadcrumb": "AI Research Tools" + }, + "whyUseMaple": { + "breadcrumb": "Why Use MAPLE", + "title": "Why Use MAPLE?", + "subhead": "Whether you're a citizen, an organization, or a legislator — MAPLE is for you." + }, + "process": { + "breadcrumb": "Legislative Process", + "title": "Understanding the Legislative Process", + "subhead": "How most bills become laws in Massachusetts.", + "railLabel": "Legislative process stages", + "expandAll": "Click here to expand all sections", + "stages": [ + "Bill Introduction", + "Committee Referral", + "Public Hearing", + "Committee Report", + "Floor Debate & Vote", + "Governor's Action" + ], + "chapters": [ + { + "num": "01", + "title": "A Bill is Introduced", + "lead": "Every law starts as an idea. Any MA resident can petition their legislator to file a bill.", + "body": "Bills can be filed at any time during the two-year session, but most are filed before the session begins — the standard deadline falls in December of the preceding year. Bills filed after that deadline require special approval. They receive a unique identifier — H. for House-originated bills, S. for Senate. The same idea can be filed in both chambers simultaneously. Bills are publicly available on the MA Legislature website and through MAPLE.", + "stat": { + "value": "6,000+", + "label": "bills filed each session", + "href": "/bills" + } + }, + { + "num": "02", + "title": "Committee Referral", + "lead": "Every bill is routed to a Joint Committee based on its subject matter.", + "body": "There are 33 Joint Legislative Committees covering topics from Education to Public Safety. Joint Committees include members from both the House and Senate. This is where the real work of examining a bill begins. Every bill is entitled to a hearing — but most bills are sent to study after that hearing, which ends their progress for that session.", + "stat": { + "value": "33", + "label": "Joint Committees", + "href": "https://malegislature.gov/Committees/Joint" + } + }, + { + "num": "03", + "title": "Public Hearing & Testimony", + "lead": "This is your moment. Anyone can submit written testimony or speak in person.", + "body": "Committees schedule public hearings where citizens, advocacy groups, experts, and government officials can all testify. Written testimony submitted before the hearing is accepted and reviewed by committee staff. Legislators pay attention to volume — and especially to constituent voices from their own districts.", + "callout": "MAPLE makes it easy to find a bill's hearing date and submit testimony directly to the committee chairs via email." + }, + { + "num": "04", + "title": "Committee Report", + "lead": "The committee decides a bill's fate — advance, amend, or let it die.", + "body": "After reviewing testimony and staff analysis, the committee votes whether to report the bill favorably, unfavorably, or with amendments. A bill reported favorably advances to the full chamber. Most bills are 'sent to study' — a formal committee recommendation that ends the bill's progress for that session. A bill can be refiled in the next session and go through the process again." + }, + { + "num": "05", + "title": "Floor Debate & Vote", + "lead": "Bills that clear committee are debated and amended on the floor, reviewed, and then voted on by each full chamber.", + "body": "In Massachusetts, each chamber reads a bill three times before voting on it. The first reading introduces the bill to the chamber. The second is where floor debate happens — members can propose amendments and changes to the text. The third sends the bill to the Committee on Bills in the Third Reading, which reviews the language before the chamber votes. A bill that passes is 'engrossed,' meaning that chamber has formally adopted its version of the text.\n\nBoth the House and Senate go through this process separately and must end up with identical text. If they pass different versions, a Conference Committee — three members from each chamber — reconciles them into one version. Both chambers then vote to accept or reject it with no further amendments allowed." + }, + { + "num": "06", + "title": "Governor's Action", + "lead": "The Governor has 10 days to sign, veto, or return a bill with amendments.", + "body": "A signed bill is 'chaptered' and becomes part of Massachusetts General Laws. A vetoed bill can be overridden by a 2/3 vote in each chamber. If the Governor returns the bill with amendments, the legislature can accept the amendments, override them, or let the bill die. If the Governor takes no action within 10 days and the legislature is still in session, the bill becomes law automatically. If the legislature has adjourned, the same inaction kills the bill — this is called a pocket veto." + } + ] + } +} diff --git a/public/locales/en/learnComponents.json b/public/locales/en/learnComponents.json index c4c9d44cc..a1d91a292 100644 --- a/public/locales/en/learnComponents.json +++ b/public/locales/en/learnComponents.json @@ -1,138 +1,5 @@ { - "basics": { - "title": "Testimony Basics", - "intro": "All laws passed by state legislatures should consider feedback from residents and community stakeholders. In Massachusetts, one way to have your voice heard is by submitting written testimony regarding specific bills. This website, the MAPLE platform, can help you submit your written testimony to the MA Legislature. However, please note that this is not an official government website and is not the only way to submit your testimony. Here are the essential things to know before submitting testimony:", - "content": [ - { - "title": "Anyone can submit testimony to the MA legislature", - "paragraph": "Legislators tend to value testimony most when it comes from their own constituents. Testimony from MA residents is typically directed to both the committee that is substantively responsible for the bill as well as the legislators (House member and Senator) representing your district." - }, - { - "title": "Your testimony will be most impactful when it feels distinctive and relevant", - "paragraph": "Be sure to write your own text and explain why you are interested in an issue." - }, - { - "title": "Committees generally accept testimony up until the hearing date designated for a bill", - "paragraph": "You can use the bill pages on this website to identify relevant committee dates. Although some committees will accept testimony after this date, for the greatest impact you should submit your testimony before the hearing." - }, - { - "title": "Testimony is generally accepted by committees of the legislature by sending an email to their Chairs", - "paragraph": "This website, MAPLE, will help you to do this by making it easy to find a bill you want to testify in and then generate an email, which you fully control, which you can then send to the relevant personnel." - }, - { - "title": "The key role of testimony is to let your legislators know how you feel about an issue", - "paragraph": "If you don't share your perspective, it will not be taken into account when policymakers make decisions about the laws that govern all our lives." - } - ] - }, - "role": { - "title": "The Role of Testimony", - "intro": "By speaking up, you can make the laws of Massachusetts work better for all of us! Everyone is able to convey their opinions to the legislature, but the process to submit testimony can be confusing and intimidating. We hope that this website, the MAPLE platform, will make that process easier, more straightforward, and more accessible to all stakeholders.", - "content": [ - { - "title": "Your voice is instrumental to the legislative process", - "paragraph": "It could guide the agenda of the legislature, what topics and bills they consider, and how they decide to act and vote on each bill." - }, - { - "title": "Your voice gives them insight", - "paragraph": "It can inform legislators of the benefits or negative consequences of proposed policies." - }, - { - "title": "You can give suggestions", - "paragraph": "You can also recommend specific changes or improvements to legislation, whether you generally support or oppose the bill." - } - ] - }, - "write": { - "title": "Writing Effective Testimony", - "intro": "The basics of writing effective testimony are to clearly outline what bill you are testifying about, whether you support or oppose it, why you are concerned about the issue, what policy you would like to see enacted, and what legislative district you live in. Here are some tips you can use to make sure the testimony you submit is as impactful as possible:", - "content": [ - { - "title": "Be Timely", - "paragraphs": [ - "Written testimony should be targeted towards specific bills being considered by the legislature. All Committees should formally accept testimony on each bill in the time between the start of the legislative session and the public hearing of that bill.", - "Some committees will continue accepting testimony after the hearing date, but it may not have the same impact on their deliberations." - ] - }, - { - "title": "Be Original", - "paragraphs": [ - "Legislators receive a lot of form letters and repeated sentiments from organized groups of constituents. These communications are important and their volume can be meaningful to legislators. But, almost always, an individual and personalized letter will have a greater impact." - ] - }, - { - "title": "Be Informative", - "paragraphs": [ - "Whether you are a longtime advocate or a first time testifier, whether you have a doctoral degree in the subject or lived experience regarding a policy, and no matter your age, race, creed, or background, your testimony is important. Explain why you are concerned about an issue and why you think one policy choice would be better than another. For example, your being a parent gives you special insight into education policy, your living in a community affected by pollution gives you special insight into environmental policy, etc." - ] - }, - { - "title": "Be Direct", - "paragraphs": [ - "State whether you support or oppose a bill. Be clear and specific about the policies you want to see. You don't have to know specific legal precedents or legislative language; just explain what you would like to happen and why." - ] - }, - { - "title": "Be Respectful", - "paragraphs": [ - "No matter how strongly and sincerely held your position is, there may be people of good intent who feel oppositely and expect and deserve to have their opinions considered by their legislators also. Respectful testimony will carry more weight with legislators, especially those who you may need to persuade to your side of an issue." - ] - } - ] - }, - "communicating": { - "title": "Communicating with Legislators", - "intro": "There are multiple ways to share your perspective and knowledge with your legislators.", - "testifyInWriting": { - "title": "Testify in writing", - "content": "You can submit your thoughts on a bill to the Committee hearing it before the date of their public hearing. This website, the MAPLE platform, focuses on this mechanism." - }, - "testifyOrally": { - "title": "Testify orally", - "content": "You can attend a public hearing for a bill of interest to you and sign up for a slot to speak before the Committee." - }, - "writeOrCall": { - "title": "Write or call them", - "content": "You can contact your legislators any time by looking up their contact information on the MA Legislature website. Your voice will probably carry the most weight with the House and Senate representatives of your own district, but you are free to contact Committee Chairs or any other member of the legislature with your opinions. You could request a meeting in person." - } - }, "legislative": { - "title": "Understanding the Massachusetts Legislative Process", - "intro": "Some of the key steps in the legislative process for how most bills become laws in MA.", - "content": [ - { - "title": "Filing", - "paragraph": "At the start of each two-year legislative session, Senators and Representatives (and sometimes the Governor or state agencies) file thousands of bills. This can be done at any time, but is typically done before the start of the legislative session (i.e. before the third Friday in January of odd-numbered years). Bills filed later (after the first month of the session) during the legislative session, require special approval." - }, - { - "title": "Committees", - "paragraph": "Each bill is assigned to the committee most relevant to its content (for example, a bill about water quality goes to the Joint Committee on Environment and Natural Resources). Committees are critical; they dictate whether a bill \"fails\" or whether it moves forward in the legislative process. In Massachusetts, bills are usually assigned to \"Joint\" committees - made up of Representatives and Senators. In some other states, House committees and Senate committees work independently, but that is rarely the case in Massachusetts. " - }, - { - "title": "Public Hearing", - "paragraph": "All bills are formally heard by committees during public hearings which are open to the public and recorded and posted as videos on the legislature's website. While the amount of time available to speak during a public hearing is limited, more detailed comments can be submitted to the committee in written testimony. All stakeholders have the chance to share their thoughts with the legislature by submitting written testimony. Typically, testimony is submitted to the committee assigned to the bill. You can also deliver oral testimony by attending a hearing or you can reach out to your legislators and speak to them directly." - }, - { - "title": "Committee Reports", - "paragraph": "Committees must file reports on each bill under their consideration after discussing them in Executive Session following the public hearing. This typically occurs before February of the second year of the legislative session (even-numbered years). The goal for proponents of a bill is that the Committee will recommend they \"ought to pass\" and thereby promote them out of the Committee. Most bills, however are \"sent to study,\" meaning that they will not be passed out of committee in that session. A successful bill may be redrafted or amended by the committee based on testimony received and other deliberations. Many of those bills will be refiled in the next session and can be considered again." - }, - { - "title": "Three Readings", - "paragraph": "Each bill passed out of Committee will be \"read\" three times by each branch, the House and Senate. This typically entails floor debate of the full chamber and a vote of the Committee on Ways and Means or Steering and Policy." - }, - { - "title": "Engrossment and Enactment", - "paragraph": "After the three readings, the bill will be voted on by each of the full chambers (House and Senate), resulting in (if successful) \"engrossment\" and then \"enactment.\"" - }, - { - "title": "Conference Committee", - "paragraph": "If necessary, differences between the House and Senate versions of a bill will be reconciled by a temporary Conference Committee appointed by the House Speaker and Senate President." - }, - { - "title": "Executive Branch", - "paragraph": "Lastly, the Governor is responsible for signing the enacted and reconciled bill into law. The governor can also veto the bill, return it to the Legislature for changes, or take a number of other less-common actions." - } - ], "additional_resources": "Additional Resources", "resources_intro": "We hope these pages will help you submit effective testimony. You may want to consult these other resources to build a more detailed understanding of the legislative process and how you can contribute.", "find_legislator": "The MA Legislature has an <0>online tool you can use to identify your legislators based on your home address.", diff --git a/stories_hold/pages/education/CommunicatingWithLegislators.stories.tsx b/stories_hold/pages/education/CommunicatingWithLegislators.stories.tsx deleted file mode 100644 index f2d49ee94..000000000 --- a/stories_hold/pages/education/CommunicatingWithLegislators.stories.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { ComponentStory } from "@storybook/react" -import { createMeta } from "stories/utils" -import { CommunicatingWithLegislators } from "components/LearnTestimonyComponents/LearnComponents" -export default createMeta({ - title: "Pages/Education/CommunicatingWithLegislators", - component: CommunicatingWithLegislators -}) - -const Template: ComponentStory = () => ( - -) - -export const Primary = Template.bind({}) - -Primary.storyName = "CommunicatingWithLegislators" diff --git a/styles/bootstrap.scss b/styles/bootstrap.scss index c42251210..d551b595a 100644 --- a/styles/bootstrap.scss +++ b/styles/bootstrap.scss @@ -7,6 +7,7 @@ $orange: #ff8600; $blue: #1a3185; $dark-blue: #0b0a3e; $green: #3d9922; +$gold: #fbbc04; $gray-700: #495057; $pantone-blue: #d1d6e7; @@ -57,6 +58,10 @@ $maple-text-inverse-muted: rgba(255, 255, 255, 0.55); $maple-surface-base: #ffffff; // Provisional: keep the current page background in PR 1 and retune later with surface contrast as a group. $maple-surface-page: #eae7e7; +// Learn section only. Distinct from $maple-surface-page on purpose. +$maple-surface-learn: #eef0f8; +// The HOW badge on /learn/testimony. Not part of the core brand palette. +$maple-learn-cyan: #42c1e1; $maple-surface-muted: rgba(248, 250, 252, 0.9); $maple-surface-accent: rgba(94, 114, 228, 0.08); $maple-surface-accent-strong: rgba(94, 114, 228, 0.12); @@ -117,6 +122,8 @@ $maple-space-2xl: 2rem; $maple-space-3xl: 3rem; // Typography +$maple-font-heading: "Lexend", system-ui, -apple-system, "Segoe UI", sans-serif; + $maple-font-weight-normal: 400; $maple-font-weight-medium: 500; $maple-font-weight-semibold: 600; @@ -236,14 +243,22 @@ $utilities: ( @import "bootstrap/scss/bootstrap"; -// Import fonts using the default bootstrap weights -@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;700&display=swap"); +// Import fonts using the default bootstrap weights. +// Lexend is the display face for the Learn section headings; Nunito remains the body face. +@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;700&family=Lexend:wght@400;600;700;900&display=swap"); :root { --bs-blue-100: #{$blue-100}; --bs-blue-300: #{$blue-300}; --bs-red-100: #{$red-100}; --bs-dark-blue: #0b0a3e; + --bs-gold: #{$gold}; + + --maple-font-heading: #{$maple-font-heading}; + + // Height of the sticky site navbar. The Learn process rail sticks below it, + // and section rows use it for scroll-margin so smooth-scroll targets clear it. + --maple-navbar-height: 4.5rem; // Maple design tokens — emitted from the SCSS variables defined above so // the compile-time (Bootstrap) and runtime (app components) layers stay @@ -267,6 +282,8 @@ $utilities: ( --maple-surface-base: #{$maple-surface-base}; --maple-surface-page: #{$maple-surface-page}; + --maple-surface-learn: #{$maple-surface-learn}; + --maple-learn-cyan: #{$maple-learn-cyan}; --maple-surface-muted: #{$maple-surface-muted}; --maple-surface-accent: #{$maple-surface-accent}; --maple-surface-accent-strong: #{$maple-surface-accent-strong}; From 4c96e4fac9e50958e701d9189dade27c6e6d1897 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 08:48:31 -0400 Subject: [PATCH 032/106] Reword the How section intro and Testify in writing step Replaces the "Step through the process..." intro with a shorter framing line and points readers at MAPLE from the written-testimony step. --- public/locales/en/learn.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/locales/en/learn.json b/public/locales/en/learn.json index dcd547483..a8db22089 100644 --- a/public/locales/en/learn.json +++ b/public/locales/en/learn.json @@ -80,11 +80,11 @@ "readyToStart": "Ready to get started?", "how": { "headline": "To make your voice heard", - "intro": "Step through the process from picking a topic important to you, reading bills and ballot questions that impact you, deciding your own stance, and clicking through to submit. Once you are ready, here are your options:", + "intro": "There are multiple ways to share your perspective and knowledge with your legislators.", "steps": [ { "title": "Testify in writing", - "body": "Written testimony should target bills being considered by the legislature. All Committees should formally accept testimony on each bill in the time between the start of the legislative session and the public hearing of that bill." + "body": "Written testimony should target bills being considered by the legislature. All Committees should formally accept testimony on each bill in the time between the start of the legislative session and the public hearing of that bill. This is what MAPLE can help with!" }, { "title": "Testify in person", From 658cf43391dabadd1bbf1ad74af54b003c87a0cc Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 09:03:28 -0400 Subject: [PATCH 033/106] Let scroll drive the legislative process accordion The chapter crossing a line just under the sticky rail opens; the previous one closes. "Click here to expand all sections" overrides this and opens everything; clicking the link again, a rail node, or a chapter header hands control back to scroll. Two things this needed: - No manual scroll compensation. The browser's native scroll anchoring already holds the reader's position when a panel above them opens or closes. Compensating on top of it double-corrected, and near the bottom of the page (where scroll also clamps as the document shrinks) the two walked the accordion backwards -- a jump to y=1200 unwound to y=383. - A short settle lock. Without it a reflow can push the next header across the trigger line in the same frame, opening it, and so on down the page. Side effects also had to move out of the setOpenIdx updater: React invokes updaters twice under StrictMode, which fired the lock twice. --- .../learn/Process/LegislativeProcess.tsx | 118 ++++++++++++++---- public/locales/en/learn.json | 1 + 2 files changed, 93 insertions(+), 26 deletions(-) diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx index c00469119..cbc478864 100644 --- a/components/learn/Process/LegislativeProcess.tsx +++ b/components/learn/Process/LegislativeProcess.tsx @@ -554,6 +554,21 @@ export const LegislativeProcess = () => { const pendingHeaderScrollRef = useRef(false) const headerRef = useRef(null) + // Scroll-driven opening: while the reader scrolls, the chapter crossing the + // line just under the rail opens and the previous one closes. + // + // Opening a panel reflows everything below it, so a naive implementation + // oscillates: the row that just opened pushes the next header across the + // trigger line, which opens that one, and so on. Two guards prevent that. + // `suppressUntilRef` ignores scroll while a programmatic smooth-scroll is in + // flight, and `anchorRef` pins the newly-opened header in place across the + // reflow so the page does not shift under the reader. + const suppressUntilRef = useRef(0) + // Mirrors openIdx so the scroll handler can compare without reading state + // inside a setState updater (updaters must stay pure — React invokes them + // twice in StrictMode, which fired the anchor and the lock twice). + const openIdxRef = useRef(0) + // The rail must sit directly beneath whatever remains of the site navbar. // // A one-off height measurement is not enough: the navbar's `sticky-top` does @@ -613,6 +628,60 @@ export const LegislativeProcess = () => { return () => observer.disconnect() }, []) + // No manual scroll compensation here: the browser's native scroll anchoring + // already keeps the reader's position stable when a panel above them opens or + // closes. Compensating on top of it double-corrects, and near the bottom of + // the document (where the scroll position also clamps as the page shrinks) + // the two together walk the accordion backwards. + + // Scroll drives which chapter is open, unless everything is expanded. + useEffect(() => { + if (expandAll) return + + let frame = 0 + const evaluate = () => { + frame = 0 + if (performance.now() < suppressUntilRef.current) return + if (pendingScrollRef.current !== null) return + + const rail = railRef.current + if (!rail) return + // The trigger line sits just below the sticky rail. + const line = rail.getBoundingClientRect().bottom + 24 + + const headers = Array.from( + document.querySelectorAll('[id^="learn-process-header-"]') + ) + let candidate = 0 + headers.forEach((header, i) => { + if (header.getBoundingClientRect().top <= line + 4) candidate = i + }) + + if (openIdxRef.current === candidate) return + + // Let the reflow and its scroll correction settle before evaluating + // again, so a correction cannot immediately retrigger the next chapter. + suppressUntilRef.current = performance.now() + 220 + openIdxRef.current = candidate + setActiveIdx(candidate) + setOpenIdx(candidate) + } + + const schedule = () => { + if (frame) return + frame = requestAnimationFrame(evaluate) + } + + evaluate() + window.addEventListener("scroll", schedule, { passive: true }) + window.addEventListener("resize", schedule) + return () => { + if (frame) cancelAnimationFrame(frame) + window.removeEventListener("scroll", schedule) + window.removeEventListener("resize", schedule) + } + }, [expandAll]) + useEffect(() => { const target = pendingScrollRef.current if (target === null) return @@ -626,6 +695,7 @@ export const LegislativeProcess = () => { // view. A card below the fold rises just far enough to be fully visible // rather than jumping to the top of the viewport; a card above the fold // aligns to its top, where scroll-margin-top clears the navbar and rail. + suppressUntilRef.current = performance.now() + (reduceMotion ? 100 : 900) el.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "nearest" @@ -659,36 +729,30 @@ export const LegislativeProcess = () => { }) }, [activeIdx]) - const selectStage = useCallback((i: number) => { + // Clicking a rail node or a chapter header leaves expand-all and hands + // control back to scroll: the chapter is opened and scrolled to, and from + // there scrolling takes over again. + const focusChapter = useCallback((i: number) => { + suppressUntilRef.current = performance.now() + 900 pendingScrollRef.current = i + openIdxRef.current = i setExpandAll(false) setActiveIdx(i) setOpenIdx(i) }, []) - const toggleChapter = useCallback( - (i: number) => { - if (expandAll) { - // Leaving expand-all: keep the chapter the reader just reached for. - setExpandAll(false) - setOpenIdx(i) - setActiveIdx(i) - return - } - setOpenIdx(prev => { - const next = prev === i ? null : i - if (next !== null) setActiveIdx(next) - return next - }) - }, - [expandAll] - ) - - // Expand-only. Any rail node or chapter header returns to the normal - // one-at-a-time accordion. - const expandAllSections = useCallback(() => { - setExpandAll(true) - pendingHeaderScrollRef.current = true + const selectStage = focusChapter + const toggleChapter = focusChapter + + const toggleExpandAll = useCallback(() => { + suppressUntilRef.current = performance.now() + 900 + setExpandAll(prev => { + // Collapsing hands control back to scroll: the chapter at the trigger + // line reopens on the next frame. + if (prev) return false + pendingHeaderScrollRef.current = true + return true + }) }, []) return ( @@ -706,11 +770,13 @@ export const LegislativeProcess = () => { {t("process.subhead")}{" "} panelId(i)).join(" ")} > - {t("process.expandAll")} + {expandAll + ? t("process.collapseAll") + : t("process.expandAll")} } diff --git a/public/locales/en/learn.json b/public/locales/en/learn.json index a8db22089..41dc36340 100644 --- a/public/locales/en/learn.json +++ b/public/locales/en/learn.json @@ -141,6 +141,7 @@ "subhead": "How most bills become laws in Massachusetts.", "railLabel": "Legislative process stages", "expandAll": "Click here to expand all sections", + "collapseAll": "Click here to collapse all sections", "stages": [ "Bill Introduction", "Committee Referral", From 369baa05ab4ec5ae1e154979d47f8f15c4a8ec9f Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 10:13:00 -0400 Subject: [PATCH 034/106] Animate the process accordion; revert scroll-driven opening Scroll-driven opening is removed. It could not be made to feel right: each chapter that collapses telescopes the list upward, so the reader's scroll carries them through several chapters at once. Damping it (settle locks, one-chapter stepping, scroll snapping) either made the open chapter lag behind the reader or made the page feel sticky. Chapters open on click again. The animation from that work is kept. Panels now transition their height via grid-template-rows 0fr -> 1fr, which needs no measured height, with a delayed visibility so a collapsed panel leaves the accessibility tree and the focus order once it has finished closing (verified: links inside a closed panel cannot take focus). Expand-all staggers the panels open. Collapse-all now closes every chapter, including the one open beforehand. --- .../learn/Process/LegislativeProcess.tsx | 173 +++++++----------- 1 file changed, 63 insertions(+), 110 deletions(-) diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx index cbc478864..d75b6dcc4 100644 --- a/components/learn/Process/LegislativeProcess.tsx +++ b/components/learn/Process/LegislativeProcess.tsx @@ -314,33 +314,36 @@ const RowHeader = styled.h3` ` const Panel = styled.div` - padding: 0 1.75rem 1.75rem; + /* Animating grid-template-rows from 0fr to 1fr gives a height transition + without knowing the content height. The delayed visibility keeps a closed + panel out of the accessibility tree and the focus order once it has + finished collapsing. */ + display: grid; + grid-template-rows: 0fr; + opacity: 0; + visibility: hidden; + transition: grid-template-rows 0.55s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.3s ease, visibility 0s linear 0.55s; + + &[data-open="true"] { + grid-template-rows: 1fr; + opacity: 1; + visibility: visible; + transition: grid-template-rows 0.55s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.32s ease 0.12s, visibility 0s; + transition-delay: var(--learn-panel-delay, 0ms); + } - @keyframes learn-panel-in { - from { - opacity: 0; - transform: translateY(-0.375rem); - } - to { - opacity: 1; - transform: none; - } + .clip { + min-height: 0; + overflow: hidden; } .inner { display: flex; flex-direction: column; gap: 1rem; - padding-top: 1rem; - padding-left: 5.5rem; - animation: learn-panel-in 0.24s ease both; - animation-delay: var(--learn-panel-delay, 0ms); - } - - @media (prefers-reduced-motion: reduce) { - .inner { - animation: none; - } + padding: 1rem 1.75rem 1.75rem 5.5rem; } .body { @@ -394,9 +397,15 @@ const Panel = styled.div` } @media (max-width: 36rem) { - padding: 0 1rem 1.25rem; .inner { - padding-left: 0; + padding: 1rem; + } + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + &[data-open="true"] { + transition: none; } } ` @@ -496,18 +505,16 @@ const AccordionRow = ({ id={panelId(index)} role="region" aria-labelledby={headerId(index)} - hidden={!isOpen} - style={{ borderTop: isOpen ? `2px solid ${alpha(color, 9)}` : undefined }} + data-open={isOpen ? "true" : "false"} + style={ + { + borderTop: isOpen ? `2px solid ${alpha(color, 9)}` : undefined, + "--learn-panel-delay": `${stagger * 55}ms` + } as React.CSSProperties + } > - {isOpen && ( -
    +
    +

    {chapter.body}

    {chapter.stat && ( @@ -526,7 +533,7 @@ const AccordionRow = ({
    )}
    - )} +
    ) @@ -554,21 +561,6 @@ export const LegislativeProcess = () => { const pendingHeaderScrollRef = useRef(false) const headerRef = useRef(null) - // Scroll-driven opening: while the reader scrolls, the chapter crossing the - // line just under the rail opens and the previous one closes. - // - // Opening a panel reflows everything below it, so a naive implementation - // oscillates: the row that just opened pushes the next header across the - // trigger line, which opens that one, and so on. Two guards prevent that. - // `suppressUntilRef` ignores scroll while a programmatic smooth-scroll is in - // flight, and `anchorRef` pins the newly-opened header in place across the - // reflow so the page does not shift under the reader. - const suppressUntilRef = useRef(0) - // Mirrors openIdx so the scroll handler can compare without reading state - // inside a setState updater (updaters must stay pure — React invokes them - // twice in StrictMode, which fired the anchor and the lock twice). - const openIdxRef = useRef(0) - // The rail must sit directly beneath whatever remains of the site navbar. // // A one-off height measurement is not enough: the navbar's `sticky-top` does @@ -634,54 +626,6 @@ export const LegislativeProcess = () => { // the document (where the scroll position also clamps as the page shrinks) // the two together walk the accordion backwards. - // Scroll drives which chapter is open, unless everything is expanded. - useEffect(() => { - if (expandAll) return - - let frame = 0 - const evaluate = () => { - frame = 0 - if (performance.now() < suppressUntilRef.current) return - if (pendingScrollRef.current !== null) return - - const rail = railRef.current - if (!rail) return - // The trigger line sits just below the sticky rail. - const line = rail.getBoundingClientRect().bottom + 24 - - const headers = Array.from( - document.querySelectorAll('[id^="learn-process-header-"]') - ) - let candidate = 0 - headers.forEach((header, i) => { - if (header.getBoundingClientRect().top <= line + 4) candidate = i - }) - - if (openIdxRef.current === candidate) return - - // Let the reflow and its scroll correction settle before evaluating - // again, so a correction cannot immediately retrigger the next chapter. - suppressUntilRef.current = performance.now() + 220 - openIdxRef.current = candidate - setActiveIdx(candidate) - setOpenIdx(candidate) - } - - const schedule = () => { - if (frame) return - frame = requestAnimationFrame(evaluate) - } - - evaluate() - window.addEventListener("scroll", schedule, { passive: true }) - window.addEventListener("resize", schedule) - return () => { - if (frame) cancelAnimationFrame(frame) - window.removeEventListener("scroll", schedule) - window.removeEventListener("resize", schedule) - } - }, [expandAll]) - useEffect(() => { const target = pendingScrollRef.current if (target === null) return @@ -695,7 +639,6 @@ export const LegislativeProcess = () => { // view. A card below the fold rises just far enough to be fully visible // rather than jumping to the top of the viewport; a card above the fold // aligns to its top, where scroll-margin-top clears the navbar and rail. - suppressUntilRef.current = performance.now() + (reduceMotion ? 100 : 900) el.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "nearest" @@ -729,31 +672,41 @@ export const LegislativeProcess = () => { }) }, [activeIdx]) - // Clicking a rail node or a chapter header leaves expand-all and hands - // control back to scroll: the chapter is opened and scrolled to, and from - // there scrolling takes over again. - const focusChapter = useCallback((i: number) => { - suppressUntilRef.current = performance.now() + 900 + const selectStage = useCallback((i: number) => { pendingScrollRef.current = i - openIdxRef.current = i setExpandAll(false) setActiveIdx(i) setOpenIdx(i) }, []) - const selectStage = focusChapter - const toggleChapter = focusChapter + const toggleChapter = useCallback( + (i: number) => { + if (expandAll) { + // Leaving expand-all: keep the chapter the reader just reached for. + setExpandAll(false) + setOpenIdx(i) + setActiveIdx(i) + return + } + setOpenIdx(prev => { + const next = prev === i ? null : i + if (next !== null) setActiveIdx(next) + return next + }) + }, + [expandAll] + ) const toggleExpandAll = useCallback(() => { - suppressUntilRef.current = performance.now() + 900 setExpandAll(prev => { - // Collapsing hands control back to scroll: the chapter at the trigger - // line reopens on the next frame. if (prev) return false pendingHeaderScrollRef.current = true return true }) - }, []) + // Collapsing closes every chapter, including the one that was open before + // expand-all was pressed. + setOpenIdx(prev => (expandAll ? null : prev)) + }, [expandAll]) return ( // "medium" rather than "narrow": the h1 wraps at 48rem. From 6fad899cad14907299ee3c8dbc73ee4030a98b44 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 10:36:30 -0400 Subject: [PATCH 035/106] Land a clicked chapter below the rail without a corrective jump Clicking a rail stage could leave the chapter's header hidden behind the sticky rail. scrollIntoView({ block: "nearest" }) treats the whole viewport as visible and knows nothing about the rail painted over it, so when the reader had already scrolled the header behind the rail it considered the row in view, declined to scroll, and scroll-margin-top never applied. Correcting afterwards worked but read as a jump. Instead the final position is now predicted before scrolling: the only thing that moves the row is the panel above it collapsing, and that panel can be measured before it animates. One scroll, computed from the card's top border against the rail's live bottom edge: under the rail, it comes down to sit just below it; below the fold, it rises only as far as needed and never past the rail. Also: - overflow-anchor: none on the rows. We compute the post-collapse position ourselves, and the browser's own scroll anchoring correcting the same reflow is what produced the visible jump. - Clicking the already-open chapter now scrolls to it. openIdx did not change, so the effect never ran and pendingScrollRef went stale. - previousOpenRef is synced from a later effect so header toggles and expand-all keep it accurate. Verified across every scroll position x stage combination, and that the safety net after the transition never fires. --- .../learn/Process/LegislativeProcess.tsx | 94 +++++++++++++++++-- public/locales/en/forindividuals.json | 2 +- public/locales/en/forlegislators.json | 2 +- public/locales/en/fororgs.json | 2 +- 4 files changed, 87 insertions(+), 13 deletions(-) diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx index d75b6dcc4..8182dcbd2 100644 --- a/components/learn/Process/LegislativeProcess.tsx +++ b/components/learn/Process/LegislativeProcess.tsx @@ -35,6 +35,9 @@ const prefersReducedMotion = () => typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches +/** Keep in sync with the Panel grid-template-rows transition below. */ +const PANEL_TRANSITION_MS = 550 + const rowId = (i: number) => `learn-process-stage-${i}` const panelId = (i: number) => `learn-process-panel-${i}` const headerId = (i: number) => `learn-process-header-${i}` @@ -213,6 +216,10 @@ const Row = styled.div` box-shadow: var(--maple-shadow-sm); overflow: hidden; + /* We compute the post-collapse scroll position ourselves, so the browser must + not also try to hold position when a panel above this one collapses. */ + overflow-anchor: none; + /* Smooth-scroll targets must clear the sticky navbar and the sticky rail when they align to the top, and keep a little air when they align to the bottom (scrollIntoView block: "nearest"). */ @@ -557,6 +564,11 @@ export const LegislativeProcess = () => { // Set when a rail node is clicked, so that expanding a row from its own // header does not also yank the page around. const pendingScrollRef = useRef(null) + // Which chapter was open before this change, so we know whose panel is about + // to collapse and by how much it will shift the page. + const previousOpenRef = useRef(0) + // Bumped on every click so clicking the already-open chapter still scrolls. + const [scrollNonce, setScrollNonce] = useState(0) // Set when "expand all" is pressed, so the subhead is brought to the top. const pendingHeaderScrollRef = useRef(false) const headerRef = useRef(null) @@ -631,18 +643,79 @@ export const LegislativeProcess = () => { if (target === null) return pendingScrollRef.current = null - const el = document.getElementById(rowId(target)) - if (!el) return + const previous = previousOpenRef.current + previousOpenRef.current = target + + const row = document.getElementById(rowId(target)) + const header = document.getElementById(headerId(target)) + const rail = railRef.current + if (!row || !header || !rail) return const reduceMotion = prefersReducedMotion() - // "nearest" scrolls the least distance that brings the whole card into - // view. A card below the fold rises just far enough to be fully visible - // rather than jumping to the top of the viewport; a card above the fold - // aligns to its top, where scroll-margin-top clears the navbar and rail. - el.scrollIntoView({ - behavior: reduceMotion ? "auto" : "smooth", - block: "nearest" - }) + const behavior: ScrollBehavior = reduceMotion ? "auto" : "smooth" + const GAP = 16 + + // A chapter above this one collapsing pulls this row upward by that panel's + // height. Measure it now, before it animates away, and fold it into a single + // scroll -- otherwise the row slides under the rail and has to be yanked + // back, which is the jump the reader sees. + let shiftUp = 0 + if (previous !== null && previous < target && !expandAll) { + const closing = document.getElementById(panelId(previous)) + if (closing) shiftUp = closing.getBoundingClientRect().height + } + + // The row's height once its own panel has finished opening. The inner + // content is laid out even while the panel is collapsed, so it can be + // measured up front. + const inner = row.querySelector(".inner") + const openPanel = document.getElementById(panelId(target)) + const currentPanelHeight = openPanel + ? openPanel.getBoundingClientRect().height + : 0 + const finalRowHeight = + row.getBoundingClientRect().height - + currentPanelHeight + + (inner ? inner.scrollHeight : 0) + + const railBottom = rail.getBoundingClientRect().bottom + const desiredTop = railBottom + GAP + const finalTop = header.getBoundingClientRect().top - shiftUp + const finalBottom = finalTop + finalRowHeight + const viewportBottom = window.innerHeight - GAP + + // Positive delta scrolls the page down (content moves up). + let delta = 0 + if (finalTop < desiredTop) { + // Would sit under the rail: bring its top border to just below the rail. + delta = finalTop - desiredTop + } else if (finalBottom > viewportBottom) { + // Below the fold: rise only as far as needed, never past the rail. + delta = Math.min(finalBottom - viewportBottom, finalTop - desiredTop) + } + + if (Math.abs(delta) > 1) window.scrollBy({ top: delta, behavior }) + + // Safety net: if a measurement was off, settle it once the transition ends. + // When the prediction is right this is a no-op, so there is no second jump. + const timer = window.setTimeout( + () => { + const overlap = + rail.getBoundingClientRect().bottom + + GAP - + header.getBoundingClientRect().top + if (overlap > 4) window.scrollBy({ top: -overlap, behavior }) + }, + reduceMotion ? 0 : PANEL_TRANSITION_MS + 80 + ) + return () => clearTimeout(timer) + }, [openIdx, scrollNonce, expandAll]) + + // Declared after the scroll effect so that effect still sees the previous + // value. Keeps the reference correct when a chapter is opened or closed + // without a scroll (a header toggle, expand-all). + useEffect(() => { + previousOpenRef.current = openIdx }, [openIdx]) useEffect(() => { @@ -674,6 +747,7 @@ export const LegislativeProcess = () => { const selectStage = useCallback((i: number) => { pendingScrollRef.current = i + setScrollNonce(n => n + 1) setExpandAll(false) setActiveIdx(i) setOpenIdx(i) diff --git a/public/locales/en/forindividuals.json b/public/locales/en/forindividuals.json index 5443654ce..440bafc89 100644 --- a/public/locales/en/forindividuals.json +++ b/public/locales/en/forindividuals.json @@ -2,7 +2,7 @@ "title": "MAPLE for Individuals", "callToAction": { "header": "Why use MAPLE", - "title": "MAPLE makes it easy for you to join the Commonwealth-wide conversation about the bills that will shape our future.", + "title": "Making your voice count in the laws that shape our future.", "bodytext": "With a quick search on MAPLE, you can see what bills the state legislature is considering about the topics that matter to you. You can find out what policy changes are proposed, see what other constituents and organizations are saying about them, and-most important-you can submit written testimony directly to our representatives in the State House to make your perspective part of the conversation." }, "benefits": { diff --git a/public/locales/en/forlegislators.json b/public/locales/en/forlegislators.json index e24b0c0b1..38c8a5685 100644 --- a/public/locales/en/forlegislators.json +++ b/public/locales/en/forlegislators.json @@ -2,7 +2,7 @@ "title": "MAPLE for Legislators", "callToAction": { "header": "Why use MAPLE", - "title": "MAPLE is a free, nonpartisan, nonprofit platform that makes it easy for legislative offices to see what constituents from around the Commonwealth are saying about bills sitting in any Committee.", + "title": "Gain free, nonpartisan insight into how constituents are thinking about any proposed measure.", "bodytextOne": "Even before you create your own account, you can search our lightning-fast platform for all testimony submitted by MAPLE users about any bill. Our advanced features let you filter to bills sponsored by your office, bills concerning your city, or bills that you choose to follow.", "bodytextTwo": "Why trust MAPLE? We are a nonpartisan, nonprofit, volunteer-developed civic technology initiative focused on increasing engagement between the legislature and its constituents. We offer our product as an open source public good; we are committed not to charging our users and we will never sell the data from our platform." }, diff --git a/public/locales/en/fororgs.json b/public/locales/en/fororgs.json index aaedc16f6..8bd8f790a 100644 --- a/public/locales/en/fororgs.json +++ b/public/locales/en/fororgs.json @@ -2,7 +2,7 @@ "title": "MAPLE for Organizations", "callToAction": { "header": "Why use MAPLE", - "title": "MAPLE is a powerful communication tool for organizations, offered completely for free.", + "title": "Reach a wider civically-engage audience and get your message out.", "bodytext": "It takes only a couple minutes to sign up for your organization's account on MAPLE, giving you immediate access to publish and share your testimony, designate your priority bills, connect with your members and followers, and research legislation." }, "benefits": { From bb1601865daac18fa8d32c780bf5a3b79ce7cad5 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 10:40:04 -0400 Subject: [PATCH 036/106] Reword the Individuals and Organizations taglines Individuals: "Making your voice count..." -> "Make your voice count..." Organizations: rewritten, dropping the "civically-engage" typo. --- public/locales/en/forindividuals.json | 2 +- public/locales/en/fororgs.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/locales/en/forindividuals.json b/public/locales/en/forindividuals.json index 440bafc89..048c3cd8e 100644 --- a/public/locales/en/forindividuals.json +++ b/public/locales/en/forindividuals.json @@ -2,7 +2,7 @@ "title": "MAPLE for Individuals", "callToAction": { "header": "Why use MAPLE", - "title": "Making your voice count in the laws that shape our future.", + "title": "Make your voice count in the laws that shape our future.", "bodytext": "With a quick search on MAPLE, you can see what bills the state legislature is considering about the topics that matter to you. You can find out what policy changes are proposed, see what other constituents and organizations are saying about them, and-most important-you can submit written testimony directly to our representatives in the State House to make your perspective part of the conversation." }, "benefits": { diff --git a/public/locales/en/fororgs.json b/public/locales/en/fororgs.json index 8bd8f790a..bef27ea7b 100644 --- a/public/locales/en/fororgs.json +++ b/public/locales/en/fororgs.json @@ -2,7 +2,7 @@ "title": "MAPLE for Organizations", "callToAction": { "header": "Why use MAPLE", - "title": "Reach a wider civically-engage audience and get your message out.", + "title": "Get your message out and rally your supporters in civic discourse.", "bodytext": "It takes only a couple minutes to sign up for your organization's account on MAPLE, giving you immediate access to publish and share your testimony, designate your priority bills, connect with your members and followers, and research legislation." }, "benefits": { From b6b9fd3ab965fc96a5aee92ae5704f5c3fe4e9ed Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 11:14:51 -0400 Subject: [PATCH 037/106] Rework the process page: the rail follows the reader The rail no longer opens or closes chapters. Every chapter is expanded by default and collapses independently from its own header. As the reader scrolls, the rail highlights the chapter they are looking at, cycling from the first. Clicking a stage scrolls to that chapter and opens it only if it was collapsed; it never closes anything. The expand-all / collapse-all link is gone. This also removes a whole class of bugs. Because the rail no longer changes any layout, a highlight can no longer reflow the page, so nothing can cascade, oscillate, or drag a chapter out from under the reader. The scroll handler is now pure observation. The handover has a 180px look-ahead measured from the rail's live bottom edge, so the next stage lights up while roughly a card's worth of the previous chapter is still in view. Also in this change: - Why Use MAPLE: benefits become disclosures, all collapsed by default, each with its supporting copy. `signUp` is a call to action, not a benefit, so it no longer renders as one. `legislativeResearch` has no bodytext -- it is stored around a link -- and is reassembled with an internal link to /bills. - Print styles: every disclosure prints expanded, on all three pages. The tips panel needs `display: block !important` because Chrome's user-agent sheet declares `[hidden] { display: none !important }`. - Copy: "Find your legislator's contact info", and the "multiples sides" typo. --- .../learn/Process/LegislativeProcess.tsx | 305 ++++++++---------- components/learn/Testimony/AboutTestimony.tsx | 24 ++ components/learn/WhyUseMaple/WhyUseMaple.tsx | 240 ++++++++++++-- public/locales/en/forindividuals.json | 2 +- public/locales/en/fororgs.json | 6 +- public/locales/en/learn.json | 4 +- 6 files changed, 364 insertions(+), 217 deletions(-) diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx index 8182dcbd2..723a05a65 100644 --- a/components/learn/Process/LegislativeProcess.tsx +++ b/components/learn/Process/LegislativeProcess.tsx @@ -179,31 +179,6 @@ const HeaderAnchor = styled.div` scroll-margin-top: calc(var(--maple-navbar-height) + 0.75rem); ` -const ExpandAllButton = styled.button` - display: inline; - padding: 0; - margin: 0.5rem 0 0; - background: none; - border: 0; - color: var(--bs-blue); - font-weight: 400; - text-decoration: underline; - cursor: pointer; - - &:hover { - text-decoration: none; - } - - &:focus-visible { - outline: 2px solid var(--bs-blue); - outline-offset: 2px; - border-radius: var(--maple-radius-sm); - } -` - -/* A `position: sticky` element can only stick within its containing block. - Scoping the rail to the chapters means it releases and scrolls away once the - last chapter passes, rather than hovering over Additional Resources. */ const StickyScope = styled.div` position: relative; ` @@ -318,6 +293,15 @@ const RowHeader = styled.h3` width: 2.25rem; } } + + @media print { + .chevron { + display: none; + } + button { + cursor: auto; + } + } ` const Panel = styled.div` @@ -338,7 +322,6 @@ const Panel = styled.div` visibility: visible; transition: grid-template-rows 0.55s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.32s ease 0.12s, visibility 0s; - transition-delay: var(--learn-panel-delay, 0ms); } .clip { @@ -415,6 +398,19 @@ const Panel = styled.div` transition: none; } } + + /* Every chapter prints expanded, whatever is open on screen. */ + @media print { + display: block; + grid-template-rows: none; + opacity: 1; + visibility: visible; + transition: none; + + .clip { + overflow: visible; + } + } ` const StatLabel = ({ stat }: { stat: Stat }) => { @@ -447,8 +443,7 @@ const AccordionRow = ({ color, isOpen, isActive, - onToggle, - stagger = 0 + onToggle }: { index: number chapter: Chapter @@ -456,8 +451,6 @@ const AccordionRow = ({ isOpen: boolean isActive: boolean onToggle: () => void - /** Index used to stagger the open animation when several panels open at once. */ - stagger?: number }) => (
    @@ -550,28 +540,28 @@ export const LegislativeProcess = () => { const chapters = t("process.chapters", { returnObjects: true }) as Chapter[] const stages = t("process.stages", { returnObjects: true }) as string[] - const [activeIdx, setActiveIdx] = useState(0) - const [openIdx, setOpenIdx] = useState(0) - // "Expand all" is a temporary override. Any interaction with a rail node or a - // chapter header drops back to the normal one-at-a-time accordion. - const [expandAll, setExpandAll] = useState(false) + // Every chapter starts expanded. Each one collapses independently from its own + // header; opening or closing one never touches the others. + const [openChapters, setOpenChapters] = useState>( + () => new Set(chapters.map((_, i) => i)) + ) + const isOpen = (i: number) => openChapters.has(i) - const isOpen = (i: number) => expandAll || openIdx === i + // The rail is read-only feedback: it follows the chapter the reader is looking + // at. It no longer opens or closes anything, so it cannot reflow the page. + const [activeIdx, setActiveIdx] = useState(0) const railRef = useRef(null) const trackRef = useRef(null) const scopeRef = useRef(null) - // Set when a rail node is clicked, so that expanding a row from its own - // header does not also yank the page around. + const headerRef = useRef(null) + + // Set when a rail node is clicked, so we scroll to that chapter. const pendingScrollRef = useRef(null) - // Which chapter was open before this change, so we know whose panel is about - // to collapse and by how much it will shift the page. - const previousOpenRef = useRef(0) - // Bumped on every click so clicking the already-open chapter still scrolls. const [scrollNonce, setScrollNonce] = useState(0) - // Set when "expand all" is pressed, so the subhead is brought to the top. - const pendingHeaderScrollRef = useRef(false) - const headerRef = useRef(null) + // While a programmatic scroll is running, the reader is not driving, so the + // rail should not chase the intermediate positions. + const suppressUntilRef = useRef(0) // The rail must sit directly beneath whatever remains of the site navbar. // @@ -589,8 +579,10 @@ export const LegislativeProcess = () => { const publishNavOffset = () => { const nav = document.querySelector(".main-navbar") const bottom = nav ? nav.getBoundingClientRect().bottom : 0 - const offset = Math.max(0, bottom) - scope.style.setProperty("--maple-navbar-height", `${offset}px`) + scope.style.setProperty( + "--maple-navbar-height", + `${Math.max(0, bottom)}px` + ) } const schedule = () => { if (frame) return @@ -632,20 +624,58 @@ export const LegislativeProcess = () => { return () => observer.disconnect() }, []) - // No manual scroll compensation here: the browser's native scroll anchoring - // already keeps the reader's position stable when a panel above them opens or - // closes. Compensating on top of it double-corrects, and near the bottom of - // the document (where the scroll position also clamps as the page shrinks) - // the two together walk the accordion backwards. + // Scroll drives the rail's highlight, and nothing else. The active chapter is + // the last one whose top has passed under the rail -- i.e. the one the reader + // is currently reading. Because this changes no layout, it cannot oscillate. + useEffect(() => { + let frame = 0 + const evaluate = () => { + frame = 0 + if (performance.now() < suppressUntilRef.current) return + + const rail = railRef.current + if (!rail) return + const railBottom = rail.getBoundingClientRect().bottom + // Once the rail has scrolled away there is nothing to track against. + if (railBottom <= 0) return + + // Look-ahead: a stage lights up while its chapter is still approaching the + // rail, so the next step is highlighted with a little of the previous one + // still in view. Measured down from the rail's live bottom edge. + const LOOK_AHEAD = 180 + + const rows = Array.from( + document.querySelectorAll('[id^="learn-process-stage-"]') + ) + let next = 0 + rows.forEach((row, i) => { + if (row.getBoundingClientRect().top <= railBottom + LOOK_AHEAD) next = i + }) + setActiveIdx(prev => (prev === next ? prev : next)) + } + const schedule = () => { + if (frame) return + frame = requestAnimationFrame(evaluate) + } + + evaluate() + window.addEventListener("scroll", schedule, { passive: true }) + window.addEventListener("resize", schedule) + return () => { + if (frame) cancelAnimationFrame(frame) + window.removeEventListener("scroll", schedule) + window.removeEventListener("resize", schedule) + } + }, []) + // Scroll to a chapter after a rail click. Nothing above it collapses any more, + // so the only thing that can move it is its own panel opening -- which grows + // downward and leaves the header where it is. useEffect(() => { const target = pendingScrollRef.current if (target === null) return pendingScrollRef.current = null - const previous = previousOpenRef.current - previousOpenRef.current = target - const row = document.getElementById(rowId(target)) const header = document.getElementById(headerId(target)) const rail = railRef.current @@ -655,82 +685,40 @@ export const LegislativeProcess = () => { const behavior: ScrollBehavior = reduceMotion ? "auto" : "smooth" const GAP = 16 - // A chapter above this one collapsing pulls this row upward by that panel's - // height. Measure it now, before it animates away, and fold it into a single - // scroll -- otherwise the row slides under the rail and has to be yanked - // back, which is the jump the reader sees. - let shiftUp = 0 - if (previous !== null && previous < target && !expandAll) { - const closing = document.getElementById(panelId(previous)) - if (closing) shiftUp = closing.getBoundingClientRect().height - } + // Don't let the rail chase the smooth scroll on its way past other chapters. + suppressUntilRef.current = + performance.now() + (reduceMotion ? 50 : PANEL_TRANSITION_MS + 350) + setActiveIdx(target) - // The row's height once its own panel has finished opening. The inner - // content is laid out even while the panel is collapsed, so it can be - // measured up front. + // The row's height once its panel has finished opening. The inner content is + // laid out even while the panel is collapsed, so it can be measured up front. const inner = row.querySelector(".inner") - const openPanel = document.getElementById(panelId(target)) - const currentPanelHeight = openPanel - ? openPanel.getBoundingClientRect().height - : 0 + const panel = document.getElementById(panelId(target)) const finalRowHeight = row.getBoundingClientRect().height - - currentPanelHeight + + (panel ? panel.getBoundingClientRect().height : 0) + (inner ? inner.scrollHeight : 0) - const railBottom = rail.getBoundingClientRect().bottom - const desiredTop = railBottom + GAP - const finalTop = header.getBoundingClientRect().top - shiftUp - const finalBottom = finalTop + finalRowHeight + const desiredTop = rail.getBoundingClientRect().bottom + GAP + const top = header.getBoundingClientRect().top + const bottom = top + finalRowHeight const viewportBottom = window.innerHeight - GAP // Positive delta scrolls the page down (content moves up). let delta = 0 - if (finalTop < desiredTop) { - // Would sit under the rail: bring its top border to just below the rail. - delta = finalTop - desiredTop - } else if (finalBottom > viewportBottom) { + if (top < desiredTop) { + // Sitting under the rail: bring its top border to just below it. + delta = top - desiredTop + } else if (bottom > viewportBottom) { // Below the fold: rise only as far as needed, never past the rail. - delta = Math.min(finalBottom - viewportBottom, finalTop - desiredTop) + delta = Math.min(bottom - viewportBottom, top - desiredTop) } if (Math.abs(delta) > 1) window.scrollBy({ top: delta, behavior }) - - // Safety net: if a measurement was off, settle it once the transition ends. - // When the prediction is right this is a no-op, so there is no second jump. - const timer = window.setTimeout( - () => { - const overlap = - rail.getBoundingClientRect().bottom + - GAP - - header.getBoundingClientRect().top - if (overlap > 4) window.scrollBy({ top: -overlap, behavior }) - }, - reduceMotion ? 0 : PANEL_TRANSITION_MS + 80 - ) - return () => clearTimeout(timer) - }, [openIdx, scrollNonce, expandAll]) - - // Declared after the scroll effect so that effect still sees the previous - // value. Keeps the reference correct when a chapter is opened or closed - // without a scroll (a header toggle, expand-all). - useEffect(() => { - previousOpenRef.current = openIdx - }, [openIdx]) - - useEffect(() => { - if (!expandAll || !pendingHeaderScrollRef.current) return - pendingHeaderScrollRef.current = false - const el = headerRef.current - if (!el) return - el.scrollIntoView({ - behavior: prefersReducedMotion() ? "auto" : "smooth", - block: "start" - }) - }, [expandAll]) + }, [scrollNonce]) // On narrow screens the rail overflows horizontally. Keep the active node - // visible, whichever way the stage was selected. + // visible as the highlight moves. useEffect(() => { const track = trackRef.current if (!track || track.scrollWidth <= track.clientWidth) return @@ -738,49 +726,34 @@ export const LegislativeProcess = () => { const node = track.children[activeIdx] as HTMLElement | undefined if (!node) return - const reduceMotion = prefersReducedMotion() track.scrollTo({ left: node.offsetLeft - (track.clientWidth - node.offsetWidth) / 2, - behavior: reduceMotion ? "auto" : "smooth" + behavior: prefersReducedMotion() ? "auto" : "smooth" }) }, [activeIdx]) + // A rail click scrolls to the chapter, and opens it only if it was collapsed. + // It never closes anything. const selectStage = useCallback((i: number) => { + setOpenChapters(prev => { + if (prev.has(i)) return prev + const next = new Set(prev) + next.add(i) + return next + }) pendingScrollRef.current = i setScrollNonce(n => n + 1) - setExpandAll(false) - setActiveIdx(i) - setOpenIdx(i) }, []) - const toggleChapter = useCallback( - (i: number) => { - if (expandAll) { - // Leaving expand-all: keep the chapter the reader just reached for. - setExpandAll(false) - setOpenIdx(i) - setActiveIdx(i) - return - } - setOpenIdx(prev => { - const next = prev === i ? null : i - if (next !== null) setActiveIdx(next) - return next - }) - }, - [expandAll] - ) - - const toggleExpandAll = useCallback(() => { - setExpandAll(prev => { - if (prev) return false - pendingHeaderScrollRef.current = true - return true + // A chapter header toggles just that chapter, and does not scroll. + const toggleChapter = useCallback((i: number) => { + setOpenChapters(prev => { + const next = new Set(prev) + if (next.has(i)) next.delete(i) + else next.add(i) + return next }) - // Collapsing closes every chapter, including the one that was open before - // expand-all was pressed. - setOpenIdx(prev => (expandAll ? null : prev)) - }, [expandAll]) + }, []) return ( // "medium" rather than "narrow": the h1 wraps at 48rem. @@ -792,21 +765,7 @@ export const LegislativeProcess = () => { title={t("process.title")} titleSize="2.375rem" subheadMaxWidth="none" - subhead={ - <> - {t("process.subhead")}{" "} - panelId(i)).join(" ")} - > - {expandAll - ? t("process.collapseAll") - : t("process.expandAll")} - - - } + subhead={t("process.subhead")} /> @@ -817,10 +776,8 @@ export const LegislativeProcess = () => { {stages.map((title, i) => { const color = STAGE_COLORS[i] - // With every chapter expanded, no stage is "past" and all - // of them read as current. - const done = !expandAll && i < activeIdx - const current = expandAll || i === activeIdx + const done = i < activeIdx + const current = i === activeIdx const Icon = STAGE_ICONS[i] return ( @@ -830,7 +787,6 @@ export const LegislativeProcess = () => { onClick={() => selectStage(i)} aria-current={current ? "step" : undefined} aria-controls={panelId(i)} - aria-expanded={isOpen(i)} > { ? TINT[color] : "var(--bs-gray-50, #f9fafb)", border: `2px solid ${ - expandAll || i <= activeIdx + i <= activeIdx ? color : "var(--bs-gray-200, #e5e7eb)" }`, @@ -883,7 +839,6 @@ export const LegislativeProcess = () => { isOpen={isOpen(i)} isActive={activeIdx === i} onToggle={() => toggleChapter(i)} - stagger={expandAll ? i : 0} /> ))}
    diff --git a/components/learn/Testimony/AboutTestimony.tsx b/components/learn/Testimony/AboutTestimony.tsx index 3dd7e7c37..c3227ec37 100644 --- a/components/learn/Testimony/AboutTestimony.tsx +++ b/components/learn/Testimony/AboutTestimony.tsx @@ -195,6 +195,23 @@ const TipsPanel = styled.div` line-height: 1.6; margin: 0; } + + /* The content is collapsed with the hidden attribute. Chrome's user-agent + sheet declares [hidden] { display: none !important }, so nothing short of + !important can reveal it for printing. */ + @media print { + .content[hidden] { + display: block !important; + } + + button { + cursor: auto; + } + + svg { + display: none; + } + } ` const Panel = styled.div` @@ -242,6 +259,13 @@ const Reveal = styled.div<{ $hidden: boolean }>` transform: none; transition: none; } + + /* The How section is revealed on scroll; on paper it must always be there. */ + @media print { + opacity: 1; + transform: none; + transition: none; + } ` const ReadyToStart = styled.p` diff --git a/components/learn/WhyUseMaple/WhyUseMaple.tsx b/components/learn/WhyUseMaple/WhyUseMaple.tsx index fe4017e99..60e377c18 100644 --- a/components/learn/WhyUseMaple/WhyUseMaple.tsx +++ b/components/learn/WhyUseMaple/WhyUseMaple.tsx @@ -1,9 +1,17 @@ -import { useTranslation } from "next-i18next" +import { TFunction, useTranslation } from "next-i18next" +import { useEffect, useState } from "react" import styled from "styled-components" +import { Internal } from "../../links" import LearnBreadcrumb from "../LearnBreadcrumb" import LearnHeader from "../LearnHeader" import LearnLayout from "../LearnLayout" -import { CheckIcon, MegaphoneIcon, ScaleIcon, UsersIcon } from "../icons" +import { + CheckIcon, + ChevronDownIcon, + MegaphoneIcon, + ScaleIcon, + UsersIcon +} from "../icons" import { GREEN, NAVY, ORANGE, alpha } from "../palette" /** @@ -56,8 +64,7 @@ export const PERSONAS: Persona[] = [ "seeEveryone", "curateHistory", "legislativeResearch", - "changeNorms", - "signUp" + "changeNorms" ], body: ["bodytext"] }, @@ -134,22 +141,22 @@ const Detail = styled.div` ` const Hero = styled.div<{ $color: string }>` - padding: 1.5rem 1.75rem; + padding: 2rem; background-color: ${p => alpha(p.$color, 5)}; .tagline { font-family: var(--maple-font-heading); font-weight: 900; - font-size: 1.25rem; - line-height: 1.3; + font-size: 1.5rem; + line-height: 1.25; color: ${p => p.$color}; - margin-bottom: 0.5rem; + margin-bottom: 0.75rem; } .why { color: var(--maple-text-body); - font-size: 0.9375rem; - line-height: 1.6; + font-size: 1.0625rem; + line-height: 1.65; margin-bottom: 0; } @@ -174,22 +181,41 @@ const Offer = styled.div` } ul { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1.125rem 1.5rem; + /* One full-width column: each benefit is its own disclosure. */ + display: flex; + flex-direction: column; + gap: 0.5rem; margin: 0; padding: 0; list-style: none; - - @media (max-width: 48rem) { - grid-template-columns: 1fr; - } } li { + border: 1px solid var(--maple-surface-border); + border-radius: var(--maple-radius-lg); + overflow: hidden; + } + + .benefit-toggle { + width: 100%; display: flex; align-items: flex-start; gap: 0.75rem; + padding: 1rem 1.25rem; + background: none; + border: 0; + text-align: left; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: var(--maple-surface-muted); + } + + &:focus-visible { + outline: 2px solid var(--bs-blue); + outline-offset: -2px; + } } .tick { @@ -203,10 +229,85 @@ const Offer = styled.div` justify-content: center; } - .feature { - color: var(--maple-text-body); - font-size: 1rem; - line-height: 1.55; + .feature-title { + flex: 1; + min-width: 0; + font-weight: 700; + color: var(--maple-text-strong); + line-height: 1.4; + } + + .chevron { + flex-shrink: 0; + color: var(--maple-text-muted); + transition: transform 0.25s ease; + } + + .benefit-toggle[aria-expanded="true"] .chevron { + transform: rotate(180deg); + } + + /* Height transition without measuring the content; delayed visibility keeps + a closed body out of the accessibility tree and the focus order. */ + .benefit-body { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + visibility: hidden; + transition: grid-template-rows 0.35s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.2s ease, visibility 0s linear 0.35s; + } + + .benefit-body[data-open="true"] { + grid-template-rows: 1fr; + opacity: 1; + visibility: visible; + transition: grid-template-rows 0.35s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.25s ease 0.08s, visibility 0s; + } + + .benefit-body > div { + min-height: 0; + overflow: hidden; + } + + .benefit-body p { + color: var(--maple-text-muted); + font-size: 0.9375rem; + line-height: 1.6; + margin: 0; + padding: 0 1.25rem 1.25rem 3.25rem; + } + + @media (prefers-reduced-motion: reduce) { + .benefit-body, + .benefit-body[data-open="true"], + .chevron { + transition: none; + } + } + + /* Print the whole page: every benefit expanded, no toggles. */ + @media print { + .benefit-body { + display: block; + grid-template-rows: none; + opacity: 1; + visibility: visible; + transition: none; + } + + .benefit-body > div { + overflow: visible; + } + + .chevron { + display: none; + } + + .benefit-toggle { + cursor: auto; + } } @media (max-width: 36rem) { @@ -214,6 +315,36 @@ const Offer = styled.div` } ` +/** + * Supporting copy for a benefit. Most read a single `bodytext`; `fororgs`'s + * legislativeResearch is stored as bodytext1 + a link + bodytext2, so it is + * reassembled with an internal link to the bill search. + */ +const BenefitBody = ({ + ns, + benefitKey, + t +}: { + ns: string + benefitKey: string + t: TFunction +}) => { + const base = `${ns}:benefits.${benefitKey}` + const body = t(`${base}.bodytext`, { defaultValue: "" }) + if (body) return <>{body} + + const before = t(`${base}.bodytext1`, { defaultValue: "" }) + const linkText = t(`${base}.linkText`, { defaultValue: "" }) + const after = t(`${base}.bodytext2`, { defaultValue: "" }) + if (!before && !linkText && !after) return null + + return ( + <> + {before} {linkText} {after} + + ) +} + export const WhyUseMaple = ({ slug, onSelect @@ -230,6 +361,13 @@ export const WhyUseMaple = ({ const active = PERSONAS.find(p => p.slug === slug) ?? PERSONAS[0] + // One benefit open at a time. Keyed by persona so switching tabs starts fresh + // rather than leaving a stale key from the previous persona's list. + const [openBenefit, setOpenBenefit] = useState(null) + useEffect(() => { + setOpenBenefit(null) + }, [active]) + return ( @@ -284,20 +422,52 @@ export const WhyUseMaple = ({

    {t(`${active.ns}:benefits.header`)}

      - {active.benefits.map(key => ( -
    • - - - {t(`${active.ns}:benefits.${key}.title`)} - -
    • - ))} + {active.benefits.map(key => { + const open = openBenefit === key + return ( +
    • + +
      +
      +

      + +

      +
      +
      +
    • + ) + })}
    diff --git a/public/locales/en/forindividuals.json b/public/locales/en/forindividuals.json index 048c3cd8e..8e4be66c4 100644 --- a/public/locales/en/forindividuals.json +++ b/public/locales/en/forindividuals.json @@ -12,7 +12,7 @@ "bodytext": "Before the Massachusetts state legislature can pass a bill, they convene three committees to hear public testimony on the policy changes that bill proposes. Don't miss out on the chance to make your perspective heard; submit your testimony to use your voice to shape public policy for our Commonwealth." }, "multipleSides": { - "title": "Hear from multiples sides and perspectives", + "title": "Hear from multiple sides and perspectives", "bodytext": "As a non-partisan platform, all stakeholders can use MAPLE to share their perspective on issues and bills. See what others are saying to get more informed about the most important public policy issues of the day." }, "trustedOrgs": { diff --git a/public/locales/en/fororgs.json b/public/locales/en/fororgs.json index bef27ea7b..56a900826 100644 --- a/public/locales/en/fororgs.json +++ b/public/locales/en/fororgs.json @@ -1,7 +1,7 @@ { "title": "MAPLE for Organizations", "callToAction": { - "header": "Why use MAPLE", + "header": "Why use MAPLE", "title": "Get your message out and rally your supporters in civic discourse.", "bodytext": "It takes only a couple minutes to sign up for your organization's account on MAPLE, giving you immediate access to publish and share your testimony, designate your priority bills, connect with your members and followers, and research legislation." }, @@ -9,7 +9,7 @@ "header": "What we offer", "reach": { "title": "Help your priorities & positions reach a wider audience", - "bodytext": " Your organization's testimony will be highlighted to MAPLE visitors when they look up bills, with easy tools for sharing on social media. Testimony published on MAPLE will be seen by legislators, residents, members of the media, and other stakeholders. The more organizations share their testimony on the platform, the more people will see it. You can even designate bills as your legislative priorities to signal your focus for each session." + "bodytext": "Your organization's testimony will be highlighted to MAPLE visitors when they look up bills, with easy tools for sharing on social media. Testimony published on MAPLE will be seen by legislators, residents, members of the media, and other stakeholders. The more organizations share their testimony on the platform, the more people will see it. You can even designate bills as your legislative priorities to signal your focus for each session." }, "connect": { "title": "Connect with your members, and grow your reach", @@ -34,7 +34,7 @@ "legislativeResearch": { "title": "Speed up your legislative research", "bodytext1": "MAPLE is fast. (Really fast.). Try", - "linkText": "searching for a bill", + "linkText": "searching for a bill", "bodytext2": "to see just how much MAPLE can speed up your legislative research process; no sign in required!" }, "changeNorms": { diff --git a/public/locales/en/learn.json b/public/locales/en/learn.json index 41dc36340..08a482f1a 100644 --- a/public/locales/en/learn.json +++ b/public/locales/en/learn.json @@ -96,7 +96,7 @@ { "title": "Call your legislator", "body": "You can contact your legislators any time by looking up their contact information on the MA Legislature website. Your voice will probably carry the most weight with the House and Senate representatives of your own district, but you are free to contact Committee Chairs or any other member of the legislature with your opinions. You could request a meeting in person.", - "linkLabel": "Find your legislator's number:", + "linkLabel": "Find your legislator's contact info:", "linkText": "malegislature.gov/Search/FindMyLegislator", "href": "https://malegislature.gov/Search/FindMyLegislator" } @@ -140,8 +140,6 @@ "title": "Understanding the Legislative Process", "subhead": "How most bills become laws in Massachusetts.", "railLabel": "Legislative process stages", - "expandAll": "Click here to expand all sections", - "collapseAll": "Click here to collapse all sections", "stages": [ "Bill Introduction", "Committee Referral", From 8415911d3069b39b2b18412d0943ac0032e87df6 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 11:16:38 -0400 Subject: [PATCH 038/106] Reduce the rail's look-ahead to 120px The next stage now lights up with a little less of the previous chapter still showing. --- components/learn/Process/LegislativeProcess.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx index 723a05a65..9a3d14a5d 100644 --- a/components/learn/Process/LegislativeProcess.tsx +++ b/components/learn/Process/LegislativeProcess.tsx @@ -642,7 +642,7 @@ export const LegislativeProcess = () => { // Look-ahead: a stage lights up while its chapter is still approaching the // rail, so the next step is highlighted with a little of the previous one // still in view. Measured down from the rail's live bottom edge. - const LOOK_AHEAD = 180 + const LOOK_AHEAD = 120 const rows = Array.from( document.querySelectorAll('[id^="learn-process-stage-"]') From 02551628f26a989872541d4dc62d2c87b352b7fd Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 11:26:32 -0400 Subject: [PATCH 039/106] Make the rail behave differently going up than going down Scrolling down, the next stage lights while some of the current chapter is still in view. Scrolling up, a stage only lights once the top of its own card is back in view below the rail -- a stage never lights while its card's top is off-screen above the reader. The highlight now steps from the current stage rather than being recomputed each frame. Recomputing made the upward handover fire about 500px late. The asymmetry between the two lines is also the hysteresis: changing the highlight back costs a whole card's height. An earlier attempt moved a single line with the direction of travel, which was much worse -- it jumped by the width of the gap whenever the reader reversed, so the highlight flickered on the smallest nudge (23 changes across 24 alternating scrolls; now 0). --- .../learn/Process/LegislativeProcess.tsx | 52 ++++++++++++++----- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx index 9a3d14a5d..72730e5df 100644 --- a/components/learn/Process/LegislativeProcess.tsx +++ b/components/learn/Process/LegislativeProcess.tsx @@ -624,11 +624,27 @@ export const LegislativeProcess = () => { return () => observer.disconnect() }, []) - // Scroll drives the rail's highlight, and nothing else. The active chapter is - // the last one whose top has passed under the rail -- i.e. the one the reader - // is currently reading. Because this changes no layout, it cannot oscillate. + // Scroll drives the rail's highlight, and nothing else. Because this changes + // no layout, it cannot reflow the page. + // + // The highlight steps from the current stage rather than being recomputed from + // scratch, using two lines measured from the rail's live bottom edge: + // + // advancing the next chapter's top crossing a line a little below the rail + // lights the next stage, while some of the current chapter is + // still in view + // retreating the previous stage only lights again once the top of its own + // card has come back into view below the rail -- a stage never + // lights while its card's top is off-screen above + // + // The asymmetry is deliberate, and it is also what gives the switch its + // hysteresis: going back up costs a whole card's height. A line that moved + // with the direction of travel was much worse -- it jumped by the width of the + // gap whenever the reader changed direction, so the highlight flickered on the + // smallest nudge. useEffect(() => { let frame = 0 + const evaluate = () => { frame = 0 if (performance.now() < suppressUntilRef.current) return @@ -639,20 +655,30 @@ export const LegislativeProcess = () => { // Once the rail has scrolled away there is nothing to track against. if (railBottom <= 0) return - // Look-ahead: a stage lights up while its chapter is still approaching the - // rail, so the next step is highlighted with a little of the previous one - // still in view. Measured down from the rail's live bottom edge. - const LOOK_AHEAD = 120 + const ADVANCE_AT = railBottom + 120 - const rows = Array.from( + const tops = Array.from( document.querySelectorAll('[id^="learn-process-stage-"]') - ) - let next = 0 - rows.forEach((row, i) => { - if (row.getBoundingClientRect().top <= railBottom + LOOK_AHEAD) next = i + ).map(row => row.getBoundingClientRect().top) + if (!tops.length) return + + setActiveIdx(prev => { + let i = Math.min(prev, tops.length - 1) + + // Scrolling down: step forward while the next chapter's top has crossed + // the advance line. Loops so a fast scroll catches up in one frame. + while (i + 1 < tops.length && tops[i + 1] <= ADVANCE_AT) i++ + + // Scrolling up: only when we did not just advance, step back while the + // previous chapter's own top is back in view below the rail. + if (i === prev) { + while (i > 0 && tops[i - 1] >= railBottom) i-- + } + + return i === prev ? prev : i }) - setActiveIdx(prev => (prev === next ? prev : next)) } + const schedule = () => { if (frame) return frame = requestAnimationFrame(evaluate) From 59ab45a9e992efcf6fac1ee7620baf0cd78b695c Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 11:56:07 -0400 Subject: [PATCH 040/106] Trigger Vercel preview From 9a3b1a5ca3ee85653ae367b405fee599024defb1 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 13:36:48 -0400 Subject: [PATCH 041/106] Land every rail-click chapter cleanly below the sticky nav Clicking Public Hearing through Governor's Action used to leave a ~150px gap between the chapter's header and the rail, while Bill Introduction and Committee Referral landed snug. The cause is the site's own layout: the navbar is sticky-top inside a fixed-height (vh-100) container, so it stays pinned only while that container is in view and then scrolls away with it. Reserving the navbar's height is right for chapters that land while it is still pinned and wrong for the deeper ones, where it is already gone. The scroll target now models that release deterministically. The navbar's bottom follows min(navHeight, containerBottom - scrollY); solving for the scroll that lands a chapter GAP below the rail gives two regimes -- reserve the navbar while it is pinned, don't once it has left -- split at the measured release point rather than a hardcoded chapter index, so it holds on any viewport. Below the 768px breakpoint the navbar swaps to a non-sticky MobileNav that is gone at once, so nothing reserves it there. That is detected by reading the rendered navbar's position rather than duplicating the breakpoint, so it stays in sync with Navbar. Verified at 1280px and 390px: all six chapters land at the same gap, none behind the header, no bounce. --- .../learn/Process/LegislativeProcess.tsx | 74 +++++++++++-------- 1 file changed, 45 insertions(+), 29 deletions(-) diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx index 72730e5df..023b4ee01 100644 --- a/components/learn/Process/LegislativeProcess.tsx +++ b/components/learn/Process/LegislativeProcess.tsx @@ -703,44 +703,60 @@ export const LegislativeProcess = () => { pendingScrollRef.current = null const row = document.getElementById(rowId(target)) - const header = document.getElementById(headerId(target)) const rail = railRef.current - if (!row || !header || !rail) return + if (!row || !rail) return const reduceMotion = prefersReducedMotion() - const behavior: ScrollBehavior = reduceMotion ? "auto" : "smooth" - const GAP = 16 // Don't let the rail chase the smooth scroll on its way past other chapters. suppressUntilRef.current = performance.now() + (reduceMotion ? 50 : PANEL_TRANSITION_MS + 350) setActiveIdx(target) - // The row's height once its panel has finished opening. The inner content is - // laid out even while the panel is collapsed, so it can be measured up front. - const inner = row.querySelector(".inner") - const panel = document.getElementById(panelId(target)) - const finalRowHeight = - row.getBoundingClientRect().height - - (panel ? panel.getBoundingClientRect().height : 0) + - (inner ? inner.scrollHeight : 0) - - const desiredTop = rail.getBoundingClientRect().bottom + GAP - const top = header.getBoundingClientRect().top - const bottom = top + finalRowHeight - const viewportBottom = window.innerHeight - GAP - - // Positive delta scrolls the page down (content moves up). - let delta = 0 - if (top < desiredTop) { - // Sitting under the rail: bring its top border to just below it. - delta = top - desiredTop - } else if (bottom > viewportBottom) { - // Below the fold: rise only as far as needed, never past the rail. - delta = Math.min(bottom - viewportBottom, top - desiredTop) - } - - if (Math.abs(delta) > 1) window.scrollBy({ top: delta, behavior }) + // Scroll so the chapter's top sits GAP below the rail, in document + // coordinates. The rail sticks beneath the site navbar, which is + // `sticky-top` inside a fixed-height (vh-100) page container -- so the navbar + // only stays pinned while that container is in view, then scrolls away with + // it. Its bottom edge therefore follows navbarBottom(y) = min(navHeight, + // V - y), where V is the container's document height. Solving for the scroll + // that lands the chapter GAP below the rail gives two regimes: + // + // navbar still pinned at the destination -> reserve its height + // navbar already gone (deeper chapters) -> do not + // + // We measure the release point rather than hardcode which chapters are past + // it, so it stays correct on any viewport. + // + // Below the 768px breakpoint the navbar swaps to the non-sticky MobileNav, + // which is gone the instant you scroll -- so nothing reserves it there. We + // detect that by reading the rendered navbar's `position` instead of + // duplicating the breakpoint, so this can't drift out of sync with Navbar. + // + // Sticky live rects are unreliable (they report the unstuck position near + // the top and the stuck one after scrolling), so we work from intrinsic + // heights and the container's document bottom, all constant at any scroll. + const GAP = 16 + const nav = document.querySelector(".main-navbar") + const navHeight = nav ? nav.offsetHeight : 0 + const navSticky = !!nav && getComputedStyle(nav).position === "sticky" + const containerBottom = nav?.parentElement + ? nav.parentElement.getBoundingClientRect().bottom + window.scrollY + : window.innerHeight + const railHeight = rail.offsetHeight + const rowDocTop = row.getBoundingClientRect().top + window.scrollY + + // Desktop: the sticky navbar releases at the container's bottom edge. + // Mobile: the navbar never sticks, so it is effectively released at once. + const releasePoint = navSticky ? containerBottom - navHeight : 0 + const yWithNavbar = rowDocTop - navHeight - railHeight - GAP + const targetY = Math.max( + 0, + yWithNavbar <= releasePoint ? yWithNavbar : rowDocTop - railHeight - GAP + ) + window.scrollTo({ + top: targetY, + behavior: reduceMotion ? "auto" : "smooth" + }) }, [scrollNonce]) // On narrow screens the rail overflows horizontally. Keep the active node From 1144bd7af7582f0e659f8727efebc4cc7d8ca75b Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 13:47:51 -0400 Subject: [PATCH 042/106] Fix two mobile navbar/rail issues Rail: on narrow screens the journey rail scrolls horizontally, and setting overflow-x to auto forces overflow-y to auto as well, which clipped the top of the active node -- it is scaled up 12% and has a shadow. The scroll strip now has vertical padding (with a compensating negative margin so the card height is unchanged) and the connecting line shifts down to match, so the active circle is no longer shaved off. Mobile nav dropdown (app-wide): inside the hamburger the dropdown menu is white, but its items carried .navLink-primary -- white text meant for the top-level links on the dark navbar -- so they were invisible, white on white. A scoped override gives items inside #basic-navbar-nav .dropdown-menu the same dark, readable text the desktop dropdown uses. Desktop is unaffected. --- .../learn/Process/LegislativeProcess.tsx | 8 ++++++++ styles/globals.css | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx index 023b4ee01..ad814fdeb 100644 --- a/components/learn/Process/LegislativeProcess.tsx +++ b/components/learn/Process/LegislativeProcess.tsx @@ -117,7 +117,15 @@ const RailTrack = styled.ol` &::-webkit-scrollbar { display: none; } + /* Setting overflow-x to auto forces overflow-y to auto as well, which clips + the active node -- it is scaled up 12% and has a shadow. Pad the strip + vertically so it has room; the negative margin keeps the card's height + unchanged. The connecting line shifts down by the same amount to stay + centered on the nodes. */ + padding-block: 0.5rem; + margin-block: -0.5rem; &::before { + top: calc(1.6875rem + 0.5rem); right: auto; width: max(100%, 30rem); } diff --git a/styles/globals.css b/styles/globals.css index e19cd1372..f8c3cab75 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -269,6 +269,24 @@ width: fit-content; } +/* Inside the mobile hamburger, the dropdown menu is white, but its items carry + .navLink-primary (white text, for the top-level links on the dark navbar) — + white on white, invisible. Restore the dark, readable text the desktop + dropdown uses. */ +#basic-navbar-nav .dropdown-menu .dropdown-item, +#basic-navbar-nav .dropdown-menu .navLink-primary { + color: var(--maple-text-body) !important; + font-weight: 400; +} + +#basic-navbar-nav .dropdown-menu .dropdown-item:hover, +#basic-navbar-nav .dropdown-menu .dropdown-item:focus, +#basic-navbar-nav .dropdown-menu .navLink-primary:hover, +#basic-navbar-nav .dropdown-menu .navLink-primary:focus { + color: var(--bs-blue) !important; + background-color: var(--maple-surface-muted); +} + .offcanvas { background-color: var(--bs-body-bg) !important; } From 9743156de43cc5bc28da727a706a0cd354747766 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 13:58:04 -0400 Subject: [PATCH 043/106] Keep the persona tabs on one row with short labels The persona tabs used to stack vertically on narrow screens because their labels ("MAPLE for Individuals", etc.) were too long to sit side by side. The "MAPLE for" prefix is redundant on the tabs, so they now read just "Individuals", "Organizations", and "Legislators" (new tabLabel keys, kept translatable). At that length all three stay on a single row at every width down to 320px, icon above the short label; the full title remains as each tab's accessible name. --- components/learn/WhyUseMaple/WhyUseMaple.tsx | 39 +++++++++++++++----- public/locales/en/forindividuals.json | 1 + public/locales/en/forlegislators.json | 3 +- public/locales/en/fororgs.json | 1 + 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/components/learn/WhyUseMaple/WhyUseMaple.tsx b/components/learn/WhyUseMaple/WhyUseMaple.tsx index 60e377c18..0b5feb61d 100644 --- a/components/learn/WhyUseMaple/WhyUseMaple.tsx +++ b/components/learn/WhyUseMaple/WhyUseMaple.tsx @@ -85,17 +85,18 @@ export const PERSONAS: Persona[] = [ ] const PersonaGrid = styled.div` - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + display: flex; gap: 0.75rem; margin-bottom: 1.25rem; - - @media (max-width: 48rem) { - grid-template-columns: 1fr; - } ` const PersonaCard = styled.button<{ $color: string; $active: boolean }>` + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; border-radius: var(--maple-radius-lg); padding: 0.875rem 1rem; text-align: left; @@ -104,17 +105,17 @@ const PersonaCard = styled.button<{ $color: string; $active: boolean }>` p.$active ? p.$color : "var(--maple-surface-base)"}; box-shadow: var(--maple-shadow-sm); cursor: pointer; - transition: background-color 0.2s ease, border-color 0.2s ease; + transition: background-color 0.2s ease, border-color 0.2s ease, flex 0.2s ease; .glyph { - display: block; - margin-bottom: 0.25rem; + flex-shrink: 0; color: ${p => (p.$active ? "#fff" : "var(--bs-gray-400, #9ca3af)")}; } .label { font-weight: 700; font-size: 0.8125rem; + line-height: 1.2; margin: 0; color: ${p => (p.$active ? "#fff" : "#374151")}; } @@ -128,6 +129,18 @@ const PersonaCard = styled.button<{ $color: string; $active: boolean }>` outline-offset: 3px; } + /* All three keep their short label on one row. Center and tighten a little + on narrow screens so "Organizations" still fits. */ + @media (max-width: 40rem) { + align-items: center; + text-align: center; + padding: 0.75rem 0.5rem; + + .label { + font-size: 0.75rem; + } + } + @media (prefers-reduced-motion: reduce) { transition: none; } @@ -381,6 +394,8 @@ export const WhyUseMaple = ({ {PERSONAS.map(persona => { const isActive = persona.slug === active.slug + const label = t(`${persona.ns}:title`) + const tabLabel = t(`${persona.ns}:tabLabel`) return ( onSelect(persona.slug)} $color={persona.color} @@ -399,7 +416,9 @@ export const WhyUseMaple = ({ className="glyph" sx={{ fontSize: "1.5rem" }} /> -

    {t(`${persona.ns}:title`)}

    +
    ) })} diff --git a/public/locales/en/forindividuals.json b/public/locales/en/forindividuals.json index 8e4be66c4..fe77c8e57 100644 --- a/public/locales/en/forindividuals.json +++ b/public/locales/en/forindividuals.json @@ -1,5 +1,6 @@ { "title": "MAPLE for Individuals", + "tabLabel": "Individuals", "callToAction": { "header": "Why use MAPLE", "title": "Make your voice count in the laws that shape our future.", diff --git a/public/locales/en/forlegislators.json b/public/locales/en/forlegislators.json index 38c8a5685..9fb9db5c9 100644 --- a/public/locales/en/forlegislators.json +++ b/public/locales/en/forlegislators.json @@ -1,7 +1,8 @@ { "title": "MAPLE for Legislators", + "tabLabel": "Legislators", "callToAction": { - "header": "Why use MAPLE", + "header": "Why use MAPLE", "title": "Gain free, nonpartisan insight into how constituents are thinking about any proposed measure.", "bodytextOne": "Even before you create your own account, you can search our lightning-fast platform for all testimony submitted by MAPLE users about any bill. Our advanced features let you filter to bills sponsored by your office, bills concerning your city, or bills that you choose to follow.", "bodytextTwo": "Why trust MAPLE? We are a nonpartisan, nonprofit, volunteer-developed civic technology initiative focused on increasing engagement between the legislature and its constituents. We offer our product as an open source public good; we are committed not to charging our users and we will never sell the data from our platform." diff --git a/public/locales/en/fororgs.json b/public/locales/en/fororgs.json index 56a900826..4160cf62b 100644 --- a/public/locales/en/fororgs.json +++ b/public/locales/en/fororgs.json @@ -1,5 +1,6 @@ { "title": "MAPLE for Organizations", + "tabLabel": "Organizations", "callToAction": { "header": "Why use MAPLE", "title": "Get your message out and rally your supporters in civic discourse.", From cdb4bfb299cc011e258332382bea765fd3502bc4 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 14:00:49 -0400 Subject: [PATCH 044/106] Show the active-state look on persona-tab hover, instantly Hovering an inactive tab fills it with its persona color and turns the icon and label white -- the same look as the selected tab, no border. The active tab already looks this way. The hover is instant: the card's transition is removed, so the fill and text change together with no fade. --- components/learn/WhyUseMaple/WhyUseMaple.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/components/learn/WhyUseMaple/WhyUseMaple.tsx b/components/learn/WhyUseMaple/WhyUseMaple.tsx index 0b5feb61d..1919eb9ac 100644 --- a/components/learn/WhyUseMaple/WhyUseMaple.tsx +++ b/components/learn/WhyUseMaple/WhyUseMaple.tsx @@ -105,7 +105,6 @@ const PersonaCard = styled.button<{ $color: string; $active: boolean }>` p.$active ? p.$color : "var(--maple-surface-base)"}; box-shadow: var(--maple-shadow-sm); cursor: pointer; - transition: background-color 0.2s ease, border-color 0.2s ease, flex 0.2s ease; .glyph { flex-shrink: 0; @@ -120,8 +119,15 @@ const PersonaCard = styled.button<{ $color: string; $active: boolean }>` color: ${p => (p.$active ? "#fff" : "#374151")}; } + /* Hover fills the tab with its color and turns the text and icon white -- the + same look as the selected state. The active tab already looks this way. */ &:hover { - border-color: ${p => p.$color}; + background-color: ${p => p.$color}; + + .glyph, + .label { + color: #fff; + } } &:focus-visible { @@ -140,10 +146,6 @@ const PersonaCard = styled.button<{ $color: string; $active: boolean }>` font-size: 0.75rem; } } - - @media (prefers-reduced-motion: reduce) { - transition: none; - } ` const Detail = styled.div` From 96880c4127cbd91881050e8a957b8dfafbf72b7b Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 14:21:44 -0400 Subject: [PATCH 045/106] Make the active mobile nav dropdown item bold white The active item in the mobile hamburger dropdown keeps Bootstrap's filled background (the brand red), but the earlier fix that darkened dropdown text for readability caught the active item too -- dark on red. Restore white, bold text for the active item so it reads against its red background. --- styles/globals.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/styles/globals.css b/styles/globals.css index f8c3cab75..c74e7206b 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -287,6 +287,14 @@ background-color: var(--maple-surface-muted); } +/* The active item keeps Bootstrap's filled background (the brand red), so its + text must be white and bold, not the dark body color the rule above sets. */ +#basic-navbar-nav .dropdown-menu .dropdown-item.active, +#basic-navbar-nav .dropdown-menu .navLink-primary.active { + color: var(--maple-text-inverse) !important; + font-weight: 700; +} + .offcanvas { background-color: var(--bs-body-bg) !important; } From cd92a1fa1c46738878ced42d370f39c6d1e2767e Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 14:30:33 -0400 Subject: [PATCH 046/106] Stop the rail's initial-scroll jank on mobile On mobile the rail's sticky top was bound to --maple-navbar-height, which a scroll handler rewrote every frame while the non-sticky MobileNav scrolled away (82px -> 0 over the first ~96px). Each rewrite forced the browser to recompute the sticky rail, which was the janky initial scroll before the rail "locked". Desktop was unaffected because the sticky navbar keeps the value constant. The navbar has already scrolled away by the time the rail reaches the top on mobile, so the rail just sticks at top: 0 via CSS below the 768px breakpoint, and the per-frame tracking now runs only when the navbar is actually sticky (desktop). No churn, smooth initial scroll; desktop behavior is unchanged. --- .../learn/Process/LegislativeProcess.tsx | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/components/learn/Process/LegislativeProcess.tsx b/components/learn/Process/LegislativeProcess.tsx index ad814fdeb..24f64f1de 100644 --- a/components/learn/Process/LegislativeProcess.tsx +++ b/components/learn/Process/LegislativeProcess.tsx @@ -46,14 +46,21 @@ const headerId = (i: number) => `learn-process-header-${i}` const RailWrapper = styled.div` position: sticky; - /* Measured at runtime — see the effect in LegislativeProcess. Falls back to a - sensible constant before the first layout pass. */ + /* Desktop: measured at runtime to sit below the sticky navbar (see the + nav-offset effect). Falls back to a sensible constant before first paint. */ top: var(--maple-navbar-height, 6rem); z-index: 2; margin-inline: -2rem; padding: 0.5rem 2rem 1rem; background-color: var(--maple-surface-learn); + /* Below the 768px nav breakpoint the navbar is non-sticky and has scrolled + away before the rail reaches the top, so the rail simply sticks at 0 -- no + per-frame tracking, which is what made the initial scroll janky here. */ + @media (max-width: 48rem) { + top: 0; + } + @media (max-width: 36rem) { margin-inline: -1rem; padding: 0.5rem 1rem 0.75rem; @@ -571,22 +578,26 @@ export const LegislativeProcess = () => { // rail should not chase the intermediate positions. const suppressUntilRef = useRef(0) - // The rail must sit directly beneath whatever remains of the site navbar. + // The rail must sit directly beneath whatever remains of the sticky site + // navbar, which stays pinned for most of the page on desktop, so the offset + // tracks the navbar's current bottom edge on scroll. // - // A one-off height measurement is not enough: the navbar's `sticky-top` does - // not always keep it pinned (it scrolls away on the mobile navbar, and its - // stickiness can be defeated by an ancestor), which would leave the rail - // floating with a strip of page content visible above it. So the offset - // tracks the navbar's *current* bottom edge on scroll, clamped at zero, and - // the rail hugs the very top once the navbar has scrolled out of view. + // This runs on desktop only. Below the breakpoint the navbar is the + // non-sticky MobileNav: it scrolls away immediately, and the rail does not + // reach the top until well after it is gone, so the rail simply sticks at + // top: 0 (set in CSS). Tracking there would rewrite the sticky offset every + // frame during the navbar's exit -- recomputing the sticky rail each frame -- + // which is exactly the initial-scroll jank on mobile. So we skip it entirely + // when the navbar is not sticky. useLayoutEffect(() => { const scope = scopeRef.current - if (!scope) return + const nav = document.querySelector(".main-navbar") + if (!scope || !nav) return + if (getComputedStyle(nav).position !== "sticky") return let frame = 0 const publishNavOffset = () => { - const nav = document.querySelector(".main-navbar") - const bottom = nav ? nav.getBoundingClientRect().bottom : 0 + const bottom = nav.getBoundingClientRect().bottom scope.style.setProperty( "--maple-navbar-height", `${Math.max(0, bottom)}px` @@ -604,9 +615,8 @@ export const LegislativeProcess = () => { window.addEventListener("scroll", schedule, { passive: true }) window.addEventListener("resize", schedule) - const nav = document.querySelector(".main-navbar") const observer = new ResizeObserver(schedule) - if (nav) observer.observe(nav) + observer.observe(nav) return () => { if (frame) cancelAnimationFrame(frame) From d885c2ec3e0a9b295cebe0575841f72fe45ad3c0 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 10 Jul 2026 14:38:41 -0400 Subject: [PATCH 047/106] Loosen line-height and padding on the AI Research Tools page Bump the body copy to 140% line-height and add more padding inside the section cards and between them, so the MCP setup instructions read less cramped. Scoped to this page: DescrContainer/SectionContainer/SectionTitle are shared with other pages, so the line-height is overridden through a local styled wrapper and the padding via this page's own utility classes, with a note to revisit whether the looser spacing should become a global default. --- components/learn/AiTools/AiTools.tsx | 55 +++++++++++++++++----------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/components/learn/AiTools/AiTools.tsx b/components/learn/AiTools/AiTools.tsx index 65cd4e907..244b858a5 100644 --- a/components/learn/AiTools/AiTools.tsx +++ b/components/learn/AiTools/AiTools.tsx @@ -60,6 +60,19 @@ const ExampleLabel = styled.div` margin-bottom: 0.25rem; ` +// NOTE: This 140% line-height (and the extra card/section padding bumped into +// the JSX classNames below) is deliberately scoped to the AI Research Tools +// page. DescrContainer/SectionContainer/SectionTitle are shared with other +// pages (about/MapleAI, OurTeam), so we override locally rather than editing the +// shared components. TODO: revisit whether this spacing reads better everywhere +// and should be promoted to a global change on those shared components. +const AiToolsBody = styled(Container)` + ${DescrContainer}, + ${StepText} { + line-height: 1.4; + } +` + export const AiTools = () => { const { t } = useTranslation("aiTools") const { t: tLearn } = useTranslation("learn") @@ -75,16 +88,16 @@ export const AiTools = () => { subhead={t("description")} titleSize="2.25rem" /> - + {/* What is it */} - + - {t("section1.title")} - + {t("section1.title")} + {t("section1.desc1")} - + {t("section1.desc2Pre")}{" "} { {/* What you can do */} - + - {t("section2.title")} - + {t("section2.title")} + {t("section2.intro")} - +
    diff --git a/next.config.js b/next.config.js index 35aa9c4f5..154e17d70 100644 --- a/next.config.js +++ b/next.config.js @@ -19,9 +19,9 @@ module.exports = { async redirects() { return [ redirectFirebaseAuthHandlers(), - // The four Learn testimony tabs were consolidated into /learn/testimony. - // These slugs were linked from the footer and from external sites, so - // they redirect permanently rather than 404. Each of the three topic tabs + // Three of the four old Learn testimony tabs were consolidated into + // /learn/testimony. These slugs were linked from the footer and from + // external sites, so they redirect permanently rather than 404. Each tab // became a section of the new page and keeps its old slug as an anchor id // (see ANCHORS in components/learn/Testimony/AboutTestimony.tsx), so the // reader lands on the content that replaced the page they asked for @@ -30,7 +30,6 @@ module.exports = { ...[ "testimony-basics", "role-of-testimony", - "writing-effective-testimony", "communicating-with-legislators" ].map(slug => ({ source: `/learn/${slug}`, diff --git a/pages/learn/writing-effective-testimony.tsx b/pages/learn/writing-effective-testimony.tsx new file mode 100644 index 000000000..e4440a0e0 --- /dev/null +++ b/pages/learn/writing-effective-testimony.tsx @@ -0,0 +1,15 @@ +import { createPage } from "../../components/page" +import WritingTips from "components/learn/Testimony/WritingTips" +import { createGetStaticTranslationProps } from "components/translations" + +export default createPage({ + titleI18nKey: "titles.writing_testimony", + Page: () => +}) + +export const getStaticProps = createGetStaticTranslationProps([ + "auth", + "common", + "footer", + "learn" +]) diff --git a/public/locales/en/common.json b/public/locales/en/common.json index ec1f29d0f..663c00874 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -123,7 +123,7 @@ "hearings": "Hearings", "home": "Home", "learnAboutTestimony": "Learn About Testimony", - "legislativeProcess": "About the MA Legislative Process", + "legislativeProcess": "The MA Legislative Process", "legislator": "Legislator", "logo": "Massachusetts Platform for Legislative Engagement home", "main": "Main navigation", @@ -135,8 +135,9 @@ "privacyAndConduct": "Privacy Policy & Code of Conduct", "signOut": "Sign Out", "supportMaple": "Support MAPLE", - "aboutTestimony": "About Testimony", + "aboutTestimony": "How Testimony Works", "aiTools": "AI Research Tools", + "writingTestimony": "Writing Effective Testimony", "testimony": "Testimony", "viewProfile": "View Profile", "whyUseMaple": "Why Use MAPLE", @@ -201,7 +202,8 @@ "why_use_maple": "Why Use Maple?", "ai_tools": "AI Research Tools", "learn_hub": "Learn", - "about_testimony": "How Testimony Works" + "about_testimony": "How Testimony Works", + "writing_testimony": "How to Write Effective Testimony" }, "user_updates": "User Updates", "view": "View", diff --git a/public/locales/en/forindividuals.json b/public/locales/en/forindividuals.json index fe77c8e57..b355a8902 100644 --- a/public/locales/en/forindividuals.json +++ b/public/locales/en/forindividuals.json @@ -1,6 +1,7 @@ { "title": "MAPLE for Individuals", "tabLabel": "Individuals", + "personaLabel": "Individuals", "callToAction": { "header": "Why use MAPLE", "title": "Make your voice count in the laws that shape our future.", diff --git a/public/locales/en/forlegislators.json b/public/locales/en/forlegislators.json index 9fb9db5c9..963709709 100644 --- a/public/locales/en/forlegislators.json +++ b/public/locales/en/forlegislators.json @@ -1,6 +1,7 @@ { "title": "MAPLE for Legislators", "tabLabel": "Legislators", + "personaLabel": "Legislators", "callToAction": { "header": "Why use MAPLE", "title": "Gain free, nonpartisan insight into how constituents are thinking about any proposed measure.", diff --git a/public/locales/en/fororgs.json b/public/locales/en/fororgs.json index 4160cf62b..6f40c2120 100644 --- a/public/locales/en/fororgs.json +++ b/public/locales/en/fororgs.json @@ -1,6 +1,7 @@ { "title": "MAPLE for Organizations", "tabLabel": "Organizations", + "personaLabel": "Organizations", "callToAction": { "header": "Why use MAPLE", "title": "Get your message out and rally your supporters in civic discourse.", diff --git a/public/locales/en/learn.json b/public/locales/en/learn.json index 08a482f1a..276d5fc9d 100644 --- a/public/locales/en/learn.json +++ b/public/locales/en/learn.json @@ -40,46 +40,46 @@ { "badge": "who", "headline": "Anyone can submit testimony", - "body": "Legislators value testimony most when it comes from their own constituents. Testimony from MA residents is typically directed to both the committee responsible for the bill and the legislators representing your district." + "body": "Legislators value testimony most when it comes from their own constituents. Testimony from Massachusetts residents is typically directed to both the committee responsible for the bill and the legislators representing you in your district." }, { "badge": "what", - "headline": "Make it distinctive and relevant", - "body": "Write your own text and explain why you are personally interested in an issue. Form letters still count, but an individual, personalized letter will always have greater impact." + "headline": "It should be distinctive and relevant", + "body": "Writing your own text and explaining why you are interested in an issue gives legislators valuable, personalized insight into their constituents. Form letters still count, but a personalized, individual letter will always have greater impact." }, { "badge": "when", - "headline": "Before the committee hearing date", - "body": "Committees generally accept testimony up until the public hearing date for a bill. You can use bill pages on this site to identify relevant dates. Submit before the hearing for the greatest impact." + "headline": "Submit before the hearing date", + "body": "Committees generally accept testimony up until the public hearing date for a bill. You can use Maple's bill pages on this site or this link to identify relevant dates. Submit before the hearing for the greatest impact." }, { "badge": "where", - "headline": "By email to committee chairs", - "body": "MAPLE makes it easy to find a bill you want to testify on and generate an email — which you fully control — to send to the relevant committee personnel." + "headline": "Use MAPLE to submit testimony", + "body": "Browse our bill and ballot question pages to find the measures that matter to you. Select one and follow the prompts to compose your testimony. When you are happy with your testimony, MAPLE sends it to the relevant committee personnel." } ], "why": { - "headline": "To shape the laws that govern your life", + "headline": "Legislators need your input in order to represent you", "body": "If you don't share your perspective, it may not be taken into account when policymakers make decisions about the laws that govern all our lives. By speaking up, you can make the laws of Massachusetts work better for all of us. MAPLE is designed to make it more accessible.", "mattersHeading": "Why it Matters:", "matters": [ { - "title": "Your voice shapes the legislative agenda", - "body": "It can guide what topics and bills the legislature considers, and how they decide to act and vote on each one." + "title": "Your voice is instrumental to the legislative process", + "body": "Testimony can guide the agenda of the legislature, from what topics and bills they consider, to how they decide to act and vote on each measure." }, { - "title": "You give legislators real-world insight", - "body": "Testimony informs legislators of the benefits or negative consequences of proposed policies — context that doesn't always appear in the official record." + "title": "You give lawmakers real-world insight", + "body": "It is important for legislators to understand how communities may be positively or negatively impacted by proposed policies — context that doesn't always appear in the official record." }, { "title": "You can recommend specific changes", - "body": "You can suggest concrete improvements to legislation, whether you generally support or oppose a bill. Specificity makes your testimony more actionable." + "body": "You can suggest concrete improvements to legislation, whether you generally support or oppose a bill. Specificity makes your testimony more actionable to legislators." } ] }, "readyToStart": "Ready to get started?", "how": { - "headline": "To make your voice heard", + "headline": "Making your voice heard", "intro": "There are multiple ways to share your perspective and knowledge with your legislators.", "steps": [ { @@ -102,24 +102,24 @@ } ] }, - "tipsToggle": "Tips for writing effective testimony", - "tipsIntro": "Clearly outline what bill you are testifying about, whether you support or oppose it, why you are concerned about the issue, what policy you would like to see enacted, and what legislative district you live in.", + "tipsLink": "Need help? <0>Read our testimony writing tips", "tips": [ { "label": "Be Timely", - "body": "Written testimony should target bills being considered by the legislature. All Committees should formally accept testimony on each bill in the time between the start of the legislative session and the public hearing of that bill." - }, + "body": "Written testimony should target bills being considered by the legislature and should be submitted before the committee hearing date in order to have the most impact." + }, { "label": "Be Original", "body": "Legislators receive a lot of form letters. These communications are important, but almost always an individual and personalized letter will have greater impact." - }, + }, { "label": "Be Informative", - "body": "Whether you're a longtime advocate or first-time testifier, your testimony is important. Explain why you are concerned about an issue and why you think one policy choice would be better than another." + "body": "Introduce yourself. Explain why you are concerned about an issue and why you think one policy choice would be better than another. Whether you're a longtime advocate or first-time testifier, your testimony is important." }, + { "label": "Be Direct", - "body": "State whether you support or oppose a bill. Be clear and specific about the policies you want to see. You don't need to know specific legal precedents or legislative language." + "body": "State whether you support or oppose a bill. Be clear and specific about the policies you want to see. You don't need to know specific legal precedents or legislative language to provide valuable input." }, { "label": "Be Respectful", @@ -127,13 +127,52 @@ } ] }, + "writingTips": { + "breadcrumb": "Writing Effective Testimony", + "title": "How to Write Effective Testimony", + "subhead": "You don't need legal training or a policy background. You need a clear position, a reason you care, and the district you live in — this page walks through the rest.", + "principles": { + "headline": "Five things to make testimony impactful" + }, + "include": { + "headline": "What every letter should include", + "intro": "Whatever else you write, make sure a reader can answer these five questions after one pass.", + "items": [ + { + "title": "Which bill you are writing about", + "body": "Name the bill by number and title — for example, \"H.1234, An Act relative to…\". Committees sort testimony by bill, so a letter without one is hard to file." + }, + { + "title": "Whether you support or oppose it", + "body": "Say so plainly, in the first line if you can. If your position is conditional, state the condition rather than leaving it implied." + }, + { + "title": "Who you are, and where you live", + "body": "Your name and your city or town. Legislators weigh testimony from their own constituents most heavily, so your district is doing real work here." + }, + { + "title": "Why you care about this", + "body": "This is the part no one else can write. A short, specific account of how the issue touches your life, your work, or your community carries further than a page of general argument." + }, + { + "title": "What you want done", + "body": "Pass it, reject it, or amend it — and if amended, how. Specific asks are the ones a staffer can act on." + } + ] + }, + "cta": { + "headline": "Ready to write?", + "body": "Find a bill you care about and MAPLE will walk you through composing your testimony and sending it to the right committee.", + "button": "Browse bills" + } + }, "aiTools": { "breadcrumb": "AI Research Tools" }, "whyUseMaple": { "breadcrumb": "Why Use MAPLE", "title": "Why Use MAPLE?", - "subhead": "Whether you're a citizen, an organization, or a legislator — MAPLE is for you." + "subhead": "Whether you are a citizen, an organization leader, or a legislator, MAPLE can support you." }, "process": { "breadcrumb": "Legislative Process", @@ -152,7 +191,7 @@ { "num": "01", "title": "A Bill is Introduced", - "lead": "Every law starts as an idea. Any MA resident can petition their legislator to file a bill.", + "lead": "Every law starts as an idea. Any Massachusetts resident can petition their legislator to file a bill.", "body": "Bills can be filed at any time during the two-year session, but most are filed before the session begins — the standard deadline falls in December of the preceding year. Bills filed after that deadline require special approval. They receive a unique identifier — H. for House-originated bills, S. for Senate. The same idea can be filed in both chambers simultaneously. Bills are publicly available on the MA Legislature website and through MAPLE.", "stat": { "value": "6,000+", diff --git a/public/locales/en/learnComponents.json b/public/locales/en/learnComponents.json index a1d91a292..c8158ce1f 100644 --- a/public/locales/en/learnComponents.json +++ b/public/locales/en/learnComponents.json @@ -4,6 +4,6 @@ "resources_intro": "We hope these pages will help you submit effective testimony. You may want to consult these other resources to build a more detailed understanding of the legislative process and how you can contribute.", "find_legislator": "The MA Legislature has an <0>online tool you can use to identify your legislators based on your home address.", "legislative_doc": "The MA Legislature publishes a <0>document on the legislative process.", - "legal_services": "Mass Legal Services published a 2007 guide to The <0>Legislative Process in Massachusetts." + "legal_services": "Mass Legal Services published a 2023 guide to The <0>Legislative Process in Massachusetts." } } From 086f96bffaf87e7b4410d06e1b202b83b23cee9a Mon Sep 17 00:00:00 2001 From: k-g-k Date: Tue, 14 Jul 2026 19:41:20 -0400 Subject: [PATCH 077/106] Guard the Learn icon lists, fix the mobile Learn dropdown, and edit copy Guard the three places where a locale-driven list indexes a fixed array: the About Testimony "why it matters" icons and card badges now skip a missing entry rather than rendering and crashing, and the Writing Effective Testimony principles cycle the stage colours so any number of tips stays coloured. Make the mobile Learn dropdown match the desktop one: pin its menu background to the desktop grey, and drop the custom hover so Bootstrap drives the hover and active states -- fixing a broken look where hovering the selected item left white text on a light background. Also tighten the "what every letter should include" copy. --- components/learn/Testimony/AboutTestimony.tsx | 7 +++-- components/learn/Testimony/WritingTips.tsx | 7 ++++- public/locales/en/learn.json | 10 +++---- styles/globals.css | 26 ++++++++----------- 4 files changed, 27 insertions(+), 23 deletions(-) diff --git a/components/learn/Testimony/AboutTestimony.tsx b/components/learn/Testimony/AboutTestimony.tsx index a03a38acd..13930f265 100644 --- a/components/learn/Testimony/AboutTestimony.tsx +++ b/components/learn/Testimony/AboutTestimony.tsx @@ -245,7 +245,7 @@ export const AboutTestimony = () => {
    - + {Badge && }

    {card.headline}

    {card.body}

    @@ -270,10 +270,13 @@ export const AboutTestimony = () => { {t("testimony.why.mattersHeading")}

    {matters.map((item, i) => { + // WHY_ICONS has one icon per matters entry; if copy adds an entry + // beyond the drawn set, that row just goes without an icon rather + // than rendering and crashing the page. const Icon = WHY_ICONS[i] return ( - + {Icon && }

    {item.title}

    {item.body}

    diff --git a/components/learn/Testimony/WritingTips.tsx b/components/learn/Testimony/WritingTips.tsx index 2d612f724..6351bf305 100644 --- a/components/learn/Testimony/WritingTips.tsx +++ b/components/learn/Testimony/WritingTips.tsx @@ -264,7 +264,12 @@ export const WritingTips = () => { {t("writingTips.principles.headline")} {tips.map((tip, i) => ( - + // Cycle the stage colours so any number of tips stays coloured, rather + // than running off the end of the array into undefined. + diff --git a/public/locales/en/learn.json b/public/locales/en/learn.json index 276d5fc9d..d329fe6bc 100644 --- a/public/locales/en/learn.json +++ b/public/locales/en/learn.json @@ -136,7 +136,7 @@ }, "include": { "headline": "What every letter should include", - "intro": "Whatever else you write, make sure a reader can answer these five questions after one pass.", + "intro": "Make sure a reader can answer these five questions after reading your testimony.", "items": [ { "title": "Which bill you are writing about", @@ -144,19 +144,19 @@ }, { "title": "Whether you support or oppose it", - "body": "Say so plainly, in the first line if you can. If your position is conditional, state the condition rather than leaving it implied." + "body": "Say so plainly in the first line. If your position is conditional, state the condition rather than leaving it implied." }, { "title": "Who you are, and where you live", - "body": "Your name and your city or town. Legislators weigh testimony from their own constituents most heavily, so your district is doing real work here." + "body": "Your name and your city or town. Legislators weigh testimony from their own constituents most heavily, so including your district is important here." }, { "title": "Why you care about this", - "body": "This is the part no one else can write. A short, specific account of how the issue touches your life, your work, or your community carries further than a page of general argument." + "body": "A short, specific account of how the issue affects your life, your work, or your community. This is what makes your testimony unique and impactful." }, { "title": "What you want done", - "body": "Pass it, reject it, or amend it — and if amended, how. Specific asks are the ones a staffer can act on." + "body": "Pass it, reject it, or amend it — and if amended, how? Being specific allows staffers to act on your feedback." } ] }, diff --git a/styles/globals.css b/styles/globals.css index c74e7206b..f3e94de15 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -262,33 +262,29 @@ } #basic-navbar-nav .dropdown-menu { - background-color: var(--maple-surface-base); + /* Set explicitly to the desktop dropdown's background (Bootstrap's $body-bg, + #eae7e7). Inside the dark hamburger collapse the inherited background comes + out dark, so it must be pinned here to match the desktop menu. */ + background-color: var(--maple-surface-page); box-sizing: border-box; max-width: calc(100vw - 3rem); min-width: 0; width: fit-content; } -/* Inside the mobile hamburger, the dropdown menu is white, but its items carry - .navLink-primary (white text, for the top-level links on the dark navbar) — - white on white, invisible. Restore the dark, readable text the desktop - dropdown uses. */ +/* The mobile hamburger's dropdown items carry .navLink-primary (white text, for + the top-level links on the dark navbar), which would be white-on-white inside + this light menu. Force the dark, readable text the desktop dropdown uses; the + menu background, hover, and active states are then left to Bootstrap so this + menu matches the desktop dropdown exactly. */ #basic-navbar-nav .dropdown-menu .dropdown-item, #basic-navbar-nav .dropdown-menu .navLink-primary { color: var(--maple-text-body) !important; font-weight: 400; } -#basic-navbar-nav .dropdown-menu .dropdown-item:hover, -#basic-navbar-nav .dropdown-menu .dropdown-item:focus, -#basic-navbar-nav .dropdown-menu .navLink-primary:hover, -#basic-navbar-nav .dropdown-menu .navLink-primary:focus { - color: var(--bs-blue) !important; - background-color: var(--maple-surface-muted); -} - -/* The active item keeps Bootstrap's filled background (the brand red), so its - text must be white and bold, not the dark body color the rule above sets. */ +/* The active item fills with the brand red (Bootstrap), including on hover, so + its text stays white and bold rather than the dark body colour above. */ #basic-navbar-nav .dropdown-menu .dropdown-item.active, #basic-navbar-nav .dropdown-menu .navLink-primary.active { color: var(--maple-text-inverse) !important; From 22585bdeed9b0379006ee31b80e595ab07a58b7e Mon Sep 17 00:00:00 2001 From: k-g-k Date: Tue, 14 Jul 2026 19:48:28 -0400 Subject: [PATCH 078/106] Fix the mobile Learn dropdown hover and active-row states Bootstrap's default dropdown hover background inherits from --bs-tertiary-bg, which resolves dark inside the navbar (the desktop dropdown escapes that context via a portal, so it stays light). Relying on it turned the mobile hover dark. Pin the non-active hover to the light highlight the desktop menu uses, and hold the active row's red fill and white text through hover and focus so hovering the selected item no longer falls back to the dark default. --- styles/globals.css | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/styles/globals.css b/styles/globals.css index f3e94de15..a891cb497 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -274,19 +274,33 @@ /* The mobile hamburger's dropdown items carry .navLink-primary (white text, for the top-level links on the dark navbar), which would be white-on-white inside - this light menu. Force the dark, readable text the desktop dropdown uses; the - menu background, hover, and active states are then left to Bootstrap so this - menu matches the desktop dropdown exactly. */ + this light menu. Force the dark, readable text the desktop dropdown uses. */ #basic-navbar-nav .dropdown-menu .dropdown-item, #basic-navbar-nav .dropdown-menu .navLink-primary { color: var(--maple-text-body) !important; font-weight: 400; } -/* The active item fills with the brand red (Bootstrap), including on hover, so - its text stays white and bold rather than the dark body colour above. */ +/* Hover/focus on a non-active item: a light highlight, matching the desktop + dropdown. Pinned because Bootstrap's default hover background inherits from + --bs-tertiary-bg, which resolves dark inside the navbar (the desktop dropdown + escapes that context via a portal, so it stays light). */ +#basic-navbar-nav .dropdown-menu .dropdown-item:hover:not(.active), +#basic-navbar-nav .dropdown-menu .dropdown-item:focus:not(.active), +#basic-navbar-nav .dropdown-menu .navLink-primary:hover:not(.active), +#basic-navbar-nav .dropdown-menu .navLink-primary:focus:not(.active) { + background-color: var(--maple-surface-muted) !important; +} + +/* The active row keeps the brand-red fill and white bold text through hover and + focus, so hovering the selected item does not fall back to the dark default. */ #basic-navbar-nav .dropdown-menu .dropdown-item.active, -#basic-navbar-nav .dropdown-menu .navLink-primary.active { +#basic-navbar-nav .dropdown-menu .dropdown-item.active:hover, +#basic-navbar-nav .dropdown-menu .dropdown-item.active:focus, +#basic-navbar-nav .dropdown-menu .navLink-primary.active, +#basic-navbar-nav .dropdown-menu .navLink-primary.active:hover, +#basic-navbar-nav .dropdown-menu .navLink-primary.active:focus { + background-color: var(--bs-dropdown-link-active-bg) !important; color: var(--maple-text-inverse) !important; font-weight: 700; } From 02e0a49dae0e2b6852ac51baffd75d31af33ae0a Mon Sep 17 00:00:00 2001 From: "Nathan E. Sanders" Date: Tue, 14 Jul 2026 21:42:12 -0400 Subject: [PATCH 079/106] Add Firestore indexes for lobbying explorer (#2199) * feat: add Firestore composite indexes for lobbying explorer Adds indexes required for the lobbying pages: - lobbyingFilings: generalCourt+billId, entityNameNorm+year, clientNameNorm+year, entityNameNorm+generalCourt+billId, clientNameNorm+generalCourt+billId, generalCourt+entityNameNorm, generalCourt+clientNameNorm - lobbyingRegistrants: entityNameNorm+year Co-Authored-By: Claude Sonnet 4.6 * feat: add Firestore read rule for lobbyingMeta collection Required for the lobbying explorer Vercel preview to read overview stats. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- firestore.indexes.json | 56 ++++++++++++++++++++++++++++++++++++++++++ firestore.rules | 4 +++ 2 files changed, 60 insertions(+) diff --git a/firestore.indexes.json b/firestore.indexes.json index c267a6868..e78707bc2 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -975,6 +975,62 @@ "order": "ASCENDING" } ] + }, + { + "collectionGroup": "lobbyingFilings", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "registrantId", + "order": "ASCENDING" + }, + { + "fieldPath": "year", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "lobbyingFilings", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "clientNameNorm", + "order": "ASCENDING" + }, + { + "fieldPath": "year", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "lobbyingFilings", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "entityNameNorm", + "order": "ASCENDING" + }, + { + "fieldPath": "year", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "lobbyingRegistrants", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "entityNameNorm", + "order": "ASCENDING" + }, + { + "fieldPath": "year", + "order": "DESCENDING" + } + ] } ], "fieldOverrides": [ diff --git a/firestore.rules b/firestore.rules index 42db67276..df5659ac5 100644 --- a/firestore.rules +++ b/firestore.rules @@ -111,6 +111,10 @@ service cloud.firestore { allow read: if true; allow write: if false; } + match /lobbyingMeta/{id} { + allow read: if true; + allow write: if false; + } match /transcriptions/{tid} { // public, read-only allow read: if true From 99b84fa19b5f83b505ca11b477cc6dc1a2892619 Mon Sep 17 00:00:00 2001 From: Sam DeMarrais Date: Tue, 14 Jul 2026 21:49:38 -0400 Subject: [PATCH 080/106] Multi video backend (#2153) * New attempt at hearing backend * Prettier * Small fixes * Video title parsing * Allowed to reuse existing transcripts * Prettier * Rewrote additional files * Small bugfixing * Align to Assembly AI return * Prettier * Fixing simple bugs * Moved removeCommonWords to helpers.ts * Cross-compatibility * Additional fixes * Prettier * Fixed bug with recreate-transcripts * Fixed bug with migrateHearingTranscription * Prettier * Single file submitTranscription * Rewrite to stepwise save transcription ids * Multi video limit * Bugfix, remove writer * Bugfixes * Error for unset FUNCTIONS_API_BASE * Rewrite error * scrapeSingleHearing user warning * Removed necessary mutation from getHearingVideos --- .../ballotquestions/CommitteeHearing.test.tsx | 22 +- components/ballotquestions/types.ts | 2 +- docs/ballot-questions-frontend.md | 6 +- functions/src/events/AssemblyAIHandler.ts | 461 ++++++++++++++++++ functions/src/events/EventScraper.ts | 147 ++++++ functions/src/events/HearingScraper.ts | 340 +++++++++++++ functions/src/events/helpers.test.ts | 84 +++- functions/src/events/helpers.ts | 34 ++ functions/src/events/index.ts | 2 + functions/src/events/scrapeEvents.ts | 449 +---------------- functions/src/events/types.ts | 2 +- functions/src/index.ts | 1 + functions/src/webhooks/transcription.ts | 23 +- pages/ballotQuestions/[id].tsx | 3 +- .../backfillHearingTranscription.ts | 83 +--- .../migrateHearingTranscription.ts | 269 +++++----- .../BallotQuestionDetails.stories.tsx | 5 +- 17 files changed, 1276 insertions(+), 657 deletions(-) create mode 100644 functions/src/events/AssemblyAIHandler.ts create mode 100644 functions/src/events/EventScraper.ts create mode 100644 functions/src/events/HearingScraper.ts diff --git a/components/ballotquestions/CommitteeHearing.test.tsx b/components/ballotquestions/CommitteeHearing.test.tsx index 9777da7c5..ae41805e5 100644 --- a/components/ballotquestions/CommitteeHearing.test.tsx +++ b/components/ballotquestions/CommitteeHearing.test.tsx @@ -31,7 +31,11 @@ describe("CommitteeHearing", () => { }) it("shows hearing context copy", () => { - render() + render( + + ) expect(screen.getByText("Committee Hearing")).toBeInTheDocument() expect( screen.getByText("Committee hearings are public meetings.") @@ -39,13 +43,19 @@ describe("CommitteeHearing", () => { }) it("formats the hearing date", () => { - render() + render( + + ) expect(screen.getByText(/December 14, 2025/)).toBeInTheDocument() }) it("shows a hearing page link when an id is present", () => { render( - + ) expect( screen.getByRole("link", { name: /Open hearing page/i }) @@ -53,7 +63,11 @@ describe("CommitteeHearing", () => { }) it("hides the hearing page link when no hearing id is available", () => { - render() + render( + + ) expect(screen.queryByRole("link")).not.toBeInTheDocument() }) }) diff --git a/components/ballotquestions/types.ts b/components/ballotquestions/types.ts index cdc9e0b9f..b27cecb87 100644 --- a/components/ballotquestions/types.ts +++ b/components/ballotquestions/types.ts @@ -1,6 +1,6 @@ export type Hearing = { id: string - videoURL?: string + videoURLs: string[] startsAt: number // milliseconds since epoch, converted from Firestore Timestamp server-side } diff --git a/docs/ballot-questions-frontend.md b/docs/ballot-questions-frontend.md index 5e9ee756a..ff0cede3d 100644 --- a/docs/ballot-questions-frontend.md +++ b/docs/ballot-questions-frontend.md @@ -166,15 +166,15 @@ For each relevant hearing, display: - **Status**: "Occurred" if `hearing.content.startsAt` is in the past, "Scheduled" if in the future - **Date**: formatted from `hearing.content.startsAt` -- **Watch link**: "Watch the committee hearing here." linked to `hearing.videoURL` — hidden if no video +- **Watch link**: "Watch the committee hearing here." linked to `hearing.videoURLs` — hidden if no videos Since ballot questions are always under SJ42 and typically have one hearing, render a single hearing block. If there are multiple, render them in reverse chronological order (most recent first). **Hearing data model recap:** - `bill.hearingIds?: string[]` — event IDs; doc path is `/events/hearing-{id}` -- `bill.nextHearingAt?: Timestamp` — convenience field for upcoming hearing only (not sufficient alone — we need date + videoURL from the full document) -- `hearing.videoURL?: string` — link for the "Watch" CTA +- `bill.nextHearingAt?: Timestamp` — convenience field for upcoming hearing only (not sufficient alone — we need date + videoURLs from the full document) +- `hearing.videoURLs: string[]` — link for the "Watch" CTA - `hearing.content.startsAt` — determines "Occurred" vs. "Scheduled" status No new components are needed for hearing display — build a simple `CommitteeHearing` component local to `components/ballotquestions/`. diff --git a/functions/src/events/AssemblyAIHandler.ts b/functions/src/events/AssemblyAIHandler.ts new file mode 100644 index 000000000..5b1d9f2d7 --- /dev/null +++ b/functions/src/events/AssemblyAIHandler.ts @@ -0,0 +1,461 @@ +import { + AssemblyAI, + Transcript, + TranscriptParagraph, + TranscriptUtterance, + TranscriptWord +} from "assemblyai" +import * as functions from "firebase-functions/v1" +import { db, storage } from "../firebase" +import { randomBytes } from "node:crypto" +import { sha256 } from "js-sha256" +import ffmpeg from "fluent-ffmpeg" +import fs from "fs" + +abstract class AssemblyAIHandlerBase { + abstract submitTranscription({ + EventId, + videoUrl, + bucketName + }: { + EventId: number + videoUrl: string + bucketName?: string + }): Promise< + | { + status: "ok" + id: string + } + | { + status: "error" + type: string + } + > + + abstract getTranscript(transcript_id: string): Promise + abstract fetchParagraphs( + transcript_id: string + ): Promise +} + +export class AssemblyAIHandler extends AssemblyAIHandlerBase { + assembly: AssemblyAI + + constructor({ apiKey }: { apiKey: string }) { + super() + this.assembly = new AssemblyAI({ + apiKey + }) + } + + async submitTranscription({ + EventId, + videoUrl, + bucketName + }: { + EventId: number + videoUrl: string + bucketName?: string + }): Promise< + | { + status: "ok" + id: string + } + | { + status: "error" + type: string + error: any + } + > { + if (!process.env.FUNCTIONS_API_BASE) { + return { + status: "error", + type: "transcription", + error: "process.env.FUNCTIONS_API_BASE is not set" + } + } + + const newToken = randomBytes(16).toString("hex") + let error + const audioUrl = await extractAudioFromVideo( + EventId, + videoUrl, + bucketName + ).catch(e => { + error = e + return null + }) + + if (!audioUrl) { + return { + status: "error", + type: "audio extraction", + error + } + } + + const transcript = await this.assembly.transcripts + .submit({ + audio: + // test with: "https://assemblyaiusercontent.com/playground/aKUqpEtmYmI.flac", + audioUrl, + webhook_url: + // make sure process.env.FUNCTIONS_API_BASE equals + // https://us-central1-digital-testimony-prod.cloudfunctions.net + // on prod. test with: + // "https://ngrokid.ngrok-free.app/demo-dtp/us-central1/transcription", + `${process.env.FUNCTIONS_API_BASE}/transcription`, + speaker_labels: true, + webhook_auth_header_name: "x-maple-webhook", + webhook_auth_header_value: newToken + }) + .catch(e => { + error = e + return null + }) + + if (!transcript) { + return { + status: "error", + type: "assembly submission", + error + } + } + + const result = await db + .collection("events") + .doc(`hearing-${String(EventId)}`) + .collection("private") + .doc(transcript.id) + .set({ + videoAssemblyWebhookToken: sha256(newToken) + }) + .catch(e => { + error = e + return null + }) + + if (!result) { + return { + status: "error", + type: "db token set", + error + } + } + + return { + status: "ok", + id: transcript.id + } + } + + async getTranscript(transcript_id: string): Promise { + return await this.assembly.transcripts.get(transcript_id) + } + + async fetchParagraphs(transcript_id: string): Promise { + return (await this.assembly.transcripts.paragraphs(transcript_id)) + .paragraphs + } +} + +export class AssemblyAIHandlerDummy extends AssemblyAIHandlerBase { + async submitTranscription({ + EventId, + videoUrl, + bucketName + }: { + EventId: number + videoUrl: string + bucketName?: string + }): Promise< + | { + status: "ok" + id: string + } + | { + status: "error" + type: string + error: any + } + > { + let error + const token = randomBytes(16).toString("hex") + const transcriptionId = `mock_${Math.random().toString(36).slice(2)}` + + setTimeout(async () => { + const transcript: any = await this.getTranscript(transcriptionId) + transcript["transcript_id"] = transcript.id + await fetch("http://localhost:5001/demo-dtp/us-central1/transcription", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-maple-webhook": token + }, + body: JSON.stringify(transcript) + }) + }, 10000) + + const result = await db + .collection("events") + .doc(`hearing-${String(EventId)}`) + .collection("private") + .doc(transcriptionId) + .set({ + videoAssemblyWebhookToken: sha256(token) + }) + .catch(e => { + error = e + return null + }) + + if (!result) { + return { + status: "error", + type: "db token set", + error + } + } + + return { + status: "ok", + id: transcriptionId + } + } + + async getTranscript(transcriptId: string): Promise { + return getTranscript(transcriptId).transcript + } + + async fetchParagraphs(transcriptId: string): Promise { + return getTranscript(transcriptId).paragraphs + } +} + +const extractAudioFromVideo = async ( + EventId: number, + videoUrl: string, + bucketName?: string +): Promise => { + const tmpFilePath = `/tmp/hearing-${EventId}-${Date.now()}.m4a` + + // Stream directly from URL and copy audio codec + await new Promise((resolve, reject) => { + ffmpeg(videoUrl) + .noVideo() + .audioCodec("copy") + .format("mp4") + .on("start", commandLine => { + console.log(`Spawned FFmpeg with command: ${commandLine}`) + }) + .on("end", () => { + console.log("FFmpeg processing finished successfully") + resolve() + }) + .on("error", err => { + functions.logger.error("FFmpeg error:", err) + reject(err) + }) + .save(tmpFilePath) + }) + + // Upload the audio file + const bucket = bucketName ? storage.bucket(bucketName) : storage.bucket() + const audioFileName = `hearing-${EventId}-${Date.now()}.m4a` + const file = bucket.file(audioFileName) + + const fileContent = await fs.promises.readFile(tmpFilePath) + await file.save(fileContent, { + metadata: { + contentType: "audio/mp4" + } + }) + + // Clean up temporary file + await fs.promises.unlink(tmpFilePath) + + const [url] = await file.getSignedUrl({ + action: "read", + expires: Date.now() + 24 * 60 * 60 * 1000 + }) + + // Delete old files + const [files] = await bucket.getFiles({ + prefix: "hearing-", + maxResults: 1000 + }) + const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000 + const oldFiles = files.filter(file => { + const timestamp = parseInt(file.name.split("-").pop()?.split(".")[0] || "0") + return timestamp < oneDayAgo + }) + await Promise.all(oldFiles.map(file => file.delete())) + + // Return the new audio url + return url +} + +const WORD_BANK = [ + "lorem", + "ipsum", + "dolor", + "sit", + "amet", + "consectetur", + "adipiscing", + "elit", + "sed", + "do", + "eiusmod", + "tempor", + "incididunt", + "ut", + "labore", + "et", + "dolore", + "magna", + "aliqua" +] + +const SPEAKERS = ["A", "B", "C"] + +function randomInt(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +function randomFloat(min: number, max: number, precision = 2) { + return Number((Math.random() * (max - min) + min).toFixed(precision)) +} + +function mean(values: number[]) { + return values.reduce((a, b) => a + b, 0) / values.length +} + +function loremSentence(length: number) { + return Array.from({ length }, () => { + return WORD_BANK[randomInt(0, WORD_BANK.length - 1)] + }) +} + +function loremParagraph(length: number) { + return Array.from({ length }, () => loremSentence(randomInt(3, 10))) +} + +// paragraphs -> sentences -> words +function loremTranscriptStructure() { + return Array.from({ length: randomInt(10, 20) }, () => + loremParagraph(randomInt(3, 8)) + ) +} + +export function getTranscript(transcript_id: string): { + transcript: Transcript + paragraphs: TranscriptParagraph[] +} { + const structure = loremTranscriptStructure() + + const utterances: TranscriptUtterance[] = [] + const paragraphs: TranscriptParagraph[] = [] + const allWords: TranscriptWord[] = [] + + let currentTime = 0 + + for (const paragraph of structure) { + const speaker = SPEAKERS[randomInt(0, SPEAKERS.length - 1)] + + const paragraphWords: TranscriptWord[] = [] + + for (const sentence of paragraph) { + const sentenceWords: TranscriptWord[] = [] + + for (const token of sentence) { + const confidence = randomFloat(0.5, 0.99) + + const word: TranscriptWord = { + confidence, + start: Number(currentTime.toFixed(2)), + end: Number((currentTime + 1).toFixed(2)), + speaker, + text: token + } + + sentenceWords.push(word) + paragraphWords.push(word) + allWords.push(word) + + currentTime += 300 + } + + const utterance: TranscriptUtterance = { + confidence: Number( + mean(sentenceWords.map(w => w.confidence)).toFixed(2) + ), + start: sentenceWords[0].start, + end: sentenceWords[sentenceWords.length - 1].end, + speaker, + text: sentenceWords.map(w => w.text).join(" "), + words: sentenceWords + } + + utterances.push(utterance) + + currentTime += randomInt(100, 3000) + } + + const transcriptParagraph: TranscriptParagraph = { + confidence: Number( + mean(paragraphWords.map(w => w.confidence)).toFixed(2) + ), + start: paragraphWords[0].start, + end: paragraphWords[paragraphWords.length - 1].end, + text: paragraphWords.map(w => w.text).join(" "), + words: paragraphWords + } + + paragraphs.push(transcriptParagraph) + + currentTime += randomInt(500, 7000) + } + + const transcript: Transcript = { + acoustic_model: "no", + audio_url: "https://example.com/definitely-a-video", + auto_highlights: false, + id: transcript_id, + language_confidence: 0.95, + language_confidence_threshold: 0.03, + language_model: "no", + speech_model: null, + redact_pii: true, + status: "completed", + summarization: false, + webhook_auth: true, + webhook_auth_header_name: "x-maple-webhook", + + text: utterances.map(u => u.text).join(". "), + confidence: Number(mean(allWords.map(w => w.confidence)).toFixed(2)), + + utterances, + words: allWords + } + + return { + transcript, + paragraphs + } +} + +let assemblyInstance: AssemblyAIHandler | AssemblyAIHandlerDummy | undefined + +export function assemblyAI(): AssemblyAIHandler | AssemblyAIHandlerDummy { + if (!assemblyInstance) { + const apiKey = process.env.ASSEMBLY_API_KEY + if (!apiKey || apiKey === "test-api-key") { + console.log("AssemblyAI is faked for this emulator") + assemblyInstance = new AssemblyAIHandlerDummy() + } else { + assemblyInstance = new AssemblyAIHandler({ apiKey }) + } + } + return assemblyInstance +} diff --git a/functions/src/events/EventScraper.ts b/functions/src/events/EventScraper.ts new file mode 100644 index 000000000..2e3adc656 --- /dev/null +++ b/functions/src/events/EventScraper.ts @@ -0,0 +1,147 @@ +import { RuntimeOptions, runWith } from "firebase-functions/v1" +import { DateTime } from "luxon" +import { logFetchError } from "../common" +import { db, Timestamp } from "../firebase" +import * as api from "../malegislature" +import { + BaseEvent, + BaseEventContent, + Session, + SessionContent, + SpecialEvent, + SpecialEventContent +} from "./types" +import { currentGeneralCourt } from "../shared" + +export abstract class EventScraper { + private schedule + private timeout + private memory + private pastEventCutoff + + constructor( + schedule: string, + timeout: number, + { + memory = "256MB", + pastEventCutoff = { days: 8 } + }: { + memory?: RuntimeOptions["memory"] + pastEventCutoff?: Duration + } = {} + ) { + this.schedule = schedule + this.timeout = timeout + this.memory = memory + this.pastEventCutoff = pastEventCutoff + } + + get function() { + return runWith({ + timeoutSeconds: this.timeout, + secrets: ["ASSEMBLY_API_KEY"], + memory: this.memory, + maxInstances: 1 + }) + .pubsub.schedule(this.schedule) + .onRun(() => this.run()) + } + + abstract listEvents(): Promise + abstract getEvent(item: ListItem): Promise + + private async run() { + const list = await this.listEvents().catch(logFetchError("event list")) + + if (!list) return + + const writer = db.bulkWriter() + const upcomingOrRecentCutoff = DateTime.now().minus(this.pastEventCutoff) + + for (let item of list) { + const id = (item as any)?.EventId, + event = await this.getEvent(item).catch(logFetchError("event", id)) + + if (!event) continue + if (event.startsAt.toMillis() < upcomingOrRecentCutoff.toMillis()) break + + writer.set(db.doc(`/events/${event.id}`), event, { merge: true }) + + console.log("event in run()", event) + } + + await writer.close() + } + + /** Parse the event start time in the time zone of the API. */ + getEventStart(content: { EventDate: string; StartTime: string }) { + const { year, month, day } = DateTime.fromISO(content.EventDate, { + zone: api.timeZone + }) + const { hour, minute, second, millisecond } = DateTime.fromISO( + content.StartTime, + { zone: api.timeZone } + ) + const startsAt = DateTime.fromObject( + { year, month, day, hour, minute, second, millisecond }, + { zone: api.timeZone } + ) + return startsAt + } + + /** Return timestamps shared between event types. */ + timestamps(content: BaseEventContent) { + const startsAt = this.getEventStart(content) + return { + fetchedAt: Timestamp.now(), + startsAt: Timestamp.fromMillis(startsAt.toMillis()) + } + } +} + +export class SpecialEventsScraper extends EventScraper< + SpecialEventContent, + SpecialEvent +> { + constructor() { + super("every 60 minutes", 540) + } + + async listEvents() { + const events = await api.getSpecialEvents() + return events.filter(SpecialEventContent.guard) + } + + getEvent(content: SpecialEventContent) { + const event: SpecialEvent = { + id: `specialEvent-${content.EventId}`, + type: "specialEvent", + content, + ...this.timestamps(content) + } + return Promise.resolve(event) + } +} + +export class SessionScraper extends EventScraper { + private court = currentGeneralCourt + + constructor() { + super("every 60 minutes", 120) + } + + async listEvents() { + const events = await api.getSessions(this.court) + return events.filter(SessionContent.guard) + } + + getEvent(content: SessionContent) { + const event: Session = { + id: `session-${this.court}-${content.EventId}`, + type: "session", + content, + ...this.timestamps(content) + } + return Promise.resolve(event) + } +} diff --git a/functions/src/events/HearingScraper.ts b/functions/src/events/HearingScraper.ts new file mode 100644 index 000000000..5e65286eb --- /dev/null +++ b/functions/src/events/HearingScraper.ts @@ -0,0 +1,340 @@ +import { JSDOM } from "jsdom" +import * as functions from "firebase-functions/v1" +import { db, Timestamp } from "../firebase" +import { DateTime } from "luxon" +import * as api from "../malegislature" +import { Hearing, HearingContent, HearingListItem, Video } from "./types" +import { isValidVideoUrl, removeCommonWords } from "./helpers" +import { Committee } from "../committees/types" +import { EventScraper } from "./EventScraper" +import { assemblyAI } from "./AssemblyAIHandler" + +const loadCommitteeChairNames = async ( + generalCourtNumber: number, + committeeCode: string +) => { + try { + const committeeSnap = await db + .collection(`generalCourts/${generalCourtNumber}/committees`) + .doc(committeeCode) + .get() + + if (!committeeSnap.exists) return [] as string[] + + const { members, content } = Committee.check(committeeSnap.data()) + const chairCodes = new Set() + const maybeHouse = content.HouseChairperson?.MemberCode + const maybeSenate = content.SenateChairperson?.MemberCode + + if (maybeHouse) chairCodes.add(maybeHouse) + if (maybeSenate) chairCodes.add(maybeSenate) + return (members ?? []) + .filter(member => chairCodes.has(member.id)) + .map(member => member.name) + } catch (error) { + console.warn( + `Failed to load committee chairs for ${committeeCode} (${generalCourtNumber}):`, + error + ) + return [] as string[] + } +} + +export class HearingScraper extends EventScraper { + constructor() { + super("every 60 minutes", 120) + } + + async listEvents() { + const events = await api.listHearings() + return events.filter(HearingListItem.guard) + } + + async getEvent({ EventId }: HearingListItem /* e.g. 4962 */) { + const data = await api.getHearing(EventId) + const content = HearingContent.check(data) + + const host = content.HearingHost + const committeeChairs = + host?.CommitteeCode && host?.GeneralCourtNumber + ? await loadCommitteeChairNames( + host.GeneralCourtNumber, + host.CommitteeCode + ) + : [] + + return { + id: `hearing-${EventId}`, + type: "hearing", + content, + committeeChairs, + ...this.timestamps(content) + } as Hearing + } +} + +export class HearingPostProcessor { + private schedule + private timeout + private memory + private pastEventBeginProcessing + private pastEventCutoff + + constructor( + schedule: string = "every 60 minutes", + timeout: number = 540, + { + memory = "4GB", + pastEventBeginProcessing = {}, + pastEventCutoff = { days: 8 } + }: { + memory?: functions.RuntimeOptions["memory"] + pastEventBeginProcessing?: Duration + pastEventCutoff?: Duration + } = {} + ) { + this.schedule = schedule + this.timeout = timeout + this.memory = memory + this.pastEventBeginProcessing = pastEventBeginProcessing + this.pastEventCutoff = pastEventCutoff + } + + get function() { + return functions + .runWith({ + timeoutSeconds: this.timeout, + secrets: ["ASSEMBLY_API_KEY"], + memory: this.memory, + maxInstances: 1 + }) + .pubsub.schedule(this.schedule) + .onRun(() => this.run()) + } + + private async run() { + const now = DateTime.now() + const begin = now.minus(this.pastEventBeginProcessing).toJSDate() + const cutoff = now.minus(this.pastEventCutoff).toJSDate() + + const snapshot = await db + .collection("events") + .where("type", "==", "hearing") + .where("startsAt", "<=", begin) + .where("startsAt", ">=", cutoff) + .get() + + if (snapshot.empty) return + + for (const doc of snapshot.docs) { + const changed = await this.addVideosToHearing(doc, { limit: 1 }) + if (changed) return + } + } + + async addVideosToHearing( + doc: + | FirebaseFirestore.QueryDocumentSnapshot + | FirebaseFirestore.DocumentSnapshot, + { + limit, + refetch = false + }: { + limit?: number + refetch?: boolean + } + ): Promise { + let count = 0 + const data = doc.data() + const eventId = data?.content?.EventId + if (!data || !eventId) return false + let videos: Video[] + if (!data.videos) { + videos = await this.getHearingVideos(eventId) + // We need to retry until videos become available (if ever) + if (videos.length === 0) return false + console.log(`Adding ${videos.length} videos to ${eventId}`) + await doc.ref.update({ + videos, + transcriptionIds: this.transcriptionIds(videos), + videosFetchedAt: Timestamp.now() + }) + } else if (refetch) { + const oldVideos = data.videos + videos = await this.getHearingVideos(eventId) + if (videos.length === 0) return false + for (const oldVideo of oldVideos) { + if (!oldVideo.transcriptionId) continue + const index = videos.findIndex(video => video.url === oldVideo.url) + if (index < 0) { + functions.logger.error( + `A refetch of hearing ${eventId} somehow lost a video ${oldVideo.url}` + ) + videos.push(oldVideo) + } else { + videos[index].transcriptionId = oldVideo.transcriptionId + } + } + console.log(`Refetching ${videos.length} videos to ${eventId}`) + await doc.ref.update({ + videos, + transcriptionIds: this.transcriptionIds(videos), + videosFetchedAt: Timestamp.now() + }) + } else { + videos = data.videos + } + + let nextVideos = await this.submitNextTranscription(eventId, videos) + count += 1 + if (!nextVideos) return false + while (nextVideos) { + await doc.ref.update({ + videos: nextVideos, + transcriptionIds: this.transcriptionIds(nextVideos) + }) + if (limit && count >= limit) { + return true + } + nextVideos = await this.submitNextTranscription(eventId, nextVideos) + count += 1 + } + return true + } + + async submitNextTranscription( + eventId: number, + videos: Video[] + ): Promise { + const nextTranscription = videos.findIndex(item => !item.transcriptionId) + if (nextTranscription < 0) { + return null + } + const result = await assemblyAI().submitTranscription({ + EventId: eventId, + videoUrl: videos[nextTranscription].url + }) + if (result.status === ("error" as const)) { + functions.logger.error(`Error during ${result.type}: ${result.error}`) + return null + } + console.log( + `Adding new transcription to ${eventId}: ${videos[nextTranscription].url} with id ${result.id}` + ) + videos[nextTranscription].transcriptionId = result.id + return videos + } + + transcriptionIds(videos: Video[]): string[] { + return videos + .map(video => video.transcriptionId) + .filter((item): item is string => item !== null) + } + + async getHearingVideos(EventId: number): Promise { + const hearingErr = `An error collecting videos for hearing ${EventId} (webpage format changed?)` + + const req = await fetch( + `https://malegislature.gov/Events/Hearings/Detail/${EventId}` + ) + const res = await req.text() + if (!res) throw new Error(`${hearingErr}: No response for request`) + const dom = new JSDOM(res) + if (!dom) + throw new Error(`${hearingErr}: Could not create JSDOM of request`) + + const videoElements = [].slice.call( + dom.window.document.querySelectorAll("#playWebcast") + ) as Element[] + if (videoElements.length === 0) return [] + const videoURLs = videoElements.map(elem => { + const onclick = elem.getAttribute("onclick") + if (!onclick) throw new Error(`${hearingErr}: No onclick in ${elem}`) + const match = onclick.match(/switchVideo\('([^']+)'/) + if (!match || match.length < 2) + throw new Error(`${hearingErr}: Could not match switchVideo in ${elem}`) + if (!isValidVideoUrl(match[1])) + throw new Error(`${hearingErr}: ${match[1]} is not a valid video url`) + return match[1] + }) + const tbody = videoElements[0].closest("tbody") + if (!tbody) + throw new Error( + `${hearingErr}: Could not find parent tbody of #playWebcast` + ) + const titles = Array.from(tbody.querySelectorAll("tr")).map(tr => { + const item = tr.querySelector("td")?.textContent?.trim() + if (!item) + throw new Error(`${hearingErr}: Could not locate title in ${tr}`) + return item + }) + if (titles.length !== videoURLs.length) + throw new Error( + `${hearingErr}: Number of video table rows did not equal number of #playWebcast elements` + ) + + let videos = videoURLs.map((url, i) => { + return { + url: url, + title: titles[i], + transcriptionId: null + } + }) + + let seen = new Set() + videos = videos.filter(item => { + if (seen.has(item.url)) return false + seen.add(item.url) + return true + }) + + if (videos.length > 1) { + const order = videos.map(item => { + const title = item.title.toLowerCase() + const match = title.match( + /\b(?:(\d+)\s+of\s+\d+|part\s+(\d+)|pt\.?\s+(\d+))\b/ + ) + if (!match) return -1 + const part = parseInt(match[1] || match[2] || match[3], 10) + return part - 1 + }) + seen.clear() + let validOrder = true + for (const n of order) { + if (n < 0 || n >= order.length || seen.has(n)) { + validOrder = false + break + } + seen.add(n) + } + if (validOrder) { + const reordered = new Array(videos.length) + for (let i = 0; i < order.length; i++) { + reordered[order[i]] = videos[i] + } + videos = reordered + videos = videos.map((item, index) => { + item.title = `Part ${index + 1}` + return item + }) + } else { + let shortTitles = removeCommonWords(titles) + if (shortTitles[0].length === 0) { + shortTitles = shortTitles.map((_, i) => `Part ${i + 1}`) + } + videos = videos.map((item, index) => { + item.title = shortTitles[index] + return item + }) + console.log( + `Ordering not possible for hearing ${EventId} - fallback titles are ${JSON.stringify( + shortTitles + )}` + ) + } + } else { + videos[0].title = `hearing-${EventId}` + } + return videos + } +} diff --git a/functions/src/events/helpers.test.ts b/functions/src/events/helpers.test.ts index 8ac7c68aa..23302712d 100644 --- a/functions/src/events/helpers.test.ts +++ b/functions/src/events/helpers.test.ts @@ -1,5 +1,5 @@ import { addDays, subDays } from "date-fns" -import { isValidVideoUrl, withinCutoff } from "./helpers" +import { isValidVideoUrl, withinCutoff, removeCommonWords } from "./helpers" describe("withinCutoff true", () => { beforeEach(() => { @@ -60,3 +60,85 @@ describe("isValidVideoUrl", () => { expect(result).toEqual(false) }) }) + +describe("removeCommonWords", () => { + it("should remove a common prefix from all strings", () => { + const result = removeCommonWords(["hello there", "hello you"]) + expect(result).toEqual(["there", "you"]) + }) + + it("should remove a common suffix from all strings", () => { + const result = removeCommonWords([ + "9/21 Public Speech", + "2/15 Public Speech", + "3/25 Public Speech" + ]) + expect(result).toEqual(["9/21", "2/15", "3/25"]) + }) + + it("should remove both a common prefix and suffix", () => { + const result = removeCommonWords([ + "There is a mouse in my house", + "There is a cat in my house" + ]) + expect(result).toEqual(["mouse", "cat"]) + }) + + it("should not remove partial word matches", () => { + const result = removeCommonWords(["thingdot", "thingbot"]) + expect(result).toEqual(["thingdot", "thingbot"]) + }) + + it("should preserve multiple words", () => { + const result = removeCommonWords([ + "meeting notes draft", + "project plan draft", + "design review draft" + ]) + expect(result).toEqual(["meeting notes", "project plan", "design review"]) + }) + + it("should not remove multiple of the same word", () => { + const result = removeCommonWords(["a x c", "a y c", "a c c"]) + expect(result).toEqual(["x", "y", "c"]) + }) + + it("should leave strings unchanged when there are no common words", () => { + const result = removeCommonWords([ + "apple banana", + "orange pear", + "grape melon" + ]) + expect(result).toEqual(["apple banana", "orange pear", "grape melon"]) + }) + + it("should return empty strings when all words are common", () => { + const result = removeCommonWords(["same words", "same words"]) + expect(result).toEqual(["", ""]) + }) + + it("should handle empty strings", () => { + const result = removeCommonWords(["", ""]) + expect(result).toEqual(["", ""]) + }) + + it("should handle mixtures of empty and non-empty strings", () => { + const result = removeCommonWords(["", "hello world"]) + expect(result).toEqual(["", "hello world"]) + }) + + it("should handle alternate forms of whitespace", () => { + const result = removeCommonWords([ + "a b c b e f", + "a b c i\n e\nf", + "a\n\rb\n\rc\n\rr\n\re\n\rf", + "a\tb \t c b e\tf" + ]) + expect(result).toEqual(["b", "i", "r", "b"]) + }) + + it("should handle alternate case", () => { + const result = removeCommonWords(["A b c", "a B c", "a b c", "A r f"]) + expect(result).toEqual(["b c", "B c", "b c", "r f"]) + }) +}) diff --git a/functions/src/events/helpers.ts b/functions/src/events/helpers.ts index 7e3dfd1e3..a7f58faca 100644 --- a/functions/src/events/helpers.ts +++ b/functions/src/events/helpers.ts @@ -30,3 +30,37 @@ export const isValidVideoUrl = (url: string | null | undefined) => { return true } + +export function removeCommonWords(strings: string[]) { + if (!strings.length) return [] + + // Normalize whitespace and split into words + const wordLists = strings.map(s => s.trim().replace(/\s+/g, " ").split(" ")) + + let prefixLen = 0 + while ( + wordLists.every( + words => + prefixLen < words.length && + words[prefixLen].toLowerCase() === wordLists[0][prefixLen].toLowerCase() + ) + ) { + prefixLen++ + } + + let suffixLen = 0 + while ( + wordLists.every( + words => + suffixLen < words.length - prefixLen && + words[words.length - 1 - suffixLen].toLowerCase() === + wordLists[0][wordLists[0].length - 1 - suffixLen].toLowerCase() + ) + ) { + suffixLen++ + } + + return wordLists.map(words => + words.slice(prefixLen, words.length - suffixLen).join(" ") + ) +} diff --git a/functions/src/events/index.ts b/functions/src/events/index.ts index 96ff5307d..2a1f508ad 100644 --- a/functions/src/events/index.ts +++ b/functions/src/events/index.ts @@ -1,3 +1,5 @@ export * from "./scrapeEvents" export { scrapeSingleHearing } from "./scrapeEvents" export { scrapeSingleHearingv2 } from "./scrapeEvents" +export { assemblyAI } from "./AssemblyAIHandler" +export { HearingScraper, HearingPostProcessor } from "./HearingScraper" diff --git a/functions/src/events/scrapeEvents.ts b/functions/src/events/scrapeEvents.ts index cc482f1fa..5e38b8499 100644 --- a/functions/src/events/scrapeEvents.ts +++ b/functions/src/events/scrapeEvents.ts @@ -1,432 +1,23 @@ import * as functions from "firebase-functions/v1" -import { RuntimeOptions, runWith } from "firebase-functions/v1" import { onCall, CallableRequest } from "firebase-functions/v2/https" -import { DateTime } from "luxon" -import { JSDOM } from "jsdom" -import { AssemblyAI } from "assemblyai" -import { checkAuth, checkAdmin, logFetchError } from "../common" -import { db, storage, Timestamp } from "../firebase" -import * as api from "../malegislature" -import { - BaseEvent, - BaseEventContent, - Hearing, - HearingContent, - HearingListItem, - Session, - SessionContent, - SpecialEvent, - SpecialEventContent -} from "./types" -import { currentGeneralCourt } from "../shared" -import { randomBytes } from "node:crypto" -import { sha256 } from "js-sha256" -import { isValidVideoUrl, withinCutoff } from "./helpers" -import ffmpeg from "fluent-ffmpeg" -import fs from "fs" -import { Committee } from "../committees/types" -abstract class EventScraper { - private schedule - private timeout - private memory - - constructor( - schedule: string, - timeout: number, - memory: RuntimeOptions["memory"] = "256MB" - ) { - this.schedule = schedule - this.timeout = timeout - this.memory = memory - } - - get function() { - return runWith({ - timeoutSeconds: this.timeout, - secrets: ["ASSEMBLY_API_KEY"], - memory: this.memory, - maxInstances: 1 - }) - .pubsub.schedule(this.schedule) - .onRun(() => this.run()) - } - - abstract listEvents(): Promise - abstract getEvent(item: ListItem): Promise - - private async run() { - const list = await this.listEvents().catch(logFetchError("event list")) - - if (!list) return - - const writer = db.bulkWriter() - const upcomingOrRecentCutoff = DateTime.now().minus({ days: 8 }) - - for (let item of list) { - const id = (item as any)?.EventId, - event = await this.getEvent(item).catch(logFetchError("event", id)) - - if (!event) continue - if (event.startsAt.toMillis() < upcomingOrRecentCutoff.toMillis()) break - - writer.set(db.doc(`/events/${event.id}`), event, { merge: true }) - - console.log("event in run()", event) - } - - await writer.close() - } - - /** Parse the event start time in the time zone of the API. */ - getEventStart(content: { EventDate: string; StartTime: string }) { - const { year, month, day } = DateTime.fromISO(content.EventDate, { - zone: api.timeZone - }) - const { hour, minute, second, millisecond } = DateTime.fromISO( - content.StartTime, - { zone: api.timeZone } - ) - const startsAt = DateTime.fromObject( - { year, month, day, hour, minute, second, millisecond }, - { zone: api.timeZone } - ) - return startsAt - } - - /** Return timestamps shared between event types. */ - timestamps(content: BaseEventContent) { - const startsAt = this.getEventStart(content) - return { - fetchedAt: Timestamp.now(), - startsAt: Timestamp.fromMillis(startsAt.toMillis()) - } - } -} - -class SpecialEventsScraper extends EventScraper< - SpecialEventContent, - SpecialEvent -> { - constructor() { - super("every 60 minutes", 540) - } - - async listEvents() { - const events = await api.getSpecialEvents() - return events.filter(SpecialEventContent.guard) - } - - getEvent(content: SpecialEventContent) { - const event: SpecialEvent = { - id: `specialEvent-${content.EventId}`, - type: "specialEvent", - content, - ...this.timestamps(content) - } - return Promise.resolve(event) - } -} - -class SessionScraper extends EventScraper { - private court = currentGeneralCourt - - constructor() { - super("every 60 minutes", 120) - } - - async listEvents() { - const events = await api.getSessions(this.court) - return events.filter(SessionContent.guard) - } - - getEvent(content: SessionContent) { - const event: Session = { - id: `session-${this.court}-${content.EventId}`, - type: "session", - content, - ...this.timestamps(content) - } - return Promise.resolve(event) - } -} - -const extractAudioFromVideo = async ( - EventId: number, - videoUrl: string, - bucketName?: string -): Promise => { - const tmpFilePath = `/tmp/hearing-${EventId}-${Date.now()}.m4a` - - // Stream directly from URL and copy audio codec - await new Promise((resolve, reject) => { - ffmpeg(videoUrl) - .noVideo() - .audioCodec("copy") - .format("mp4") - .on("start", commandLine => { - console.log(`Spawned FFmpeg with command: ${commandLine}`) - }) - .on("end", () => { - console.log("FFmpeg processing finished successfully") - resolve() - }) - .on("error", err => { - functions.logger.error("FFmpeg error:", err) - reject(err) - }) - .save(tmpFilePath) - }) - - // Upload the audio file - const bucket = bucketName ? storage.bucket(bucketName) : storage.bucket() - const audioFileName = `hearing-${EventId}-${Date.now()}.m4a` - const file = bucket.file(audioFileName) - - const fileContent = await fs.promises.readFile(tmpFilePath) - await file.save(fileContent, { - metadata: { - contentType: "audio/mp4" - } - }) - - // Clean up temporary file - await fs.promises.unlink(tmpFilePath) - - const [url] = await file.getSignedUrl({ - action: "read", - expires: Date.now() + 24 * 60 * 60 * 1000 - }) - - // Delete old files - const [files] = await bucket.getFiles({ - prefix: "hearing-", - maxResults: 1000 - }) - const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000 - const oldFiles = files.filter(file => { - const timestamp = parseInt(file.name.split("-").pop()?.split(".")[0] || "0") - return timestamp < oneDayAgo - }) - await Promise.all(oldFiles.map(file => file.delete())) - - // Return the new audio url - return url -} - -export const submitTranscription = async ({ - EventId, - maybeVideoUrl, - bucketName -}: { - EventId: number - maybeVideoUrl: string - bucketName?: string -}) => { - const assembly = new AssemblyAI({ - apiKey: process.env.ASSEMBLY_API_KEY ? process.env.ASSEMBLY_API_KEY : "" - }) - - const newToken = randomBytes(16).toString("hex") - const audioUrl = await extractAudioFromVideo( - EventId, - maybeVideoUrl, - bucketName - ) - - const transcript = await assembly.transcripts.submit({ - audio: - // test with: "https://assemblyaiusercontent.com/playground/aKUqpEtmYmI.flac", - audioUrl, - webhook_url: - // make sure process.env.FUNCTIONS_API_BASE equals - // https://us-central1-digital-testimony-prod.cloudfunctions.net - // on prod. test with: - // "https://ngrokid.ngrok-free.app/demo-dtp/us-central1/transcription", - `${process.env.FUNCTIONS_API_BASE}/transcription`, - speaker_labels: true, - webhook_auth_header_name: "x-maple-webhook", - webhook_auth_header_value: newToken - }) - - await db - .collection("events") - .doc(`hearing-${String(EventId)}`) - .collection("private") - .doc("webhookAuth") - .set({ - videoAssemblyWebhookToken: sha256(newToken) - }) - - return transcript.id -} - -export const getHearingVideoUrl = async (EventId: number) => { - const req = await fetch( - `https://malegislature.gov/Events/Hearings/Detail/${EventId}` - ) - const res = await req.text() - if (res) { - const dom = new JSDOM(res) - if (dom) { - const maybeVideoSource = - dom.window.document.querySelectorAll("video source") - if (maybeVideoSource.length && maybeVideoSource[0]) { - const firstVideoSource = maybeVideoSource[0] as HTMLSourceElement - const maybeVideoUrl = firstVideoSource.src - - return isValidVideoUrl(maybeVideoUrl) ? maybeVideoUrl : null - } - } - } - return null -} - -const shouldScrapeVideo = async ( - EventId: number, - ignoreCutoff: boolean = false -) => { - const eventInDb = await db - .collection("events") - .doc(`hearing-${String(EventId)}`) - .get() - const eventData = eventInDb.data() - - console.log("eventData in shouldScrapeVideo()", eventData) - - if (!eventData) { - return false - } - if (!eventData.videoURL) { - return ( - ignoreCutoff || - withinCutoff(new Date(Hearing.check(eventData).startsAt.toDate())) - ) - } - return false -} - -const loadCommitteeChairNames = async ( - generalCourtNumber: number, - committeeCode: string -) => { - try { - const committeeSnap = await db - .collection(`generalCourts/${generalCourtNumber}/committees`) - .doc(committeeCode) - .get() - - if (!committeeSnap.exists) return [] as string[] - - const { members, content } = Committee.check(committeeSnap.data()) - const chairCodes = new Set() - const maybeHouse = content.HouseChairperson?.MemberCode - const maybeSenate = content.SenateChairperson?.MemberCode - - if (maybeHouse) chairCodes.add(maybeHouse) - if (maybeSenate) chairCodes.add(maybeSenate) - return (members ?? []) - .filter(member => chairCodes.has(member.id)) - .map(member => member.name) - } catch (error) { - console.warn( - `Failed to load committee chairs for ${committeeCode} (${generalCourtNumber}):`, - error - ) - return [] as string[] - } -} - -class HearingScraper extends EventScraper { - constructor() { - super("every 60 minutes", 480, "4GB") - } - - async listEvents() { - const events = await api.listHearings() - return events.filter(HearingListItem.guard) - } - - async getEvent( - { EventId }: HearingListItem /* e.g. 4962 */, - { ignoreCutoff = false }: { ignoreCutoff?: boolean } = {} - ) { - const data = await api.getHearing(EventId) - const content = HearingContent.check(data) - - console.log("content in getEvent()", content) - - const host = content.HearingHost - const committeeChairs = - host?.CommitteeCode && host?.GeneralCourtNumber - ? await loadCommitteeChairNames( - host.GeneralCourtNumber, - host.CommitteeCode - ) - : [] - - if (await shouldScrapeVideo(EventId, ignoreCutoff)) { - try { - const maybeVideoUrl = await getHearingVideoUrl(EventId) - if (maybeVideoUrl) { - const transcriptId = await submitTranscription({ - maybeVideoUrl, - EventId - }) - - // Immediately save video info to prevent reprocessing - // since the bulkWriter does not save the video properties - // returned from this method. - await db.collection("events").doc(`hearing-${EventId}`).update({ - videoURL: maybeVideoUrl, - videoFetchedAt: Timestamp.now(), - videoTranscriptionId: transcriptId - }) - - return { - id: `hearing-${EventId}`, - type: "hearing", - content, - ...this.timestamps(content), - videoURL: maybeVideoUrl, - videoFetchedAt: Timestamp.now(), - committeeChairs, - videoTranscriptionId: transcriptId // using the assembly Id as our transcriptionId - } as Hearing - } - } catch (error) { - functions.logger.error( - `Failed to process audio for hearing ${EventId}:`, - error - ) - return { - id: `hearing-${EventId}`, - type: "hearing", - content, - committeeChairs, - ...this.timestamps(content) - } as Hearing - } - } - return { - id: `hearing-${EventId}`, - type: "hearing", - content, - committeeChairs, - ...this.timestamps(content) - } as Hearing - } -} +import { checkAuth, checkAdmin } from "../common" +import { db } from "../firebase" +import { SpecialEventsScraper, SessionScraper } from "./EventScraper" +import { HearingScraper, HearingPostProcessor } from "./HearingScraper" /** * Callable cloud function to scrape a single hearing by EventId. * Requires authentication to prevent abuse of API call limits. + * Warning that, for hearings with multiple videos, the timeout + * may not be sufficiently large to scrape all videos. Check for + * success status. * * @param data - Object containing the EventId (e.g., 1234) * @param context - Firebase callable context with auth information */ export const scrapeSingleHearing = functions .runWith({ - timeoutSeconds: 480, + timeoutSeconds: 540, secrets: ["ASSEMBLY_API_KEY"], memory: "4GB" }) @@ -445,12 +36,10 @@ export const scrapeSingleHearing = functions } try { - // Create a temporary scraper instance to reuse the existing logic - const scraper = new HearingScraper() - const hearing = await scraper.getEvent( - { EventId: eventId }, - { ignoreCutoff: true } - ) + const hearing = await new HearingScraper().getEvent({ EventId: eventId }) + const doc = await db.doc(`/events/${hearing.id}`).get() + await doc.ref.set(hearing, { merge: true }) + await new HearingPostProcessor().addVideosToHearing(doc, {}) // Save the hearing to Firestore await db.doc(`/events/${hearing.id}`).set(hearing, { merge: true }) @@ -473,7 +62,7 @@ export const scrapeSingleHearing = functions }) export const scrapeSingleHearingv2 = onCall( - { timeoutSeconds: 480, memory: "4GiB", secrets: ["ASSEMBLY_API_KEY"] }, + { timeoutSeconds: 540, memory: "4GiB", secrets: ["ASSEMBLY_API_KEY"] }, async (request: CallableRequest) => { // Require admin authentication // Check how to integrate the new object with these helper functions @@ -490,15 +79,12 @@ export const scrapeSingleHearingv2 = onCall( } try { - // Create a temporary scraper instance to reuse the existing logic - const scraper = new HearingScraper() - const hearing = await scraper.getEvent( - { EventId: eventId }, - { ignoreCutoff: true } - ) + const hearing = await new HearingScraper().getEvent({ EventId: eventId }) + const doc = await db.doc(`/events/${hearing.id}`).get() + await doc.ref.set(hearing, { merge: true }) + await new HearingPostProcessor().addVideosToHearing(doc, {}) // Save the hearing to Firestore - await db.doc(`/events/${hearing.id}`).set(hearing, { merge: true }) console.log(`Successfully scraped hearing ${eventId}`, hearing) @@ -521,3 +107,4 @@ export const scrapeSingleHearingv2 = onCall( export const scrapeSpecialEvents = new SpecialEventsScraper().function export const scrapeSessions = new SessionScraper().function export const scrapeHearings = new HearingScraper().function +export const scrapeVideos = new HearingPostProcessor().function diff --git a/functions/src/events/types.ts b/functions/src/events/types.ts index 952d6cfe5..bec2028b8 100644 --- a/functions/src/events/types.ts +++ b/functions/src/events/types.ts @@ -101,7 +101,7 @@ export type Video = Static export const Video = Record({ url: String, title: String, - transcriptionId: String + transcriptionId: Union(String, Null) }) export type Hearing = Static diff --git a/functions/src/index.ts b/functions/src/index.ts index 796a30871..95b515dd3 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -17,6 +17,7 @@ export { } from "./committees" export { scrapeHearings, + scrapeVideos, scrapeSessions, scrapeSpecialEvents, scrapeSingleHearing, diff --git a/functions/src/webhooks/transcription.ts b/functions/src/webhooks/transcription.ts index 04a5ed1f8..10c0dd9df 100644 --- a/functions/src/webhooks/transcription.ts +++ b/functions/src/webhooks/transcription.ts @@ -1,5 +1,5 @@ import * as functions from "firebase-functions" -import { AssemblyAI } from "assemblyai" +import { assemblyAI } from "../events/AssemblyAIHandler" import { db, Timestamp } from "../firebase" import { sha256 } from "js-sha256" @@ -10,13 +10,8 @@ export const transcription = functions if (req.body.status === "completed") { // If we get a request with the right header and status, get the // transcription from the assembly API. - const assembly = new AssemblyAI({ - apiKey: process.env.ASSEMBLY_API_KEY - ? process.env.ASSEMBLY_API_KEY - : "" - }) - const transcript = await assembly.transcripts.get( + const transcript = await assemblyAI().getTranscript( req.body.transcript_id ) @@ -25,7 +20,7 @@ export const transcription = functions // look for an event (aka Hearing) in the DB with a matching ID. const maybeEventsInDb = await db .collection("events") - .where("videoTranscriptionId", "==", transcript.id) + .where("transcriptionIds", "array-contains", transcript.id) .get() if (maybeEventsInDb.docs.length) { @@ -43,7 +38,7 @@ export const transcription = functions .collection("events") .doc(doc.id) .collection("private") - .doc("webhookAuth") + .doc(transcript.id) .get() const tokenDataInDb = @@ -69,12 +64,12 @@ export const transcription = functions // If there is one authenticated event, pull out the parts we want to // save and try to save them in the db. - const { paragraphs } = await assembly.transcripts.paragraphs( + const paragraphs = await assemblyAI().fetchParagraphs( transcript.id ) const { id, text, audio_url, utterances } = transcript try { - const transcriptionInDb = await db + const transcriptionInDb = db .collection("transcriptions") .doc(id) @@ -97,7 +92,7 @@ export const transcription = functions writer.set( db .collection("transcriptions") - .doc(`${transcript.id}`) + .doc(transcript.id) .collection("utterances") .doc(), { speaker, confidence, start, end, text } @@ -115,7 +110,7 @@ export const transcription = functions writer.set( db .collection("transcriptions") - .doc(`${transcript.id}`) + .doc(transcript.id) .collection("paragraphs") .doc(), { confidence, start, end, text } @@ -132,7 +127,7 @@ export const transcription = functions .collection("events") .doc(authenticatedEventIds[index]) .collection("private") - .doc("webhookAuth") + .doc(transcript.id) .set({ videoAssemblyWebhookToken: null }) diff --git a/pages/ballotQuestions/[id].tsx b/pages/ballotQuestions/[id].tsx index 5bbbe1ff9..0d9b04598 100644 --- a/pages/ballotQuestions/[id].tsx +++ b/pages/ballotQuestions/[id].tsx @@ -10,6 +10,7 @@ import { Hearing } from "../../components/ballotquestions/types" import { BallotQuestion, Bill } from "../../components/db" +import { Video } from "../../components/hearing/hearing" import { createPage } from "../../components/page" import { usePublishService } from "../../components/publish/hooks" import { serverSideTranslations } from "next-i18next/serverSideTranslations" @@ -22,7 +23,7 @@ async function getHearing(id: string): Promise { const data = snap.data() return { id, - videoURL: data.videoURL ?? undefined, + videoURLs: data.videos ? data.videos.map((item: Video) => item.url) : [], startsAt: data.startsAt?.toMillis() ?? 0 } } diff --git a/scripts/firebase-admin/backfillHearingTranscription.ts b/scripts/firebase-admin/backfillHearingTranscription.ts index a9c19daeb..c14bc7d5e 100644 --- a/scripts/firebase-admin/backfillHearingTranscription.ts +++ b/scripts/firebase-admin/backfillHearingTranscription.ts @@ -1,15 +1,15 @@ -import { Timestamp } from "../../functions/src/firebase" -import { Record, Number, String } from "runtypes" +import { Record, Number, String, Boolean } from "runtypes" import { Script } from "./types" -import { getHearingVideoUrl, submitTranscription } from "functions/src/events" +import { HearingPostProcessor } from "functions/src/events" const Args = Record({ eventId: Number.optional(), - bucketName: String.optional() + bucketName: String.optional(), + refetchVideos: Boolean.optional() }) export const script: Script = async ({ db, args }) => { - const { eventId, bucketName } = Args.check(args) + const { eventId, bucketName, refetchVideos } = Args.check(args) // Process a single event by eventId if (eventId) { @@ -20,30 +20,14 @@ export const script: Script = async ({ db, args }) => { return } const data = doc.data() - if (data?.videoTranscriptionId) { - console.log(`Hearing ${eventId} already has a transcription.`) - return - } + if (!data) return try { - const maybeVideoUrl = await getHearingVideoUrl(eventId) - if (maybeVideoUrl) { - const transcriptId = await submitTranscription({ - maybeVideoUrl, - EventId: eventId, - bucketName - }) - - await docRef.update({ - videoURL: maybeVideoUrl, - videoFetchedAt: Timestamp.now(), - videoTranscriptionId: transcriptId - }) - - console.log( - `Transcription submitted for hearing ${eventId}: ${transcriptId}` - ) - } else { - console.log(`No valid video URL found for hearing ${eventId}`) + const hearingProcessor = new HearingPostProcessor() + const changed = await hearingProcessor.addVideosToHearing(doc, { + refetch: refetchVideos + }) + if (changed) { + console.log(`Transcriptions submitted for hearing ${eventId}`) } } catch (error) { console.error(`Failed to process hearing ${eventId}:`, error) @@ -60,40 +44,23 @@ export const script: Script = async ({ db, args }) => { if (count >= 100) { break // Limit to 100 operations for this run } + const EventId = parseInt(doc.id.replace("hearing-", "")) + console.log(`Processing hearing ${EventId}...`) const data = doc.data() - if (!data.videoTranscriptionId) { - const EventId = parseInt(doc.id.replace("hearing-", "")) - console.log(`Processing hearing ${EventId}...`) + if (data.empty) continue - try { - const maybeVideoUrl = await getHearingVideoUrl(EventId) - if (maybeVideoUrl) { - const transcriptId = await submitTranscription({ - maybeVideoUrl, - EventId, - bucketName - }) - - await doc.ref.update({ - videoURL: maybeVideoUrl, - videoFetchedAt: Timestamp.now(), - videoTranscriptionId: transcriptId - }) + try { + const hearingProcessor = new HearingPostProcessor() + const changed = await hearingProcessor.addVideosToHearing(doc, { + refetch: refetchVideos + }) - console.log( - `Transcription submitted for hearing ${EventId}: ${transcriptId}` - ) - count++ - } else { - console.log(`No valid video URL found for hearing ${EventId}`) - } - } catch (error) { - console.error(`Failed to process hearing ${EventId}:`, error) + if (changed) { + console.log(`New transcriptions submitted for hearing ${EventId}`) + count++ } - } else { - console.log( - `Skipping hearing ${data.EventId}, already has transcription.` - ) + } catch (error) { + console.error(`Failed to process hearing ${EventId}:`, error) } } console.log("Done processing hearings without transcriptions.") diff --git a/scripts/firebase-admin/migrateHearingTranscription.ts b/scripts/firebase-admin/migrateHearingTranscription.ts index 910ba3943..293971837 100644 --- a/scripts/firebase-admin/migrateHearingTranscription.ts +++ b/scripts/firebase-admin/migrateHearingTranscription.ts @@ -39,6 +39,123 @@ function convertTimestamps(obj: any): any { return obj } +async function migrateTranscription( + db: admin.firestore.Firestore, + devDb: admin.firestore.Firestore, + transcriptionId: string, + bulkWriter?: FirebaseFirestore.BulkWriter +) { + const devTranscriptionDoc = await devDb + .collection("transcriptions") + .doc(transcriptionId) + .get() + + const devTranscriptionData = devTranscriptionDoc.exists + ? devTranscriptionDoc.data() + : null + + if (!devTranscriptionData) { + throw new Error( + `Transcription ${transcriptionId} not found in dev project.` + ) + } + + // Create transcription in target project instead of setting, in case it already exists, which will throw an error + const convertedData = convertTimestamps(devTranscriptionData) + console.log(`Creating transcription ${transcriptionId}...`) + if (bulkWriter) { + bulkWriter.create( + db.collection("transcriptions").doc(transcriptionId), + convertedData + ) + } else { + await db + .collection("transcriptions") + .doc(transcriptionId) + .create(convertedData) + } + + const subcollections = await devTranscriptionDoc.ref.listCollections() + for (const subcol of subcollections) { + const docs = await subcol.get() + for (const doc of docs.docs) { + const ref = db + .collection("transcriptions") + .doc(transcriptionId) + .collection(subcol.id) + .doc(doc.id) + if (bulkWriter) { + bulkWriter.set(ref, doc.data()) + } else { + await ref.set(doc.data()) + } + } + } +} + +async function migrateHearing( + db: admin.firestore.Firestore, + devDb: admin.firestore.Firestore, + devDoc: + | admin.firestore.DocumentSnapshot + | admin.firestore.QueryDocumentSnapshot, + bulkWriter?: FirebaseFirestore.BulkWriter +): Promise<"migrate" | "skip" | "fail"> { + const devData = devDoc.data() + + if (!devData?.transcriptionIds?.length) { + console.log(`Hearing ${devDoc.id} has no transcription to migrate.`) + return "skip" + } + const targetDoc = await db.collection("events").doc(devDoc.id).get() + const targetData = targetDoc.exists ? targetDoc.data() : null + + if (!targetData) { + console.log(`${devDoc.id} not found in target project.`) + return "skip" + } + + let found = false + for (const transcriptionId of devData.transcriptionIds) { + if ( + !targetData.trancriptionIds || + !targetData.transcriptionIds.includes(transcriptionId) + ) { + found = true + try { + await migrateTranscription(db, devDb, transcriptionId, bulkWriter) + } catch (err) { + console.error(`Error creating transcription ${transcriptionId}:`, err) + return "fail" + } + } + } + if (!found) { + console.log(`${devDoc.id} has no new transcriptions.`) + return "skip" + } + + console.log(`Updating hearing ${devDoc.id}...`) + if (bulkWriter) { + bulkWriter.update(db.collection("events").doc(devDoc.id), { + videos: devData.videos, + videosFetchedAt: convertTimestamps(devData.videosFetchedAt), + transcriptionIds: devData.transcriptionIds + }) + } else { + await db + .collection("events") + .doc(devDoc.id) + .update({ + videos: devData.videos, + videosFetchedAt: convertTimestamps(devData.videosFetchedAt), + transcriptionIds: devData.transcriptionIds + }) + } + + return "migrate" +} + const Args = Record({ sourceProject: String, hearing: Number.optional() @@ -66,78 +183,15 @@ export const script: Script = async ({ db, args }) => { if (hearing) { const hearingId = "hearing-" + hearing console.log(`Processing single hearing: ${hearingId}`) - const devHearingsSnapshot = await devDb - .collection("events") - .doc(hearingId) - .get() + const devDoc = await devDb.collection("events").doc(hearingId).get() - if (!devHearingsSnapshot.exists) { + if (!devDoc.exists) { console.error(`Hearing ${hearingId} not found in dev project.`) return } - const devData = devHearingsSnapshot.data() - if (!devData?.videoTranscriptionId) { - console.log(`Hearing ${hearingId} has no transcription to migrate.`) - return - } - const targetDoc = await db.collection("events").doc(hearingId).get() - const targetData = targetDoc.exists ? targetDoc.data() : null - - // Only migrate if hearing in target environment does not have a transcription yet - if (!targetData?.videoTranscriptionId) { - const transcriptionId = devData.videoTranscriptionId - const devTranscriptionDoc = await devDb - .collection("transcriptions") - .doc(transcriptionId) - .get() - - const devTranscriptionData = devTranscriptionDoc.exists - ? devTranscriptionDoc.data() - : null - - if (devTranscriptionData) { - // Create transcription in target project instead of setting, in case it already exists, which will throw an error - const convertedData = convertTimestamps(devTranscriptionData) - try { - console.log(`Creating transcription ${transcriptionId}...`) - await db - .collection("transcriptions") - .doc(transcriptionId) - .create(convertedData) - } catch (err) { - console.error(`Error creating transcription ${transcriptionId}:`, err) - return - } - - const subcollections = await devTranscriptionDoc.ref.listCollections() - for (const subcol of subcollections) { - const docs = await subcol.get() - for (const doc of docs.docs) { - await db - .collection("transcriptions") - .doc(transcriptionId) - .collection(subcol.id) - .doc(doc.id) - .set(doc.data()) - } - } - } else { - console.error( - `Transcription ${transcriptionId} not found in dev project.` - ) - } - - await db - .collection("events") - .doc(hearingId) - .update({ - videoURL: devData.videoURL, - videoFetchedAt: convertTimestamps(devData.videoFetchedAt), - videoTranscriptionId: devData.videoTranscriptionId - }) - console.log(`Migration complete for hearing ${hearingId}.`) - } + await migrateHearing(db, devDb, devDoc) + console.log(`Migration complete for hearing ${hearingId}.`) } else { // For full migration const devHearingsSnapshot = await devDb @@ -157,83 +211,14 @@ export const script: Script = async ({ db, args }) => { console.log(`Migration limit of ${limit} reached. Stopping.`) break } - const devData = devDoc.data() - if (!devData.videoTranscriptionId) { - skipped++ - console.log(`${devDoc.id} has no transcription to migrate.`) - continue - } - - const targetDoc = await db.collection("events").doc(devDoc.id).get() - const targetData = targetDoc.exists ? targetDoc.data() : null - - if (!targetData) { - skipped++ - console.log(`${devDoc.id} not found in target project.`) - continue - } - // Only migrate if hearing in target environment does not have a transcription yet - if (!targetData?.videoTranscriptionId) { - console.log(`Migrating ${devDoc.id}...`) - const transcriptionId = devData.videoTranscriptionId - const devTranscriptionDoc = await devDb - .collection("transcriptions") - .doc(transcriptionId) - .get() - - const devTranscriptionData = devTranscriptionDoc.exists - ? devTranscriptionDoc.data() - : null - - if (devTranscriptionData) { - // Create transcription in target project instead of setting, in case it already exists, which will throw an error - const convertedData = convertTimestamps(devTranscriptionData) - try { - console.log(`Creating transcription ${transcriptionId}...`) - bulkWriter.create( - db.collection("transcriptions").doc(transcriptionId), - convertedData - ) - } catch (err) { - failed++ - console.error( - `Error creating transcription ${transcriptionId}:`, - err - ) - continue - } - - const subcollections = await devTranscriptionDoc.ref.listCollections() - for (const subcol of subcollections) { - const docs = await subcol.get() - for (const doc of docs.docs) { - await db - .collection("transcriptions") - .doc(transcriptionId) - .collection(subcol.id) - .doc(doc.id) - .set(doc.data()) - } - } - } else { - failed++ - console.error( - `Transcription ${transcriptionId} not found in dev project.` - ) - continue - } - - console.log(`Updating ${devDoc.id}...`) - bulkWriter.update(db.collection("events").doc(devDoc.id), { - videoURL: devData.videoURL, - videoFetchedAt: convertTimestamps(devData.videoFetchedAt), - videoTranscriptionId: devData.videoTranscriptionId - }) - migrated++ + const result = await migrateHearing(db, devDb, devDoc, bulkWriter) + if (result === "migrate") { + migrated += 1 + } else if (result === "skip") { + skipped += 1 } else { - console.log(`${devDoc.id} already has a transcription, skipping.`) - skipped++ + failed += 1 } } diff --git a/stories/organisms/ballotquestions/BallotQuestionDetails.stories.tsx b/stories/organisms/ballotquestions/BallotQuestionDetails.stories.tsx index 40ec1154c..c26d83150 100644 --- a/stories/organisms/ballotquestions/BallotQuestionDetails.stories.tsx +++ b/stories/organisms/ballotquestions/BallotQuestionDetails.stories.tsx @@ -142,7 +142,10 @@ const sampleBill: Bill = { const sampleHearing = { id: "hearing-101", startsAt: new Date("2026-03-12T10:00:00-05:00").getTime(), - videoURL: "https://malegislature.gov/" + videoURLs: [ + "https://prodarchivevideo.blob.core.windows.net/video/2022/Hearings/Joint/April/12.mp4", + "https://prodarchivevideo.blob.core.windows.net/video/2022/Hearings/Joint/April/12_1.mp4" + ] } const emptyTestimonyListing: UsePublishedTestimonyListing = { From c9453a228c7523e9a600294413a7e7a81d2ba68e Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 17 Jul 2026 10:46:20 -0400 Subject: [PATCH 081/106] Add a call to action to each Why Use MAPLE persona, and underline the resource links Give each persona tab a closing card matching the Writing Effective Testimony page: individuals and organizations get a sign-up card, and legislators get a Browse Testimony button paired with a secondary sign-up. Each card ends with a contact line linking to info@mapletestimony.org. The copy is drawn into a new `cta` block per persona namespace, leaving the legacy `challenge` keys alone. Give the CTA buttons MAPLE's button height in a navy pill, and apply the same treatment to the Writing Effective Testimony button so the two pages match. Underline the Additional Resources links on the legislative process page, matching the link style on the About Testimony page. --- components/AdditionalResources.tsx | 7 +- components/learn/Testimony/WritingTips.tsx | 3 +- components/learn/WhyUseMaple/WhyUseMaple.tsx | 132 ++++++++++++++++++- public/locales/en/forindividuals.json | 6 + public/locales/en/forlegislators.json | 7 + public/locales/en/fororgs.json | 6 + public/locales/en/learn.json | 7 +- 7 files changed, 154 insertions(+), 14 deletions(-) diff --git a/components/AdditionalResources.tsx b/components/AdditionalResources.tsx index cd7f1b9f9..144f99051 100644 --- a/components/AdditionalResources.tsx +++ b/components/AdditionalResources.tsx @@ -47,12 +47,7 @@ const Section = styled.section` a { color: var(--bs-blue); - font-weight: 600; - text-decoration: none; - - &:hover { - text-decoration: underline; - } + text-decoration: underline; } @media (prefers-reduced-motion: reduce) { diff --git a/components/learn/Testimony/WritingTips.tsx b/components/learn/Testimony/WritingTips.tsx index 6351bf305..a3798e121 100644 --- a/components/learn/Testimony/WritingTips.tsx +++ b/components/learn/Testimony/WritingTips.tsx @@ -205,9 +205,10 @@ const Cta = styled.div` margin: 0 auto 1.5rem; } + /* A navy pill, at MAPLE's button height (standard vertical padding). */ a { display: inline-block; - padding: 0.75rem 1.75rem; + padding: var(--maple-space-sm) var(--maple-space-xl); border-radius: var(--maple-radius-pill); background: ${NAVY}; color: var(--maple-text-inverse); diff --git a/components/learn/WhyUseMaple/WhyUseMaple.tsx b/components/learn/WhyUseMaple/WhyUseMaple.tsx index 230cfd8b4..c03a531cc 100644 --- a/components/learn/WhyUseMaple/WhyUseMaple.tsx +++ b/components/learn/WhyUseMaple/WhyUseMaple.tsx @@ -1,6 +1,7 @@ import { TFunction, useTranslation } from "next-i18next" import { useEffect, useState } from "react" import styled from "styled-components" +import SignInWithButton from "../../auth/SignInWithButton" import { Internal } from "../../links" import LearnBreadcrumb from "../LearnBreadcrumb" import LearnHeader from "../LearnHeader" @@ -33,6 +34,12 @@ export type Persona = { benefits: string[] /** callToAction body keys, in render order. */ body: string[] + /** + * The closing call to action. "signup" opens the sign-up modal; "browse" + * links to a page and may pair it with a secondary sign-up button. Copy comes + * from the persona's `cta` namespace. + */ + cta: { kind: "signup" } | { kind: "browse"; href: string; signup?: boolean } } export const PERSONAS: Persona[] = [ @@ -48,7 +55,8 @@ export const PERSONAS: Persona[] = [ "anyLanguage", "stayInformed" ], - body: ["bodytext"] + body: ["bodytext"], + cta: { kind: "signup" } }, { slug: "for-orgs", @@ -65,7 +73,8 @@ export const PERSONAS: Persona[] = [ "legislativeResearch", "changeNorms" ], - body: ["bodytext"] + body: ["bodytext"], + cta: { kind: "signup" } }, { slug: "for-legislators", @@ -79,7 +88,8 @@ export const PERSONAS: Persona[] = [ "languageAccess", "advancedStatistics" ], - body: ["bodytextOne", "bodytextTwo"] + body: ["bodytextOne", "bodytextTwo"], + cta: { kind: "browse", href: "/testimony", signup: true } } ] @@ -379,6 +389,92 @@ const BenefitBody = ({ ) } +/* Closing call to action, matching the Writing Effective Testimony page's card: + a navy pill button (a browse link, or the sign-up modal trigger) and a contact + line below it. The button covers both the styled and the Bootstrap button + that SignInWithButton renders, whose full-width primary style is overridden. */ +const Cta = styled.div` + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + padding: 2.5rem 2rem; + margin-top: 1rem; + text-align: center; + + h2 { + font-family: var(--maple-font-heading); + font-weight: 900; + color: ${NAVY}; + font-size: 1.5rem; + margin-bottom: 0.5rem; + } + + .lede { + color: var(--maple-text-body); + line-height: 1.65; + max-width: 34rem; + margin: 0 auto 1.5rem; + } + + /* One or two navy pills at MAPLE's button height, centred and wrapping on + narrow screens. */ + .cta-buttons { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem; + } + + .cta-action, + a.cta-action { + display: inline-block; + width: auto !important; + padding: var(--maple-space-sm) var(--maple-space-xl); + border-radius: var(--maple-radius-pill); + background: ${NAVY} !important; + border: 2px solid ${NAVY} !important; + color: var(--maple-text-inverse) !important; + font-weight: 700; + text-decoration: none; + cursor: pointer; + } + + .cta-action:hover, + a.cta-action:hover { + background: var(--maple-brand-primary-strong) !important; + border-color: var(--maple-brand-primary-strong) !important; + color: var(--maple-text-inverse) !important; + } + + /* Secondary action: the same pill, outlined rather than filled. */ + .cta-action-secondary, + a.cta-action-secondary { + background: transparent !important; + color: ${NAVY} !important; + } + + .cta-action-secondary:hover, + a.cta-action-secondary:hover { + background: ${NAVY} !important; + color: var(--maple-text-inverse) !important; + } + + .cta-action-wrap { + display: inline-block; + } + + .cta-contact { + margin: 1.5rem 0 0; + font-size: 0.9375rem; + } + + .cta-contact a { + color: ${NAVY}; + font-weight: 600; + text-decoration: underline; + } +` + export const WhyUseMaple = ({ slug, onSelect @@ -511,6 +607,36 @@ export const WhyUseMaple = ({
+ + +

{t(`${active.ns}:cta.headline`)}

+

{t(`${active.ns}:cta.body`)}

+ {active.cta.kind === "browse" ? ( +
+ + {t(`${active.ns}:cta.button`)} + + {active.cta.signup && ( + + )} +
+ ) : ( + + )} +

+ + {t(`${active.ns}:cta.contact`)} + +

+ ) } diff --git a/public/locales/en/forindividuals.json b/public/locales/en/forindividuals.json index b355a8902..ea232840f 100644 --- a/public/locales/en/forindividuals.json +++ b/public/locales/en/forindividuals.json @@ -37,5 +37,11 @@ "bodytextTwo": "Creating a MAPLE account is completely free and takes only a couple minutes.", "signUp": "Click here to sign up for your MAPLE account", "contact": "Any questions? Reach out to info@mapletestimony.org" + }, + "cta": { + "headline": "Get started with MAPLE today!", + "body": "Sign up for an account to follow legislation that matters to you, learn different perspectives, and make your voice heard.", + "button": "Sign up for your account", + "contact": "Any questions? Reach out to info@mapletestimony.org" } } diff --git a/public/locales/en/forlegislators.json b/public/locales/en/forlegislators.json index 963709709..4e17c7da6 100644 --- a/public/locales/en/forlegislators.json +++ b/public/locales/en/forlegislators.json @@ -36,5 +36,12 @@ "title": "Start using MAPLE today to put your finger on the pulse of your constituents!", "bodytext": "You can browse testimony submitted by constituents across the state on MAPLE, with or without creating your own account.", "contact": "Any questions? We're here to help! Reach out to info@mapletestimony.org" + }, + "cta": { + "headline": "Get started with MAPLE!", + "body": "You can browse testimony submitted by constituents across the state on MAPLE, with or without creating your own account.", + "button": "Browse Testimony", + "signup": "Sign up for an account", + "contact": "Any questions? Reach out to info@mapletestimony.org" } } diff --git a/public/locales/en/fororgs.json b/public/locales/en/fororgs.json index 6f40c2120..2e8d19010 100644 --- a/public/locales/en/fororgs.json +++ b/public/locales/en/fororgs.json @@ -52,5 +52,11 @@ "p2": "In addition to all the benefits outlined above, these organizations will have their profiles featured prominently on the MAPLE website during the two-year legislative session. Any organization can contact MAPLE to be considered for membership in the CLO. Organizations will be prioritized to join the CLO based on the size of the constituency they represent.", "p3": "Maple is a nonpartisan project and does not take political positions; membership in the CLO does not indicate endorsement of any political position by MAPLE or its parent organization, members, or collaborators.", "callToAction": "Join the MAPLE coalition-reach out to info@mapletestimony.org" + }, + "cta": { + "headline": "Get started with MAPLE today!", + "body": "Sign up for your organization's account to publish and share your testimony, coordinate with your members and followers, and research legislation.", + "button": "Sign up for your organization", + "contact": "Any questions? Reach out to info@mapletestimony.org" } } diff --git a/public/locales/en/learn.json b/public/locales/en/learn.json index d329fe6bc..347243eb0 100644 --- a/public/locales/en/learn.json +++ b/public/locales/en/learn.json @@ -107,16 +107,15 @@ { "label": "Be Timely", "body": "Written testimony should target bills being considered by the legislature and should be submitted before the committee hearing date in order to have the most impact." - }, + }, { "label": "Be Original", "body": "Legislators receive a lot of form letters. These communications are important, but almost always an individual and personalized letter will have greater impact." - }, + }, { "label": "Be Informative", "body": "Introduce yourself. Explain why you are concerned about an issue and why you think one policy choice would be better than another. Whether you're a longtime advocate or first-time testifier, your testimony is important." }, - { "label": "Be Direct", "body": "State whether you support or oppose a bill. Be clear and specific about the policies you want to see. You don't need to know specific legal precedents or legislative language to provide valuable input." @@ -163,7 +162,7 @@ "cta": { "headline": "Ready to write?", "body": "Find a bill you care about and MAPLE will walk you through composing your testimony and sending it to the right committee.", - "button": "Browse bills" + "button": "Browse Bills" } }, "aiTools": { From 627e519809a0cb095385c0c9ebdd2ccc3846a881 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Fri, 17 Jul 2026 19:26:41 -0400 Subject: [PATCH 082/106] Restyle the About pages Bring the six About-dropdown pages into the shared Learn layout (breadcrumb + header): Mission & Goals, Our Team, Support MAPLE, How MAPLE Uses AI, In the News, and FAQ. - Restyle cards (offset-pill and white cards), spacing, fonts, and copy across the pages; add closing CTAs on Mission & Goals and FAQ - Reorder and relabel the About dropdown menu - Add an eyebrow prop to LearnBreadcrumb for the "About" trail --- components/AboutPagesCard/AboutPagesCard.tsx | 15 +- components/Faq/FaqCard.tsx | 158 ++++++- components/Faq/FaqPage.tsx | 108 +++-- components/Faq/FaqQandAButton.tsx | 166 +++++-- components/Faq/faqContent.json | 7 + components/GoalsAndMission/GoalsAndMission.js | 33 +- .../GoalsAndMissionCardContent.tsx | 163 +++++-- components/InTheNews/InTheNews.tsx | 53 +-- components/InTheNews/NewsCard.tsx | 49 +- components/Navbar.tsx | 4 +- components/OurTeam/AdvisoryBoard.tsx | 24 +- components/OurTeam/OurTeam.tsx | 143 +++--- components/OurTeam/Partners.tsx | 11 +- components/OurTeam/SteeringCommittee.tsx | 35 +- components/about/MapleAI/MapleAI.tsx | 423 ++++++++++-------- .../about/SupportMaple/SupportMaple.tsx | 173 +++++-- components/learn/LearnBreadcrumb.tsx | 22 +- pages/about/how-maple-uses-ai.tsx | 6 +- pages/about/support-maple.tsx | 8 +- public/locales/en/common.json | 2 +- public/locales/en/goalsandmission.json | 6 +- public/locales/en/inTheNews.json | 4 +- public/locales/en/mapleAI.json | 26 +- public/locales/en/our-team.json | 2 + public/locales/en/partners.json | 6 +- public/locales/en/supportmaple.json | 8 +- 26 files changed, 1070 insertions(+), 585 deletions(-) diff --git a/components/AboutPagesCard/AboutPagesCard.tsx b/components/AboutPagesCard/AboutPagesCard.tsx index 6fbf18b41..9eeede25e 100644 --- a/components/AboutPagesCard/AboutPagesCard.tsx +++ b/components/AboutPagesCard/AboutPagesCard.tsx @@ -4,6 +4,9 @@ import { FC, PropsWithChildren } from "react" const StyledHeader = styled(Card.Header)` transform: translate(0); + font-family: "Nunito", system-ui, -apple-system, "Segoe UI", sans-serif; + font-weight: 700; + font-size: 1.25rem; &:first-child { border-radius: 10px 10px 0 0; @@ -20,6 +23,12 @@ const StyledHeader = styled(Card.Header)` } ` +const Body = styled(Card.Body)` + font-size: 1rem; + line-height: 1.6; + color: var(--maple-text-body); +` + const AboutPagesCard: FC> = ({ title, children @@ -27,12 +36,12 @@ const AboutPagesCard: FC> = ({ return ( {title} - {children} + {children} ) } diff --git a/components/Faq/FaqCard.tsx b/components/Faq/FaqCard.tsx index 9c9331099..b1929f137 100755 --- a/components/Faq/FaqCard.tsx +++ b/components/Faq/FaqCard.tsx @@ -1,4 +1,6 @@ +import { useId, useState } from "react" import styled from "styled-components" +import { ChevronDownIcon } from "../learn/icons" import { FaqQandAButton } from "./FaqQandAButton" type faqCardProps = { @@ -6,33 +8,143 @@ type faqCardProps = { qAndAs: { disabled?: boolean; question: string; answer: string }[] } -export const FaqCard = ({ heading, qAndAs }: faqCardProps) => { - const FaqCardContainer = styled.div` - margin: 0 2rem; - padding: 2rem 2.5rem; +const Card = styled.section` + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + overflow: hidden; + + > button { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 1.5rem 2rem; + background: none; + border: 0; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--bs-blue); + outline-offset: -2px; + } + } + + h2 { + font-family: var(--maple-font-heading); + font-weight: 700; + color: var(--bs-blue); + font-size: 1.125rem; + margin: 0; + } - @media only screen and (max-width: 600px) { - margin: 0rem; - padding: 2rem 1.5rem; + /* Chevron size and animation copied from the Why Use MAPLE toggles: a single + chevron that rotates rather than swapping icons. No hover circle here -- + the cursor alone is enough to signal the whole header is clickable. */ + .chevron { + flex-shrink: 0; + color: var(--maple-text-muted); + transition: transform 0.25s ease; + } + + > button[aria-expanded="true"] .chevron { + transform: rotate(180deg); + } + + @media (prefers-reduced-motion: reduce) { + .chevron { + transition: none; } - ` + } + + /* Expand/collapse animation copied from the Why Use MAPLE toggles: the row + animates between 0fr and 1fr rather than mounting instantly. The inner + element clips so the padding collapses with it. */ + .items { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + visibility: hidden; + transition: grid-template-rows 0.35s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.2s ease, visibility 0s linear 0.35s; + } + + .items[data-open="true"] { + grid-template-rows: 1fr; + opacity: 1; + visibility: visible; + transition: grid-template-rows 0.35s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.25s ease 0.08s, visibility 0s; + } + + /* No padding here: overflow: hidden only clips overflowing content, not this + element's own padding, so any padding placed directly on it would still + render at full size even while collapsed. Padding lives on .items-content + instead, which is what actually gets clipped away. */ + .items-inner { + min-height: 0; + overflow: hidden; + } + + /* Horizontal inset lives on each question row instead (see FaqQandAButton), + so the divider and the hover background share the same bounds as the row's + own padding rather than the container's. */ + .items-content { + padding: 0 0 1rem; + } + + @media (prefers-reduced-motion: reduce) { + .items { + transition: none; + } + } + + @media (max-width: 36rem) { + > button { + padding: 1.25rem 1.25rem; + } + + .items-content { + padding: 0 0 0.75rem; + } + } +` + +/** A collapsible category of questions. Categories start expanded. */ +export const FaqCard = ({ heading, qAndAs }: faqCardProps) => { + const [open, setOpen] = useState(true) + const panelId = useId() + const questions = qAndAs.filter(q => !q.disabled) return ( - -
{heading ?? ""}
-
- {qAndAs.map( - (key, index) => - typeof key.disabled === "undefined" && ( -
+ + +
+
+
+ {questions.map(q => ( -
- ) - )} - + key={q.question} + question={q.question} + answer={q.answer} + /> + ))} +
+
+
+ ) } diff --git a/components/Faq/FaqPage.tsx b/components/Faq/FaqPage.tsx index fc0a6e64b..42a2d01c8 100644 --- a/components/Faq/FaqPage.tsx +++ b/components/Faq/FaqPage.tsx @@ -1,43 +1,93 @@ +import { useTranslation } from "next-i18next" import styled from "styled-components" -import { Container, Stack } from "../bootstrap" +import LearnBreadcrumb from "../learn/LearnBreadcrumb" +import LearnHeader from "../learn/LearnHeader" +import LearnLayout from "../learn/LearnLayout" import { FaqCard } from "./FaqCard" import content from "./faqContent.json" -export const FaqPage = () => { - const faqData: FaqData = content - const faqKeys: string[] = Object.keys(faqData) +type Category = { + heading: string + qAndA: { disabled?: boolean; question: string; answer: string }[] +} + +const isCategory = (v: unknown): v is Category => + typeof v === "object" && v !== null && "qAndA" in v + +const Cards = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +` + +// Closing "still have questions?" call-to-action, on the tinted page background: +// a centered card with a mailto pill button. +const Cta = styled.div` + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + padding: 2.5rem 2rem; + margin-top: 1.5rem; + text-align: center; - interface FaqData { - [key: string]: string | FaqDataCard | any + h2 { + font-family: var(--maple-font-heading); + font-weight: 900; + color: var(--bs-blue); + font-size: 1.5rem; + margin-bottom: 0.5rem; } - interface FaqDataCard { - heading: string - qAndA: { question: string; answer: string }[] + p { + color: var(--maple-text-body); + line-height: 1.65; + max-width: 34rem; + margin: 0 auto 1.5rem; } - const FaqH1 = styled.h1` - margin: 1.5rem 0rem 1.5rem 2rem; + a { + display: inline-block; + padding: var(--maple-space-sm) var(--maple-space-xl); + border-radius: var(--maple-radius-pill); + background: var(--maple-brand-primary); + color: var(--maple-text-inverse); + font-weight: 700; + text-decoration: none; + } + + a:hover { + background: var(--maple-brand-primary-strong); + color: var(--maple-text-inverse); + } +` - @media only screen and (max-width: 600px) { - margin: 1rem 0rem 1rem 0rem; - } - ` +export const FaqPage = () => { + const { t } = useTranslation("common") + const categories = Object.values(content).filter(isCategory) return ( - <> - - FAQ - - {faqKeys.map((key, index) => ( - - ))} - - - + + + + + {categories.map(category => ( + + ))} + + +

{content.ctaHeadline}

+

{content.ctaBody}

+ {content.ctaButton} +
+
) } diff --git a/components/Faq/FaqQandAButton.tsx b/components/Faq/FaqQandAButton.tsx index fa093770b..1b3fb6038 100644 --- a/components/Faq/FaqQandAButton.tsx +++ b/components/Faq/FaqQandAButton.tsx @@ -1,52 +1,156 @@ -import { useEffect, useState } from "react" -import { Image, Collapse } from "../bootstrap" -import { useTranslation } from "next-i18next" +import { useId, useState } from "react" +import AddIcon from "@mui/icons-material/Add" +import RemoveIcon from "@mui/icons-material/Remove" +import styled from "styled-components" +import { Internal } from "../links" type faqQandAProps = { question: string answer: string } -export const FaqQandAButton = ({ question, answer }: faqQandAProps) => { - const { t } = useTranslation("common") - const [open, setOpen] = useState(false) +/* Full width: the divider spans the whole card, and the hover background + (painted on the button) reaches the same edges. The inset from the card's + edges is the button's own horizontal padding, not a margin on this row. - useEffect(() => {}, [open]) + An inset-divider alternative was tried and set aside (kept below, commented + out, in case it's wanted again): a wrapped line that fills only its parent's + content box, so padding on the wrapper shortens the visible line without a + margin -- border-top can't be inset this way, since a border draws at the + outer edge of the padding box regardless of that box's own padding. */ +const Item = styled.div` + border-top: 1px solid var(--maple-surface-border); - let supportLink = null + /* .divider-wrap { + padding: 0 2rem; + } - { - question == "How can I support MAPLE?" - ? (supportLink = ( - - {" this page"} - - )) - : null + .divider-line { + height: 1px; + background-color: var(--maple-surface-border); } + @media (max-width: 36rem) { + .divider-wrap { + padding: 0 1.25rem; + } + } */ + + button { + width: 100%; + display: flex; + align-items: center; + gap: 0.875rem; + padding: 1.125rem 2rem; + background: none; + border: 0; + text-align: left; + cursor: pointer; + transition: background-color 0.15s ease; + + &:hover { + background-color: var(--maple-surface-muted); + } + + &:focus-visible { + outline: 2px solid var(--bs-blue); + outline-offset: 2px; + } + } + + @media (max-width: 36rem) { + button { + padding: 1.125rem 1.25rem; + } + } + + .badge { + flex-shrink: 0; + width: 1.5rem; + height: 1.5rem; + border-radius: var(--maple-radius-pill); + background: var(--maple-surface-learn); + color: var(--maple-text-muted); + display: flex; + align-items: center; + justify-content: center; + } + + .question { + flex: 1; + min-width: 0; + font-weight: 600; + color: var(--maple-text-strong); + line-height: 1.45; + } + + .answer { + color: var(--maple-text-muted); + line-height: 1.6; + /* The answer sits outside the button, so its indent has to repeat the + button's own left padding (2rem) plus badge width (1.5rem) and gap + (0.875rem) to land under the question text; the right margin mirrors the + button's right padding. */ + margin: 0 2rem 1.125rem 4.375rem; + } + + .answer a { + color: var(--bs-blue); + text-decoration: underline; + } + + @media (max-width: 36rem) { + .answer { + margin: 0 1.25rem 1.125rem 3.625rem; + } + } +` + +/** + * A single question that expands to reveal its answer. The one about supporting + * MAPLE links to the support page, so that answer is composed rather than plain + * text. + */ +export const FaqQandAButton = ({ question, answer }: faqQandAProps) => { + const [open, setOpen] = useState(false) + const panelId = useId() + + const isSupport = question === "How can I support MAPLE?" + return ( - <> - setOpen(!open)} - aria-controls="example-collapse-text" + + {/* Inset-divider alternative -- pairs with the commented CSS above. */} + {/*
+
+
*/} +
+ {open && ( +

{answer} - {supportLink} + {isSupport && ( + <> + {" "} + {/* eslint-disable-next-line i18next/no-literal-string */} + this page. + + )}

- - + )} + ) } diff --git a/components/Faq/faqContent.json b/components/Faq/faqContent.json index 1010e8ca3..6b1bff45c 100755 --- a/components/Faq/faqContent.json +++ b/components/Faq/faqContent.json @@ -1,4 +1,11 @@ { + "title": "Frequently Asked Questions", + "breadcrumb": "FAQ", + "subhead": "Common questions about MAPLE, testimony, accounts, and privacy.", + "ctaHeadline": "Still have a question?", + "ctaBody": "If you don't see what you are looking for please reach out with any questions.", + "ctaEmail": "admin@mapletestimony.org", + "ctaButton": "Email Us", "card1": { "heading": "General", "qAndA": [ diff --git a/components/GoalsAndMission/GoalsAndMission.js b/components/GoalsAndMission/GoalsAndMission.js index dea7ca66a..3a397b31b 100644 --- a/components/GoalsAndMission/GoalsAndMission.js +++ b/components/GoalsAndMission/GoalsAndMission.js @@ -1,31 +1,26 @@ -import { Container, Row, Col } from "../bootstrap" import AboutPagesCard from "../AboutPagesCard/AboutPagesCard" import { OurGoalsCardContent, OurMissionCardContent } from "../GoalsAndMissionCardContent/GoalsAndMissionCardContent" import { useTranslation } from "next-i18next" +import LearnBreadcrumb from "../learn/LearnBreadcrumb" +import LearnHeader from "../learn/LearnHeader" +import LearnLayout from "../learn/LearnLayout" const GoalsAndMission = () => { - const { t } = useTranslation("goalsandmission") + const { t } = useTranslation(["goalsandmission", "common"]) return ( - - - -

- {t("header")} -

- - - - - - - -
-
+ + + + + + + + + + ) } diff --git a/components/GoalsAndMissionCardContent/GoalsAndMissionCardContent.tsx b/components/GoalsAndMissionCardContent/GoalsAndMissionCardContent.tsx index 70162e874..673d352ee 100644 --- a/components/GoalsAndMissionCardContent/GoalsAndMissionCardContent.tsx +++ b/components/GoalsAndMissionCardContent/GoalsAndMissionCardContent.tsx @@ -2,7 +2,7 @@ import { Row, Col } from "../bootstrap" import Image from "react-bootstrap/Image" import { SignInWithButton } from "../auth" import * as links from "../links" -import { Trans, useTranslation } from "next-i18next" +import { useTranslation } from "next-i18next" import { useAuth } from "components/auth" import styled from "styled-components" @@ -20,20 +20,96 @@ const StepsImage = styled(Image)` } ` +// Goal captions with tighter line spacing than the body default. (Utility +// classes on the element still handle alignment/size/weight/spacing.) +const GoalCaption = styled.figcaption` + line-height: 1.2; +` + +// Closing call-to-action at the bottom of the mission card: a centered headline, +// lede, a primary sign-in pill and a secondary outlined link. +const Cta = styled.div` + margin-top: 2.5rem; + padding-bottom: 1.5rem; + text-align: center; + + h2 { + font-family: var(--maple-font-heading); + font-weight: 900; + color: var(--bs-blue); + font-size: 1.5rem; + margin-bottom: 0.5rem; + } + + .lede { + color: var(--maple-text-body); + line-height: 1.65; + max-width: 34rem; + margin: 0 auto 1.5rem; + } + + .cta-action, + a.cta-action, + button.cta-action { + display: inline-block; + width: auto !important; + padding: var(--maple-space-sm) var(--maple-space-xl); + border-radius: var(--maple-radius-pill); + background: var(--maple-brand-primary) !important; + border: 2px solid var(--maple-brand-primary) !important; + color: var(--maple-text-inverse) !important; + font-weight: 700; + text-decoration: none; + cursor: pointer; + } + + .cta-action:hover, + a.cta-action:hover, + button.cta-action:hover { + background: var(--maple-brand-primary-strong) !important; + border-color: var(--maple-brand-primary-strong) !important; + color: var(--maple-text-inverse) !important; + } + + .cta-action-wrap { + display: inline-block; + } + + .cta-buttons { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem; + } + + /* Secondary action: the same pill, outlined rather than filled. */ + .cta-action-secondary, + a.cta-action-secondary { + background: transparent !important; + color: var(--maple-brand-primary) !important; + } + + .cta-action-secondary:hover, + a.cta-action-secondary:hover { + background: var(--maple-brand-primary) !important; + color: var(--maple-text-inverse) !important; + } +` + const OurGoalsCardContent = () => { const { t } = useTranslation("goalsandmission") return ( <> -
+
{t("goals.overview")}
-
+ {t("goals.increase")} -
+
@@ -44,9 +120,9 @@ const OurGoalsCardContent = () => { src="/doc-with-arrows-from-people.svg" alt="" /> -
+ {t("goals.engage")} -
+
@@ -55,18 +131,18 @@ const OurGoalsCardContent = () => {
-
+ {t("goals.strengthen")} -
+
-
+ {t("goals.encourage")} -
+
@@ -80,12 +156,15 @@ const OurMissionCardContent = () => { return ( <> -
+
{t("mission.overview")}
- + -

+

{t("mission.connect")}

@@ -112,7 +191,8 @@ const OurMissionCardContent = () => { sm={{ span: 12, order: 2 }} md={{ span: 6, order: 1 }} lg={8} - className={`fs-6 fs-sm-5 tracking-tight text-start lh-sm pt-4 pb-3 pb-sm-4 p-md-0`} + className={`fs-6 text-start lh-sm p-3 p-md-4`} + style={{ letterSpacing: "0.005em", fontSize: "0.875rem" }} >

{t("mission.disclosure")} @@ -125,42 +205,39 @@ const OurMissionCardContent = () => {

- - - -

- {t("mission.publish1")} -

-

- {t("mission.publish2")} -

+

{t("mission.publish1")}

+

{t("mission.publish2")}

+
{!authenticated && ( - <> - - -

- {t("mission.submit_now")} -

- -
- - - - - - - + +

{t("mission.ctaHeadline")}

+

{t("mission.ctaBody")}

+
+ + + {t("mission.ctaSecondary")} + +
+
)} ) diff --git a/components/InTheNews/InTheNews.tsx b/components/InTheNews/InTheNews.tsx index 6fd4c04fe..0f1426015 100644 --- a/components/InTheNews/InTheNews.tsx +++ b/components/InTheNews/InTheNews.tsx @@ -1,5 +1,5 @@ import { useState, Fragment } from "react" -import { Col, Row, Container, Badge, Spinner } from "../bootstrap" +import { Col, Row, Badge, Spinner } from "../bootstrap" import Tab from "react-bootstrap/Tab" import Nav from "react-bootstrap/Nav" import Dropdown from "react-bootstrap/Dropdown" @@ -7,6 +7,9 @@ import { useMediaQuery } from "usehooks-ts" import { useTranslation } from "next-i18next" import { NewsCard } from "./NewsCard" import { NewsType, NewsItem, useNews } from "components/db/news" +import LearnBreadcrumb from "../learn/LearnBreadcrumb" +import LearnHeader from "../learn/LearnHeader" +import LearnLayout from "../learn/LearnLayout" type NewsFeedProps = { type: NewsType | null @@ -22,7 +25,7 @@ type TabCounts = { const NewsFeed = ({ type, newsItems }: NewsFeedProps) => { return ( -
+
{newsItems .filter(item => item.type === type || type === null) .map((item, index) => ( @@ -33,7 +36,7 @@ const NewsFeed = ({ type, newsItems }: NewsFeedProps) => { } export const InTheNews = () => { - const { t } = useTranslation("inTheNews") + const { t } = useTranslation(["inTheNews", "common"]) const isMobile = useMediaQuery("(max-width: 768px)") const { result: newsItems, loading } = useNews() @@ -46,11 +49,15 @@ export const InTheNews = () => { : null return ( - -

- {t("title")} -

-
+ + + +
{loading ? (
@@ -72,42 +79,30 @@ export const InTheNews = () => { -
- -
+
-
- -
+
-
- -
+
) : ( - - -
- -
- -
+ )}
- + ) } const TabGroup = ({ counts }: { counts: TabCounts }) => { const { t } = useTranslation("inTheNews") return ( - + {newsTypePlurals .filter(val => counts[val]) .map(val => ( @@ -115,13 +110,13 @@ const TabGroup = ({ counts }: { counts: TabCounts }) => {

@@ -156,9 +156,9 @@ const OurMissionCardContent = () => { return ( <> -
+

{t("mission.overview")} -

+

{ {!authenticated && ( -

{t("mission.ctaHeadline")}

+

{t("mission.ctaHeadline")}

{t("mission.ctaBody")}

{ return ( -
+
{newsItems .filter(item => item.type === type || type === null) .map((item, index) => ( diff --git a/components/InTheNews/NewsCard.tsx b/components/InTheNews/NewsCard.tsx index cdef22cc8..f8522b468 100644 --- a/components/InTheNews/NewsCard.tsx +++ b/components/InTheNews/NewsCard.tsx @@ -10,6 +10,7 @@ export const NewsCard = ({ newsItem }: NewsCardProps) => { const { t } = useTranslation("inTheNews") return (
{ />

{newsItem.author}

-

{ }} > {newsItem.title} -

+
) so the visual +// styling is untouched -- these sit under an h2 section/card title everywhere +// they are used. +export const NameContainer = styled.div.attrs({ + role: "heading", + "aria-level": 3 +})` color: var(--maple-brand-primary); font-size: 25px; font-weight: 600; @@ -88,7 +95,13 @@ export const SectionContainer = styled.div` background: var(--maple-surface-base); ` -export const SectionTitle = styled.div` +// A card/section title. Exposed as an h2 for screen-reader heading navigation +// via role/aria-level (rather than a native

) so the visual styling is +// untouched -- it sits directly under the page h1. +export const SectionTitle = styled.div.attrs({ + role: "heading", + "aria-level": 2 +})` color: var(--maple-text-inverse); background: var(--maple-brand-primary); font-weight: 500; From be235d20b7ca9e74297dca99a49721f55509e599 Mon Sep 17 00:00:00 2001 From: k-g-k Date: Sat, 18 Jul 2026 13:57:03 -0400 Subject: [PATCH 085/106] Refine About/Learn spacing and fix mobile Our Team issues - Tighten the navbar-to-content and card spacing on the About/Learn pages; make AboutPagesCard's vertical margin responsive (my-3 my-md-5) so mobile isn't over-spaced where the offset-pill header doesn't apply - Even out the Our Team tabs on small screens: scale the label type fluidly and keep each on one line so the underlines stay aligned; drop the extra per-card column margins so all three tabs match - Nudge the How MAPLE Uses AI first-section and Guiding Principles padding to match the other pages - Fix the Partners "NuLawLab" logo showing as a black box on mobile by using the underlying PNG instead of the pattern-wrapped SVG that mobile browsers fail to render --- components/AboutPagesCard/AboutPagesCard.tsx | 2 +- components/OurTeam/AdvisoryBoard.tsx | 2 +- components/OurTeam/OurTeam.tsx | 23 +++++++++++-------- components/OurTeam/Partners.tsx | 2 +- components/OurTeam/SteeringCommittee.tsx | 4 ++-- components/about/MapleAI/MapleAI.tsx | 9 +++++--- components/learn/LearnLayout.tsx | 4 ++-- public/northeastern_school_of_law_logo.png | Bin 0 -> 17230 bytes 8 files changed, 27 insertions(+), 19 deletions(-) create mode 100644 public/northeastern_school_of_law_logo.png diff --git a/components/AboutPagesCard/AboutPagesCard.tsx b/components/AboutPagesCard/AboutPagesCard.tsx index 9eeede25e..ef6c74903 100644 --- a/components/AboutPagesCard/AboutPagesCard.tsx +++ b/components/AboutPagesCard/AboutPagesCard.tsx @@ -34,7 +34,7 @@ const AboutPagesCard: FC> = ({ children }) => { return ( - + { {t("advisory.desc")} - + diff --git a/components/OurTeam/OurTeam.tsx b/components/OurTeam/OurTeam.tsx index 758449022..3aa978cbc 100644 --- a/components/OurTeam/OurTeam.tsx +++ b/components/OurTeam/OurTeam.tsx @@ -39,7 +39,7 @@ export const OurTeam = () => { - + @@ -60,22 +60,27 @@ export const OurTeam = () => { ) } -// Tabs stay in a single row at every screen size; the font shrinks on narrow -// viewports so the three fit rather than collapsing to a dropdown. +// Tabs stay in a single row at every screen size (rather than collapsing to a +// dropdown). The three split the row evenly, so "Steering Committee" -- the +// longest label -- is the first to run out of room and wrap to a second line, +// which drops its underline below the other two. On smaller screens, scale the +// type down fluidly and keep each label on one line so all three stay aligned. const TabsRow = styled(Row)` - @media (max-width: 36rem) { - font-size: 0.8125rem; - } + @media (max-width: 48rem) { + font-size: clamp(0.55rem, 3.25vw, 0.9375rem); - @media (max-width: 22rem) { - font-size: 0.6875rem; + .nav-link { + white-space: nowrap; + padding-left: 0.75rem; + padding-right: 0.75rem; + } } ` const TabGroup = () => { const { t } = useTranslation("our-team") return ( - +

From 7dfb9735f60044dbb7103ee0e987b98190349b85 Mon Sep 17 00:00:00 2001 From: mertbagt <73559781+mertbagt@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:20:16 -0400 Subject: [PATCH 091/106] [Legislator Page] pipe in Maple profile (#2181) * maple profile * added profile social links * reinstate biography * reinstate TestimonyTab * corrected stroke-width to strokeWidth * cleanup * clarity * margin tweaks * cleanup * cleanup testimonyTab * more cleanup * tweaked disclaimer border * added public and limit to query --- .../EditProfilePage/PersonalInfoTab.tsx | 4 +- .../LegislatorComponents.tsx | 15 ++ .../LegislatorProfilePage.tsx | 122 +++++++------- .../LegislatorProfile/LegislatorSidebar.tsx | 19 ++- .../LegislatorProfile/LegislatorTabs.tsx | 6 +- .../SidebarComponents/Biography.tsx | 150 +++++++++++++++++- .../TabComponents/TestimonyTab.tsx | 97 ++++++++++- .../TestimonyTab_PendingProfileLink.tsx | 95 ----------- components/db/profile/urlCleanup.ts | 2 +- public/locales/en/legislators.json | 7 +- 10 files changed, 357 insertions(+), 160 deletions(-) delete mode 100644 components/LegislatorProfile/TabComponents/TestimonyTab_PendingProfileLink.tsx diff --git a/components/EditProfilePage/PersonalInfoTab.tsx b/components/EditProfilePage/PersonalInfoTab.tsx index 3e4574943..b8f83cf3a 100644 --- a/components/EditProfilePage/PersonalInfoTab.tsx +++ b/components/EditProfilePage/PersonalInfoTab.tsx @@ -10,7 +10,7 @@ import { TooltipButton } from "components/buttons" import { useTranslation } from "next-i18next" import styled from "styled-components" -type UpdateProfileData = { +export type UpdateProfileData = { fullName: string aboutYou: string twitter: string @@ -37,7 +37,7 @@ type Props = { legislatorsProps?: YourLegislatorsProps } -async function updateProfile( +export async function updateProfile( { profile, actions, uid }: Props, data: UpdateProfileData ) { diff --git a/components/LegislatorProfile/LegislatorComponents.tsx b/components/LegislatorProfile/LegislatorComponents.tsx index 1f25f37ab..38a48dbe2 100644 --- a/components/LegislatorProfile/LegislatorComponents.tsx +++ b/components/LegislatorProfile/LegislatorComponents.tsx @@ -130,6 +130,21 @@ export function LinkedIn() { ) } +export function Mastodon() { + return ( + + + + ) +} + export function Twitter() { return ( diff --git a/components/LegislatorProfile/LegislatorProfilePage.tsx b/components/LegislatorProfile/LegislatorProfilePage.tsx index 2f8d127e2..3e02284ef 100644 --- a/components/LegislatorProfile/LegislatorProfilePage.tsx +++ b/components/LegislatorProfile/LegislatorProfilePage.tsx @@ -1,9 +1,12 @@ +import { collection, getDocs, limit, query, where } from "firebase/firestore" import { faChevronRight } from "@fortawesome/free-solid-svg-icons" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" import ErrorPage from "next/error" import { useTranslation } from "next-i18next" +import { useEffect, useState } from "react" import styled from "styled-components" +import { firestore } from "../firebase" import * as links from "../links" import { @@ -11,6 +14,7 @@ import { DistrictLabel, formatPhoneNumber, LinkedIn, + Mastodon, PartyLabel, Twitter } from "./LegislatorComponents" @@ -24,25 +28,6 @@ import { Internal } from "components/links" import { FollowUserButton } from "components/shared/FollowButton" import { CircleImage } from "components/shared/LabeledIcon" -type ProfilePlaceholder = { - social?: { - blueSky?: string - linkedIn?: string - twitter?: string - } - website?: string -} - -const tabs = [ - "Priorities", - "Bills", - "Elections", - "Finance", - "District", - "Her testimony", - "Votes" -] - const ButtonContainer = styled(Col).attrs(props => ({ className: `col-12 justify-content-md-end ${props.className}`, md: `3`, @@ -124,6 +109,7 @@ export function LegislatorProfilePage({ court: number memberCode: string }) { + const { user } = useAuth() const { member, loading: memberLoading } = useMember(court, memberCode) const { district, loading: districtLoading } = useDistrict( court, @@ -131,24 +117,32 @@ export function LegislatorProfilePage({ member?.District ) const { t } = useTranslation("legislators") - // const { user } = useAuth() **uncomment when Following Button in enabled** - - /* replace with profile info for legislators with Maple accounts */ - let profile: ProfilePlaceholder = { - // social: { - // blueSky: "blueskyTest", - // linkedIn: "linkedinTest", - // twitter: "twitterTest" - // }, - // website: "test.com" - social: { - blueSky: "", - linkedIn: "", - twitter: "" - }, - website: "" + + const [legislatorId, setLegislatorId] = useState("") + const [legislatorData, setLegislatorData] = useState([]) + + async function getLegislatorUID(memberCode: string) { + let docList: any[] = [] + const q = query( + collection(firestore, "profiles"), + where("memberId", "==", memberCode), + where("public", "==", true), + limit(1) + ) + const docData = await getDocs(q) + + docData.forEach(doc => { + // doc.data() is never undefined for query doc snapshots + docList.push(doc.data()) + setLegislatorData(docList) + setLegislatorId(doc.id) + }) } + useEffect(() => { + getLegislatorUID(memberCode) + }, [memberCode]) + if (memberLoading) { return ( @@ -221,14 +215,14 @@ export function LegislatorProfilePage({
- {profile.website ? ( + {legislatorData[0]?.website ? (
· - {profile.website} + {legislatorData[0].website}
) : ( @@ -252,17 +246,18 @@ export function LegislatorProfilePage({ )}
- {/* uncomment when legislator Maple accounts are linked to this page - - {user && legislatorAccountId ? ( + {user && legislatorId ? (
- +
) : ( <> - )} */} + )} - + diff --git a/components/LegislatorProfile/LegislatorSidebar.tsx b/components/LegislatorProfile/LegislatorSidebar.tsx index 96cf42c9d..c35d166f4 100644 --- a/components/LegislatorProfile/LegislatorSidebar.tsx +++ b/components/LegislatorProfile/LegislatorSidebar.tsx @@ -2,12 +2,27 @@ import { Biography } from "./SidebarComponents/Biography" import { OtherTestimony } from "./SidebarComponents/OtherTestimony" import { UpcomingHearings } from "./SidebarComponents/UpcomingHearings" -export function LegislatorSidebar() { +export function LegislatorSidebar({ + court, + legislatorData, + legislatorId, + memberCode +}: { + court: number + legislatorData: any[] + legislatorId: string + memberCode: string +}) { return ( <> - + ) } diff --git a/components/LegislatorProfile/LegislatorTabs.tsx b/components/LegislatorProfile/LegislatorTabs.tsx index 40c264453..6f2791f19 100644 --- a/components/LegislatorProfile/LegislatorTabs.tsx +++ b/components/LegislatorProfile/LegislatorTabs.tsx @@ -66,10 +66,14 @@ const TabNavItem = ({ export function LegislatorTabs({ district, districtLoading, + legislatorId, + name, tabCategory }: { district?: District | undefined districtLoading?: boolean + legislatorId: string + name: string tabCategory?: TabCategories }) { const { t } = useTranslation("legislators") @@ -103,7 +107,7 @@ export function LegislatorTabs({ { title: t("tabs.testimony"), eventKey: "testimony", - content: + content: }, { title: t("tabs.votes"), diff --git a/components/LegislatorProfile/SidebarComponents/Biography.tsx b/components/LegislatorProfile/SidebarComponents/Biography.tsx index e190ac443..cdbed8755 100644 --- a/components/LegislatorProfile/SidebarComponents/Biography.tsx +++ b/components/LegislatorProfile/SidebarComponents/Biography.tsx @@ -1,5 +1,149 @@ -import { TabBlock } from "../LegislatorComponents" +import { useTranslation } from "next-i18next" +import { useEffect, useState } from "react" +import { useForm } from "react-hook-form" +import styled from "styled-components" -export function Biography() { - return - Biography +import { Form } from "../../bootstrap" +import { Profile, ProfileHook, useProfile } from "../../db" +import Input from "../../forms/Input" + +import { useAuth } from "components/auth" +import { + updateProfile, + UpdateProfileData +} from "components/EditProfilePage/PersonalInfoTab" + +const BioBlock = styled.div` + background-color: white; + border-color: #b8c0c9; + border-radius: 5px; + border-style: solid; + border-width: 1px; + font-size: 11px; + padding: 16px; +` + +const BioButton = styled.button` + font-size: 9px; + padding: 2px; +` + +const BioTitle = styled.div` + font-weight: 700; + color: #0b0a3e; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 10px; +` + +export function Biography({ + court, + legislatorData, + legislatorId, + memberCode +}: { + court: number + legislatorData: any[] + legislatorId: string + memberCode: string +}) { + const { user } = useAuth() + const uid = user?.uid + + let pageOwner = false + if (uid === legislatorId) { + pageOwner = true + } + + const userResult = useProfile() + + if (userResult.profile && pageOwner) { + // the user is the legislator who owns this page + // therefore they get edit privledges + return ( + + ) + } + + // the user is not the legislator who owns this page + // therefore they get read-only privledges + return +} + +function EditableBiography({ + actions, + court, + memberCode, + profile +}: { + actions: ProfileHook + court: number + memberCode: string + profile: Profile +}) { + const { + register, + formState: { errors, isDirty }, + handleSubmit + } = useForm() + + const { about }: Profile = profile + + const onSubmit = handleSubmit(async update => { + await updateProfile({ profile, actions }, update) + location.assign(`/legislators/${court}/${memberCode}`) + setFormUpdated(false) + }) + + const { t } = useTranslation("legislators") + const [formUpdated, setFormUpdated] = useState(false) + + useEffect(() => { + setFormUpdated(isDirty) + }, [isDirty, setFormUpdated]) + + return ( + +
+
+ + {t("biography")} + + + {t("submit")} + +
+ +
+
+ ) +} + +function ReadonlyBiography({ legislatorData }: { legislatorData: any[] }) { + const { t } = useTranslation("legislators") + + return ( + + {t("biography")} +
+ {legislatorData[0]?.about ? legislatorData[0].about : t("notClaimed")} +
+
+ ) } diff --git a/components/LegislatorProfile/TabComponents/TestimonyTab.tsx b/components/LegislatorProfile/TabComponents/TestimonyTab.tsx index eb3bc6e04..74f6383fa 100644 --- a/components/LegislatorProfile/TabComponents/TestimonyTab.tsx +++ b/components/LegislatorProfile/TabComponents/TestimonyTab.tsx @@ -1,5 +1,98 @@ +import { useMemo } from "react" +import { useTranslation } from "next-i18next" +import styled from "styled-components" + import { TabBlock } from "../LegislatorComponents" -export function TestimonyTab() { - return - Testimony +import { useAuth } from "components/auth" +import { usePublishedTestimonyListing } from "components/db/testimony/usePublishedTestimonyListing" +import { NoResults } from "components/search/NoResults" +import { TestimonyItem } from "components/TestimonyCard/TestimonyItem" + +const DisclaimerBlock = styled.div` + align-items: flex-start; + background-color: #f0f4ff; + border-color: #b8c0c9; + border-radius: 5px; + border-style: solid; + border-width: 1px; + color: #1a3185; + display: flex; + font-size: 13px; + gap: 10px; + line-height: 1.6; + margin-top: 14px; + margin-bottom: 14px; + padding: 12px 16px; +` + +function Disclaimer({ fullname }: { fullname?: string }) { + const { t } = useTranslation("legislators") + + return ( + + + + + + +
+ {fullname} {t("canSubmit")} +
+
+ ) +} + +export function TestimonyTab({ + legislatorId, + name +}: { + legislatorId: string + name: string +}) { + const { t } = useTranslation("testimony") + const { user } = useAuth() + + const testimony = usePublishedTestimonyListing({ + uid: legislatorId + }) + + const testimonies = useMemo(() => { + const legislatorTestimonies = testimony.items.result ?? [] + + // Sort by publishedAt (newest first), then take 4 most recent + return [...legislatorTestimonies] + .sort((a, b) => b.publishedAt.toMillis() - a.publishedAt.toMillis()) + .slice(0, 4) + }, [testimony.items.result]) + + return ( + <> + + {testimonies.length > 0 && legislatorId ? ( +
+ {testimonies.map(testimony => ( + + + + ))} +
+ ) : ( + + {t("viewTestimony.noTestimonies")} + + )} + + ) } diff --git a/components/LegislatorProfile/TabComponents/TestimonyTab_PendingProfileLink.tsx b/components/LegislatorProfile/TabComponents/TestimonyTab_PendingProfileLink.tsx deleted file mode 100644 index 12b8174fd..000000000 --- a/components/LegislatorProfile/TabComponents/TestimonyTab_PendingProfileLink.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useMemo } from "react" -import { useTranslation } from "next-i18next" -import styled from "styled-components" - -import { TabBlock } from "../LegislatorComponents" - -import { useAuth } from "components/auth" -import { usePublishedTestimonyListing } from "components/db/testimony/usePublishedTestimonyListing" -import { NoResults } from "components/search/NoResults" -import { TestimonyItem } from "components/TestimonyCard/TestimonyItem" - -const DisclaimerBlock = styled.div` - align-items: flex-start; - background-color: #f0f4ff; - border: "1px #d1d6e7 solid"; - border-radius: 5px; - color: #1a3185; - display: flex; - font-size: 13px; - gap: 10px; - line-height: 1.6; - margin-top: 14px; - margin-bottom: 14px; - padding: 12px 16px; -` - -function Disclaimer({ fullname }: { fullname?: string }) { - const { t } = useTranslation("legislators") - - return ( - - - - - - -
- {fullname} {t("canSubmit")} -
-
- ) -} - -export function Testimony({ - fullname, - pageId -}: { - fullname?: string - pageId?: string -}) { - const { t } = useTranslation("testimony") - const { user } = useAuth() - - const testimony = usePublishedTestimonyListing({ - uid: pageId - }) - - const allTestimonies = useMemo(() => { - const legislatorTestimonies = testimony.items.result ?? [] - - // Combine and sort by publishedAt (newest first), then take 4 most recent - return [...legislatorTestimonies] - .sort((a, b) => b.publishedAt.toMillis() - a.publishedAt.toMillis()) - .slice(0, 4) - }, [testimony.items.result]) - - return ( - <> - - {allTestimonies.length > 0 ? ( -
- {allTestimonies.map(testimony => ( - - - - ))} -
- ) : ( - {t("viewTestimony.noTestimonies")} - )} - - ) -} diff --git a/components/db/profile/urlCleanup.ts b/components/db/profile/urlCleanup.ts index 26d09b13a..f2fe17e5c 100644 --- a/components/db/profile/urlCleanup.ts +++ b/components/db/profile/urlCleanup.ts @@ -7,7 +7,7 @@ export function cleanSocialLinks(network: keyof SocialLinks, link: string) { const index: number = path.indexOf(".com/") + 5 path = path.substring(index) } - if (network === "mastodon" && path.startsWith("@")) { + if (path && network === "mastodon" && path.startsWith("@")) { path = path.substring(1) } } diff --git a/public/locales/en/legislators.json b/public/locales/en/legislators.json index 886f6a1ba..4a9e69920 100644 --- a/public/locales/en/legislators.json +++ b/public/locales/en/legislators.json @@ -1,13 +1,17 @@ { + "addBio": "Add your biography", "billsSponsored": "Bills Sponsored", - "canSubmit": "can submit testimony on any bill or ballot question, just like any constituent. Her stance and reasoning are shown exactly as submitted — MAPLE does not edit or editorialize.", + "biography": "Biography", + "canSubmit": "can submit testimony on any bill or ballot question, just like any constituent. Their stance and reasoning are shown exactly as submitted — MAPLE does not edit or editorialize.", "contact": "Contact", "cosponsored": "Cosponsored", "districtDetails": "District details are not available yet.", + "editBio": "Edit your biography", "fundsRaised": "Funds Raised", "home": "Home", "legislators": "Legislators", "loading": "Loading district...", + "notClaimed": "This legislator has not claimed their MAPLE account", "party": { "democratic": "Democratic Party", "party": "Party", @@ -15,6 +19,7 @@ }, "stateRepresentative": "State Representative", "stateSenator": "State Senator", + "submit": "Submit", "tabs": { "bills": "Bills", "district": "District", From 91d239d53a696ff7d021438df91cac9c3d088d1b Mon Sep 17 00:00:00 2001 From: Janice Lichtman Date: Tue, 21 Jul 2026 22:16:00 -0400 Subject: [PATCH 092/106] Add OCPF campaign finance scraper and Finance tab (#2195) --- .../LegislatorProfilePage.tsx | 9 +- .../LegislatorProfile/LegislatorTabs.tsx | 7 +- .../TabComponents/FinanceTab.tsx | 293 ++++++++- components/db/membersFinance.ts | 89 +++ functions/src/index.ts | 1 + functions/src/ocpf/scrapeOcpfFinance.ts | 597 ++++++++++++++++++ functions/src/ocpf/types.ts | 54 ++ public/locales/en/legislators.json | 34 + 8 files changed, 1078 insertions(+), 6 deletions(-) create mode 100644 components/db/membersFinance.ts create mode 100644 functions/src/ocpf/scrapeOcpfFinance.ts diff --git a/components/LegislatorProfile/LegislatorProfilePage.tsx b/components/LegislatorProfile/LegislatorProfilePage.tsx index 3e02284ef..24ea34433 100644 --- a/components/LegislatorProfile/LegislatorProfilePage.tsx +++ b/components/LegislatorProfile/LegislatorProfilePage.tsx @@ -24,6 +24,7 @@ import { LegislatorTabs } from "./LegislatorTabs" import { useAuth } from "components/auth" import { Col, Container, Row, Spinner } from "components/bootstrap" import { useDistrict, useMember } from "components/db" +import { useMembersFinance } from "components/db/membersFinance" import { Internal } from "components/links" import { FollowUserButton } from "components/shared/FollowButton" import { CircleImage } from "components/shared/LabeledIcon" @@ -116,6 +117,7 @@ export function LegislatorProfilePage({ member?.Branch, member?.District ) + const { finance } = useMembersFinance(court, memberCode) const { t } = useTranslation("legislators") const [legislatorId, setLegislatorId] = useState("") @@ -352,7 +354,11 @@ export function LegislatorProfilePage({ - ? + + {finance?.totalRaised != null + ? "$" + Math.round(finance.totalRaised).toLocaleString() + : "?"} + {t("fundsRaised")} @@ -365,6 +371,7 @@ export function LegislatorProfilePage({ districtLoading={districtLoading} legislatorId={legislatorId} name={member.Name} + finance={finance} /> diff --git a/components/LegislatorProfile/LegislatorTabs.tsx b/components/LegislatorProfile/LegislatorTabs.tsx index 6f2791f19..f0d91c148 100644 --- a/components/LegislatorProfile/LegislatorTabs.tsx +++ b/components/LegislatorProfile/LegislatorTabs.tsx @@ -19,6 +19,7 @@ import { TabNavWrapper, TabType } from "components/EditProfilePage/StyledEditProfileComponents" +import { MembersFinance } from "components/db/membersFinance" const tabCategory = [ "priorities", @@ -68,13 +69,15 @@ export function LegislatorTabs({ districtLoading, legislatorId, name, - tabCategory + tabCategory, + finance }: { district?: District | undefined districtLoading?: boolean legislatorId: string name: string tabCategory?: TabCategories + finance?: MembersFinance }) { const { t } = useTranslation("legislators") @@ -97,7 +100,7 @@ export function LegislatorTabs({ { title: t("tabs.finance"), eventKey: "finance", - content: + content: }, { title: t("tabs.district"), diff --git a/components/LegislatorProfile/TabComponents/FinanceTab.tsx b/components/LegislatorProfile/TabComponents/FinanceTab.tsx index 4c263d26a..a88f97112 100644 --- a/components/LegislatorProfile/TabComponents/FinanceTab.tsx +++ b/components/LegislatorProfile/TabComponents/FinanceTab.tsx @@ -1,5 +1,292 @@ -import { TabBlock } from "../LegislatorComponents" +import { useTranslation } from "next-i18next" +import { MembersFinance } from "components/db/membersFinance" -export function FinanceTab() { - return - Finance +function formatCurrency(n?: number | null): string { + if (n == null || isNaN(n)) return "$0" + return "$" + Math.round(n).toLocaleString() +} + +function formatPct(value: number, total: number): string { + if (total === 0) return "0%" + return Math.round((value / total) * 100) + "%" +} + +interface CategoryRow { + name: string + value: number +} + +export function FinanceTab({ finance }: { finance?: MembersFinance }) { + const { t } = useTranslation("legislators") + + if (!finance) { + return

{t("finance.noData")}

+ } + + const inKindTotal = + (finance.inKind?.individual?.amount ?? 0) + + (finance.inKind?.committee?.amount ?? 0) + + (finance.inKind?.union?.amount ?? 0) + + (finance.inKind?.unitemized?.amount ?? 0) + + const candidateFundsTotal = + (finance.candidateFunds?.loans?.amount ?? 0) + + (finance.candidateFunds?.contributions?.amount ?? 0) + + const categories: CategoryRow[] = [ + { + name: t("finance.breakdown.individual"), + value: finance.breakdown?.individual?.amount ?? 0 + }, + { + name: t("finance.breakdown.committee"), + value: finance.breakdown?.committee?.amount ?? 0 + }, + { + name: t("finance.breakdown.union"), + value: finance.breakdown?.union?.amount ?? 0 + }, + { + name: t("finance.breakdown.unitemized"), + value: finance.breakdown?.unitemized?.amount ?? 0 + }, + { name: t("finance.breakdown.candidateFunds"), value: candidateFundsTotal }, + { name: t("finance.breakdown.inKind"), value: inKindTotal } + ] + .filter(c => c.value > 0) + .sort((a, b) => b.value - a.value) + + const total = categories.reduce((sum, c) => sum + c.value, 0) + const nonContribution = finance.otherReceipts?.nonContribution?.amount ?? 0 + const processingFees = finance.breakdown?.processingFees?.amount ?? 0 + + const smallDonorTotal = + (finance.breakdown?.smallDonors?.itemized?.amount ?? 0) + + (finance.breakdown?.unitemized?.amount ?? 0) + + const cycleYears = Object.keys(finance.years ?? {}).map(Number) + const cycleYear = cycleYears.length + ? Math.max(...cycleYears) + : new Date().getFullYear() + + const totalSpentAsOf = finance.lastUpdated + ? finance.lastUpdated + .toDate() + .toLocaleDateString("en-US", { month: "short", year: "numeric" }) + : "" + + const formatFullDate = (ts?: { toDate: () => Date }) => + ts + ? ts.toDate().toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric" + }) + : "" + + const bankDataAsOf = formatFullDate(finance.bankDataAsOf) + const depositDataAsOf = formatFullDate(finance.depositDataAsOf) + + const statBoxes = [ + { + label: t("finance.stats.totalRaised"), + value: formatCurrency(finance.totalRaised), + subtitle: t("finance.stats.totalRaisedSubtitle", { + count: finance.contributorCount ?? 0 + }), + footnote: t("finance.stats.totalRaisedFootnote", { date: bankDataAsOf }) + }, + { + label: t("finance.stats.totalSpent"), + value: formatCurrency(finance.totalSpent), + subtitle: t("finance.stats.totalSpentSubtitle", { date: totalSpentAsOf }), + footnote: undefined as string | undefined + }, + { + label: t("finance.stats.smallDonors"), + value: formatPct(smallDonorTotal, total), + subtitle: t("finance.stats.smallDonorsSubtitle"), + footnote: undefined as string | undefined + }, + { + label: t("finance.stats.cashOnHand"), + value: formatCurrency(finance.cashOnHand), + subtitle: t("finance.stats.cashOnHandSubtitle"), + footnote: undefined as string | undefined + } + ] + + return ( +
+
+ {t("finance.electionCycleHeading", { year: cycleYear })} +
+ +
+ {statBoxes.map(({ label, value, subtitle, footnote }) => ( +
+
+
+ {value} +
+
+ {label} +
+
+ {subtitle} +
+ {footnote && ( +
+ {footnote} +
+ )} +
+
+ ))} +
+ +
+ {t("finance.breakdownHeading")} +
+ + {categories.length === 0 ? ( +

{t("finance.noContributions")}

+ ) : ( +
+
+ {t("finance.table.category")} +
+ + {t("finance.table.amount")} + + + {t("finance.table.share")} + +
+
+ + {categories.map((c, i) => ( +
+ {c.name} +
+ + {formatCurrency(c.value)} + + + + {formatPct(c.value, total)} + + +
+
+ ))} + +

+ {depositDataAsOf + ? t("finance.sourceWithDate", { date: depositDataAsOf }) + : t("finance.source")} +

+
+ )} + + {nonContribution > 0 && ( +

+ {t("finance.otherReceipts", { + amount: formatCurrency(nonContribution) + })} +

+ )} + + {processingFees > 0 && ( +

+ {t("finance.processingFees", { + amount: formatCurrency(processingFees) + })} +

+ )} +
+ ) } diff --git a/components/db/membersFinance.ts b/components/db/membersFinance.ts new file mode 100644 index 000000000..7b01ae19a --- /dev/null +++ b/components/db/membersFinance.ts @@ -0,0 +1,89 @@ +import { Timestamp } from "firebase/firestore" +import { useMemo } from "react" +import { useAsync } from "react-async-hook" +import { loadDoc } from "./common" + +// Mirror of functions/src/ocpf/types.ts MembersFinance, using client-side Timestamp +export interface FinanceBreakdownEntry { + count: number + amount: number +} + +export interface MembersFinanceBreakdown { + individual: FinanceBreakdownEntry + committee: FinanceBreakdownEntry + union: FinanceBreakdownEntry + unitemized: { amount: number } + // Subset of `individual` — itemized (type 201) contributions under $200. + // Combined with `unitemized` on the frontend for the "Small Donors" stat. + smallDonors?: { + itemized: FinanceBreakdownEntry + } + // type 319 — payment-processor fees deducted between the gross Deposit + // Report amount (reflected in individual/committee/union above) and the + // net Bank Report amount (reflected in totalRaised). Not a contribution + // category; used only to explain the gap between the two on the frontend. + processingFees?: FinanceBreakdownEntry +} + +export interface MembersFinanceCandidateFunds { + loans: FinanceBreakdownEntry + contributions: FinanceBreakdownEntry +} + +export interface MembersFinanceInKind { + individual: FinanceBreakdownEntry + committee: FinanceBreakdownEntry + union: FinanceBreakdownEntry + unitemized: { amount: number } +} + +export interface MembersFinanceOtherReceipts { + nonContribution: FinanceBreakdownEntry +} + +export interface MembersFinanceYearData { + totalRaised: number + totalSpent: number + breakdown: MembersFinanceBreakdown + finalized: boolean +} + +export interface MembersFinance { + ocpfCpfId: number + totalRaised: number + totalSpent: number + cashOnHand: number + contributorCount: number + lastUpdated: Timestamp + // End_Date of the most recent Bank Report (type 70) — the basis for totalRaised/cashOnHand. + bankDataAsOf?: Timestamp + // End_Date of the most recent Deposit Report (type 60) — the basis for the + // breakdown categories. Normally later than bankDataAsOf, since Deposit + // Reports are filed more frequently than Bank Reports. + depositDataAsOf?: Timestamp + breakdown: MembersFinanceBreakdown + candidateFunds: MembersFinanceCandidateFunds + inKind: MembersFinanceInKind + otherReceipts: MembersFinanceOtherReceipts + years: Record +} + +export function useMembersFinance(court: number, memberId?: string) { + const { loading, result, error } = useAsync(getFinance, [court, memberId]) + return useMemo( + () => ({ finance: result, loading, error }), + [loading, result, error] + ) +} + +async function getFinance( + court: number, + memberId?: string +): Promise { + if (!memberId) return undefined + const data = await loadDoc( + `/generalCourts/${court}/membersFinance/${memberId}` + ) + return data as MembersFinance | undefined +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 95b515dd3..54ec1a6cf 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -62,6 +62,7 @@ export { export { transcription } from "./webhooks" export { matchOcpfMembers } from "./ocpf/matchOcpfMembers" +export { scrapeOcpfFinance } from "./ocpf/scrapeOcpfFinance" export * from "./triggerPubsubFunction" diff --git a/functions/src/ocpf/scrapeOcpfFinance.ts b/functions/src/ocpf/scrapeOcpfFinance.ts new file mode 100644 index 000000000..d4a18409a --- /dev/null +++ b/functions/src/ocpf/scrapeOcpfFinance.ts @@ -0,0 +1,597 @@ +// TODO: After validating output against the OCPF website, flip to: +// export const scrapeOcpfFinance = functions.pubsub.schedule("every 24 hours").onRun(...) +import * as functions from "firebase-functions" +import { getAuth } from "firebase-admin/auth" +import axios from "axios" +import unzipper from "unzipper" +import * as readline from "readline" +import { db, Timestamp } from "../firebase" +import { currentGeneralCourt } from "../shared" +import { + OcpfMemberMapping, + MembersFinance, + MembersFinanceBreakdown, + MembersFinanceCandidateFunds, + MembersFinanceInKind, + MembersFinanceOtherReceipts, + MembersFinanceYearData +} from "./types" + +// Set to null to run all members. Use individual CPF_ID to validate single-member output. +const TEST_CPF_ID: number | null = null // For testing, can use 16883, Rebecca L. Rausch, RLR0 + +const OCPF_BASE_URL = "https://ocpf2.blob.core.windows.net/downloads/data2" + +const YEARS = ["2025", "2026"] + +// Annual rollup report types to be excluded. +// Including these would double-count both receipts and expeditures. +// Note: 32, 36, 45, and 52 not relevant to individual legislators, but not harmful to exclude +const YEAR_END_REPORT_TYPE_IDS = new Set([ + 11, // Year-End Report (Depository) + 24, // Year-End Report (Non-Depository) + 32, // Year-End Report (PAC) + 36, // IEPAC Year-End Report + 45, // Year-End Report (Ballot Question Committee) + 52, // Year-End Report (Local Party Committee) + 113 // Year-End Report (Municipal) +]) + +// ── Accumulator types ───────────────────────────────────────────────────────── + +interface MutableBreakdownEntry { + count: number + amount: number +} + +interface MemberAccumulator { + cpfId: number + totalRaised: number + totalSpent: number + cashOnHand: number + cashOnHandEndDateMs: number // End_Date (as ms) of the most recent Bank Report (type 70) seen + depositEndDateMs: number // End_Date (as ms) of the most recent Deposit Report (type 60) seen + contributorCount: number + breakdown: { + individual: MutableBreakdownEntry + committee: MutableBreakdownEntry + union: MutableBreakdownEntry + unitemized: { amount: number } + smallDonors: { itemized: MutableBreakdownEntry } + processingFees: MutableBreakdownEntry + } + candidateFunds: { + loans: MutableBreakdownEntry + contributions: MutableBreakdownEntry + } + inKind: { + individual: MutableBreakdownEntry + committee: MutableBreakdownEntry + union: MutableBreakdownEntry + unitemized: { amount: number } + } + otherReceipts: { + nonContribution: MutableBreakdownEntry + } + years: Record< + string, + { + totalRaised: number + totalSpent: number + breakdown: { + individual: MutableBreakdownEntry + committee: MutableBreakdownEntry + union: MutableBreakdownEntry + unitemized: { amount: number } + smallDonors: { itemized: MutableBreakdownEntry } + processingFees: MutableBreakdownEntry + } + } + > + yearEndCheck: Record< + string, + { receiptsTotal: number; expendituresTotal: number } | null + > +} + +function emptyEntry(): MutableBreakdownEntry { + return { count: 0, amount: 0 } +} + +function newAccumulator(cpfId: number): MemberAccumulator { + const yearInit = () => ({ + totalRaised: 0, + totalSpent: 0, + breakdown: { + individual: emptyEntry(), + committee: emptyEntry(), + union: emptyEntry(), + unitemized: { amount: 0 }, + smallDonors: { itemized: emptyEntry() }, + processingFees: emptyEntry() + } + }) + return { + cpfId, + totalRaised: 0, + totalSpent: 0, + cashOnHand: 0, + cashOnHandEndDateMs: 0, + depositEndDateMs: 0, + contributorCount: 0, + breakdown: { + individual: emptyEntry(), + committee: emptyEntry(), + union: emptyEntry(), + unitemized: { amount: 0 }, + smallDonors: { itemized: emptyEntry() }, + processingFees: emptyEntry() + }, + candidateFunds: { loans: emptyEntry(), contributions: emptyEntry() }, + inKind: { + individual: emptyEntry(), + committee: emptyEntry(), + union: emptyEntry(), + unitemized: { amount: 0 } + }, + otherReceipts: { nonContribution: emptyEntry() }, + years: Object.fromEntries(YEARS.map(y => [y, yearInit()])), + yearEndCheck: Object.fromEntries(YEARS.map(y => [y, null])) + } +} + +// ── Cloud Function ──────────────────────────────────────────────────────────── + +export const scrapeOcpfFinance = functions + .runWith({ timeoutSeconds: 540, memory: "512MB" }) + .https.onRequest(async (req, res) => { + if (req.method !== "POST") { + res.status(405).send("Method Not Allowed. Use POST.") + return + } + if (process.env.FUNCTIONS_EMULATOR !== "true") { + const authHeader = req.headers.authorization + if (!authHeader?.startsWith("Bearer ")) { + res.status(401).send("Unauthorized") + return + } + try { + const decoded = await getAuth().verifyIdToken(authHeader.slice(7)) + if (decoded["role"] !== "admin") { + res.status(403).send("Forbidden") + return + } + } catch { + res.status(401).send("Unauthorized") + return + } + } + + // ── A. Load member mapping ───────────────────────────────────────────── + const mappingDoc = await db.doc("/config/ocpfMemberMapping").get() + const mapping = (mappingDoc.data() ?? {}) as OcpfMemberMapping + + // Build cpfId → memberCode reverse map + let cpfIdToMemberCode = new Map( + Object.entries(mapping).map(([memberCode, entry]) => [ + entry.cpfId, + memberCode + ]) + ) + + if (TEST_CPF_ID !== null) { + cpfIdToMemberCode = new Map( + [...cpfIdToMemberCode.entries()].filter( + ([cpfId]) => cpfId === TEST_CPF_ID + ) + ) + functions.logger.info("TEST MODE: filtering to single member", { + cpfId: TEST_CPF_ID + }) + } + + functions.logger.info("Loaded member mapping", { + totalMembers: Object.keys(mapping).length, + activeInRun: cpfIdToMemberCode.size + }) + + // ── B. Download each year's ZIP; parse reports.txt then report-items.txt ── + const accumulators = new Map() + + // reportId → memberCode, for joining with report-items + const reportIdToMemberCode = new Map() + + for (const year of YEARS) { + const url = `${OCPF_BASE_URL}/ocpf-${year}-reports.zip` + functions.logger.info(`Downloading ${url}`) + const buf = await downloadBuffer(url) + await parseReports( + buf, + year, + cpfIdToMemberCode, + accumulators, + reportIdToMemberCode + ) + + functions.logger.info(`Streaming report-items for ${year}`) + await streamReportItems(buf, reportIdToMemberCode, accumulators, year) + } + + // ── Reconciliation: year-end report vs. summed periodic totals ──────── + // Year-end reports (type 11, etc.) are excluded from accumulation because + // their Receipts_Total/Expenditures_Total are annual rollups that duplicate + // the periodic (Bank Report) totals. + // This check runs only once a year-end report exists (i.e. + // after the calendar year closes) and compares it against what we've summed. + // + // Verified empirically (CPF 16883, 2025): summed Bank Report totals matched + // the Year-End Report exactly ($104,770.60), confirming the type-60 skip + // logic below does not double-count totalRaised/totalSpent. A mismatch here + // would point to some other report type being mis-handled — do not ignore + // it, investigate before trusting the displayed totals. + // + // Note: this only reconciles totalRaised/totalSpent (report-level totals). + // It does NOT catch the separate, known gap between totalRaised (Bank + // Report, after payment-processor fees) and the Contribution Breakdown + // categories total (Deposit Report items, before deduction of fees). This gap is + // found by record type 319 (processing fees), which + // is currently unhandled in accumulateItem's switch. + for (const [memberCode, acc] of accumulators) { + for (const year of YEARS) { + const check = acc.yearEndCheck[year] + if (!check) continue + const summedRaised = acc.years[year]?.totalRaised ?? 0 + const summedSpent = acc.years[year]?.totalSpent ?? 0 + const raisedDiff = Math.abs(check.receiptsTotal - summedRaised) + const spentDiff = Math.abs(check.expendituresTotal - summedSpent) + if (raisedDiff > 0.02 || spentDiff > 0.02) { + functions.logger.warn( + "Year-end totals mismatch — investigate periodic report accumulation", + { + memberCode, + year, + yearEnd: { + receiptsTotal: check.receiptsTotal, + expendituresTotal: check.expendituresTotal + }, + summed: { + receiptsTotal: summedRaised, + expendituresTotal: summedSpent + }, + diff: { receipts: raisedDiff, expenditures: spentDiff } + } + ) + } + } + } + + // ── C. Write Firestore docs ─────────────────────────────────────────── + const now = Timestamp.now() + // Firestore batches are limited to 500 operations. MA general courts have ~200 members + // so this is fine, but if we ever exceed 500 members this will need to be chunked. + const batch = db.batch() + + for (const [memberCode, acc] of accumulators) { + const doc = db.doc( + `/generalCourts/${currentGeneralCourt}/membersFinance/${memberCode}` + ) + const data: MembersFinance = { + ocpfCpfId: acc.cpfId, + totalRaised: acc.totalRaised, + totalSpent: acc.totalSpent, + cashOnHand: acc.cashOnHand, + contributorCount: acc.contributorCount, + lastUpdated: now, + bankDataAsOf: Timestamp.fromMillis(acc.cashOnHandEndDateMs), + depositDataAsOf: Timestamp.fromMillis(acc.depositEndDateMs), + breakdown: acc.breakdown as MembersFinanceBreakdown, + candidateFunds: acc.candidateFunds as MembersFinanceCandidateFunds, + inKind: acc.inKind as MembersFinanceInKind, + otherReceipts: acc.otherReceipts as MembersFinanceOtherReceipts, + years: Object.fromEntries( + Object.entries(acc.years).map(([y, yd]) => [ + y, + { + totalRaised: yd.totalRaised, + totalSpent: yd.totalSpent, + breakdown: yd.breakdown as MembersFinanceBreakdown, + finalized: acc.yearEndCheck[y] !== null + } as MembersFinanceYearData + ]) + ) + } + batch.set(doc, data) + } + + await batch.commit() + + functions.logger.info("scrapeOcpfFinance complete", { + processed: accumulators.size, + years: YEARS + }) + + res.status(200).json({ + results: { processed: accumulators.size, years: YEARS } + }) + }) + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async function downloadBuffer(url: string): Promise { + const response = await axios.get(url, { responseType: "arraybuffer" }) + return Buffer.from(response.data as ArrayBuffer) +} + +const REPORT_COLUMN_ALIASES: Record = { + cpfId: ["cpf_id"], + reportId: ["report_id"], + reportTypeId: ["report_type_id"], + receiptsTotal: ["receipts_total"], + receiptsUnitemizedTotal: ["receipts_unitemized_total"], + expendituresTotal: ["expenditures_total"], + endBalance: ["end_balance"], + endDate: ["end_date"] +} + +const ITEM_COLUMN_ALIASES: Record = { + reportId: ["report_id"], + recordTypeId: ["record_type_id"], + amount: ["amount"] +} + +function buildIndex( + headers: string[], + aliases: Record +): Record { + const normalized = headers.map(h => h.toLowerCase().replace(/\s+/g, "_")) + const index: Record = {} + for (const [field, aliasList] of Object.entries(aliases)) { + for (const alias of aliasList) { + const i = normalized.indexOf(alias) + if (i !== -1) { + index[field] = i + break + } + } + if (!(field in index)) { + throw new Error( + `Required column '${field}' not found. Headers: ${headers.join(", ")}` + ) + } + } + return index +} + +function col(cols: string[], idx: number): string { + return (cols[idx] ?? "").trim().replace(/^"|"$/g, "") +} + +async function parseReports( + buf: Buffer, + year: string, + cpfIdToMemberCode: Map, + accumulators: Map, + reportIdToMemberCode: Map +): Promise { + const directory = await unzipper.Open.buffer(buf) + const entry = directory.files.find( + f => f.type === "File" && f.path.toLowerCase() === "reports.txt" + ) + if (!entry) throw new Error(`reports.txt not found in zip`) + + const text = (await entry.buffer()).toString("utf8") + const lines = text.split(/\r?\n/) + const rawHeaders = lines[0].split("\t").map(h => h.trim()) + const idx = buildIndex(rawHeaders, REPORT_COLUMN_ALIASES) + + let matched = 0 + for (let i = 1; i < lines.length; i++) { + const line = lines[i] + if (!line.trim()) continue + + const cols = line.split("\t") + const cpfId = parseInt(col(cols, idx.cpfId), 10) + const memberCode = cpfIdToMemberCode.get(cpfId) + if (!memberCode) continue + + const reportId = parseInt(col(cols, idx.reportId), 10) + if (isNaN(reportId)) continue + const reportTypeId = parseInt(col(cols, idx.reportTypeId), 10) + const receiptsTotal = parseFloat(col(cols, idx.receiptsTotal)) || 0 + const receiptsUnitemized = + parseFloat(col(cols, idx.receiptsUnitemizedTotal)) || 0 + const expendituresTotal = parseFloat(col(cols, idx.expendituresTotal)) || 0 + const endBalance = parseFloat(col(cols, idx.endBalance)) || 0 + + let acc = accumulators.get(memberCode) + if (!acc) { + acc = newAccumulator(cpfId) + accumulators.set(memberCode, acc) + } + + if (YEAR_END_REPORT_TYPE_IDS.has(reportTypeId)) { + // Annual rollup — skip accumulation to avoid double-counting the periodic + // totals already summed above. Store for the reconciliation check below. + if (acc.yearEndCheck[year] === null) { + acc.yearEndCheck[year] = { receiptsTotal, expendituresTotal } + } + matched++ + continue + } + + // Credit Card (type 80) and Reimbursement (type 90) reports are supplemental: + // they itemize spending that the depository bank already captured as bank + // transactions in the monthly Bank Report's Expenditures_Total. Verified + // empirically — for two members the sum of all Bank Reports matched the + // Year-End Report exactly without including type 80/90 amounts, confirming + // their Expenditures_Total is already counted. Their items (354 Credit Card + // Sub-Items, 351 Reimbursement Sub-Items) are not used by accumulateItem(), + // so there is no reason to register their report IDs. + if (reportTypeId === 80 || reportTypeId === 90) { + matched++ + continue + } + + reportIdToMemberCode.set(reportId, memberCode) + + // Deposit Reports (type 60) carry gross contribution amounts (before fees). + // The same money appears net-of-fees in Bank Report (type 70) Receipts_Total, + // so adding both would double-count. Skip the totals for type 60 but keep + // its report_id registered (for 201/202/203/204 item-level breakdown) and + // its unitemized amount (Bank Reports have blank for that field). + if (reportTypeId !== 60) { + acc.totalRaised += receiptsTotal + acc.totalSpent += expendituresTotal + } + acc.breakdown.unitemized.amount += receiptsUnitemized + + if (acc.years[year]) { + if (reportTypeId !== 60) { + acc.years[year].totalRaised += receiptsTotal + acc.years[year].totalSpent += expendituresTotal + } + acc.years[year].breakdown.unitemized.amount += receiptsUnitemized + } + + // Report_Type_ID 70 = Bank Report — use End_Balance from the report with the latest End_Date + const endDate = col(cols, idx.endDate) + const endDateMs = endDate ? new Date(endDate).getTime() : 0 + if (reportTypeId === 70 && endDateMs > acc.cashOnHandEndDateMs) { + acc.cashOnHand = endBalance + acc.cashOnHandEndDateMs = endDateMs + } + // Deposit Reports are filed more frequently than Bank Reports, so this + // date is normally later — tracked to show readers why the Contributions + // Breakdown (sourced from Deposit Reports) and Total Raised (sourced from + // Bank Reports) can reflect different "as of" dates. + if (reportTypeId === 60 && endDateMs > acc.depositEndDateMs) { + acc.depositEndDateMs = endDateMs + } + + matched++ + } + + functions.logger.info(`Parsed reports.txt for ${year}`, { matched }) +} + +async function streamReportItems( + buf: Buffer, + reportIdToMemberCode: Map, + accumulators: Map, + year: string +): Promise { + const directory = await unzipper.Open.buffer(buf) + const entry = directory.files.find( + f => f.type === "File" && f.path.toLowerCase() === "report-items.txt" + ) + if (!entry) throw new Error(`report-items.txt not found in zip`) + + const stream = entry.stream() + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }) + + let isFirst = true + let idx: Record = {} + let processed = 0 + let skipped = 0 + + for await (const line of rl) { + if (!line.trim()) continue + + if (isFirst) { + const rawHeaders = line.split("\t").map(h => h.trim()) + idx = buildIndex(rawHeaders, ITEM_COLUMN_ALIASES) + isFirst = false + continue + } + + const cols = line.split("\t") + const reportId = parseInt(col(cols, idx.reportId), 10) + const memberCode = reportIdToMemberCode.get(reportId) + if (!memberCode) { + skipped++ + continue + } + + const acc = accumulators.get(memberCode) + if (!acc) { + skipped++ + continue + } + + const recordTypeId = parseInt(col(cols, idx.recordTypeId), 10) + const amount = parseFloat(col(cols, idx.amount)) || 0 + + accumulateItem(acc, recordTypeId, amount, year) + processed++ + } + + functions.logger.info(`Streamed report-items.txt for ${year}`, { + processed, + skipped + }) +} + +function accumulateItem( + acc: MemberAccumulator, + recordTypeId: number, + amount: number, + year: string +): void { + const addTo = ( + entry: MutableBreakdownEntry, + alsoYearBreakdown?: MutableBreakdownEntry + ) => { + entry.count++ + entry.amount += amount + if (alsoYearBreakdown) { + alsoYearBreakdown.count++ + alsoYearBreakdown.amount += amount + } + } + + const yb = acc.years[year]?.breakdown + + switch (recordTypeId) { + case 201: // Individual Contribution + addTo(acc.breakdown.individual, yb?.individual) + acc.contributorCount++ + if (amount < 200) { + addTo(acc.breakdown.smallDonors.itemized, yb?.smallDonors?.itemized) + } + break + case 202: // Committee Contribution + addTo(acc.breakdown.committee, yb?.committee) + break + case 203: // Union/Association Contribution + addTo(acc.breakdown.union, yb?.union) + break + case 204: // Non-contribution receipt + addTo(acc.otherReceipts.nonContribution) + break + case 206: // Candidate Loan + case 331: // Out-of-pocket expense (as loan) + addTo(acc.candidateFunds.loans) + break + case 332: // Out-of-pocket expense (as contribution) + addTo(acc.candidateFunds.contributions) + break + case 401: // Individual In-kind + addTo(acc.inKind.individual) + break + case 402: // Committee In-kind + addTo(acc.inKind.committee) + break + case 403: // Union In-kind + addTo(acc.inKind.union) + break + case 420: // Aggregated un-itemized in-kind + acc.inKind.unitemized.amount += amount + break + case 319: // Payment-processor fee (see breakdown.processingFees doc comment) + addTo(acc.breakdown.processingFees, yb?.processingFees) + break + // 205 (Bank Interest) and 220 (Aggregated un-itemized) come from reports.txt, not items + default: + break + } +} diff --git a/functions/src/ocpf/types.ts b/functions/src/ocpf/types.ts index ae61e2ce9..5ae323d1d 100644 --- a/functions/src/ocpf/types.ts +++ b/functions/src/ocpf/types.ts @@ -38,4 +38,58 @@ export interface MembersFinanceBreakdown { committee: FinanceBreakdownEntry union: FinanceBreakdownEntry unitemized: { amount: number } + // Subset of `individual` — itemized (type 201) contributions under $200. + // Combined with `unitemized` on the frontend for the "Small Donors" stat. + smallDonors?: { + itemized: FinanceBreakdownEntry + } + // type 319 — payment-processor fees deducted between the gross Deposit + // Report amount (reflected in individual/committee/union above) and the + // net Bank Report amount (reflected in totalRaised). Not a contribution + // category; used only to explain the gap between the two on the frontend. + processingFees?: FinanceBreakdownEntry +} + +export interface MembersFinanceCandidateFunds { + loans: FinanceBreakdownEntry // types 206 + 331 + contributions: FinanceBreakdownEntry // type 332 +} + +export interface MembersFinanceInKind { + individual: FinanceBreakdownEntry // type 401 + committee: FinanceBreakdownEntry // type 402 + union: FinanceBreakdownEntry // type 403 + unitemized: { amount: number } // type 420 +} + +export interface MembersFinanceOtherReceipts { + nonContribution: FinanceBreakdownEntry // type 204 +} + +export interface MembersFinanceYearData { + totalRaised: number + totalSpent: number + breakdown: MembersFinanceBreakdown + finalized: boolean +} + +// Firestore: /generalCourts/{court}/membersFinance/{memberCode} +export interface MembersFinance { + ocpfCpfId: number + totalRaised: number + totalSpent: number + cashOnHand: number + contributorCount: number // count of type-201 rows (row = one itemized contribution) + lastUpdated: FirebaseFirestore.Timestamp + // End_Date of the most recent Bank Report (type 70) — the basis for totalRaised/cashOnHand. + bankDataAsOf?: FirebaseFirestore.Timestamp + // End_Date of the most recent Deposit Report (type 60) — the basis for the + // breakdown categories. Normally later than bankDataAsOf, since Deposit + // Reports are filed more frequently than Bank Reports. + depositDataAsOf?: FirebaseFirestore.Timestamp + breakdown: MembersFinanceBreakdown + candidateFunds: MembersFinanceCandidateFunds + inKind: MembersFinanceInKind + otherReceipts: MembersFinanceOtherReceipts + years: Record } diff --git a/public/locales/en/legislators.json b/public/locales/en/legislators.json index 4a9e69920..0714b478b 100644 --- a/public/locales/en/legislators.json +++ b/public/locales/en/legislators.json @@ -7,6 +7,40 @@ "cosponsored": "Cosponsored", "districtDetails": "District details are not available yet.", "editBio": "Edit your biography", + "finance": { + "breakdown": { + "candidateFunds": "Candidate's own funds", + "committee": "Committees / PACs", + "inKind": "In-kind contributions", + "individual": "Individual donors", + "union": "Unions & associations", + "unitemized": "Micro-donations (under $50)" + }, + "breakdownHeading": "Contributions Breakdown", + "electionCycleHeading": "{{year}} Election Cycle", + "noContributions": "No itemized contributions recorded yet.", + "noData": "Finance data not yet available for this legislator.", + "otherReceipts": "Other non-contribution receipts: {{amount}} (refunds and miscellaneous receipts — not included above)", + "processingFees": "Category amounts above are shown before {{amount}} in payment-processing fees deducted, which is why they total more than \"Total Raised\" above.", + "source": "Source: MA OCPF · ocpf.us", + "sourceWithDate": "Source: MA OCPF · ocpf.us\nIncludes contributions through {{date}}", + "stats": { + "cashOnHand": "Cash on Hand", + "cashOnHandSubtitle": "End of reporting period", + "smallDonors": "Small Donors", + "smallDonorsSubtitle": "Under $200 contributions", + "totalRaised": "Total Raised*", + "totalRaisedFootnote": "*after processing fees, through {{date}}", + "totalRaisedSubtitle": "From {{count}} contributors", + "totalSpent": "Total Spent", + "totalSpentSubtitle": "As of {{date}}" + }, + "table": { + "amount": "Amount", + "category": "Category", + "share": "Share" + } + }, "fundsRaised": "Funds Raised", "home": "Home", "legislators": "Legislators", From eee144433dfee8639c8076414531610e816eb190 Mon Sep 17 00:00:00 2001 From: Sam DeMarrais Date: Tue, 21 Jul 2026 22:17:01 -0400 Subject: [PATCH 093/106] Election scraping (#2178) * Election scraping * Further election scraping * More robust elections * Prettier * Various election bugfixes * Tests and election id handling * Prettier --- .prettierignore | 1 + functions/src/index.ts | 1 + functions/src/legislators/ElectionScraper.ts | 50 + functions/src/legislators/electionTypes.ts | 120 + functions/src/legislators/index.ts | 1 + .../src/legislators/scrapeElections.test.ts | 55 + functions/src/legislators/scrapeElections.ts | 377 + scripts/firebase-admin/backfillElections.ts | 22 + test.log | 3759 +++ .../electionResults/2016_general.html | 20209 ++++++++++++++++ .../electionResults/2016_general.json | 6497 +++++ .../electionResults/2016_general_table.html | 3491 +++ .../electionResults/2022_to_2026.html | 9867 ++++++++ .../electionResults/2022_to_2026.json | 2267 ++ .../2024_republican_primaries.html | 17208 +++++++++++++ .../2024_republican_primaries.json | 5716 +++++ 16 files changed, 69641 insertions(+) create mode 100644 functions/src/legislators/ElectionScraper.ts create mode 100644 functions/src/legislators/electionTypes.ts create mode 100644 functions/src/legislators/index.ts create mode 100644 functions/src/legislators/scrapeElections.test.ts create mode 100644 functions/src/legislators/scrapeElections.ts create mode 100644 scripts/firebase-admin/backfillElections.ts create mode 100644 test.log create mode 100644 tests/fixtures/electionResults/2016_general.html create mode 100644 tests/fixtures/electionResults/2016_general.json create mode 100644 tests/fixtures/electionResults/2016_general_table.html create mode 100644 tests/fixtures/electionResults/2022_to_2026.html create mode 100644 tests/fixtures/electionResults/2022_to_2026.json create mode 100644 tests/fixtures/electionResults/2024_republican_primaries.html create mode 100644 tests/fixtures/electionResults/2024_republican_primaries.json diff --git a/.prettierignore b/.prettierignore index aaf25be4b..45649aa44 100644 --- a/.prettierignore +++ b/.prettierignore @@ -17,3 +17,4 @@ llm playwright-report CLAUDE.md .cursor/ +tests/fixtures diff --git a/functions/src/index.ts b/functions/src/index.ts index 54ec1a6cf..e11b30569 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -58,6 +58,7 @@ export { unfollowUser, getFollowers } from "./subscriptions" +export { scrapeElections } from "./legislators" export { transcription } from "./webhooks" diff --git a/functions/src/legislators/ElectionScraper.ts b/functions/src/legislators/ElectionScraper.ts new file mode 100644 index 000000000..0f2c33272 --- /dev/null +++ b/functions/src/legislators/ElectionScraper.ts @@ -0,0 +1,50 @@ +import { runWith, RuntimeOptions } from "firebase-functions" +import { db } from "../firebase" +import { fetchElectionsData } from "./scrapeElections" + +export class ElectionScraper { + private schedule + private timeout + private memory + + constructor( + schedule: string = "every 24 hours", + timeout: number = 480, + memory: RuntimeOptions["memory"] = "256MB" + ) { + this.schedule = schedule + this.timeout = timeout + this.memory = memory + } + + get function() { + return runWith({ + timeoutSeconds: this.timeout, + memory: this.memory, + maxInstances: 1 + }) + .pubsub.schedule(this.schedule) + .onRun(() => this.run()) + } + + private async run(yearTo?: number, yearFrom?: number) { + const date = new Date() + yearTo = yearTo ?? date.getFullYear() + yearFrom = yearFrom ?? (date.getMonth() < 6 ? yearTo - 1 : yearTo) + + const list = await fetchElectionsData(yearFrom, yearTo) + + if (!list) return + + const writer = db.bulkWriter() + + for (let item of list) { + const id = item.id + writer.set(db.doc(`/electionResults/${id}`), item, { merge: true }) + } + + await writer.close() + } +} + +export const scrapeElections = new ElectionScraper().function diff --git a/functions/src/legislators/electionTypes.ts b/functions/src/legislators/electionTypes.ts new file mode 100644 index 000000000..efc5824ac --- /dev/null +++ b/functions/src/legislators/electionTypes.ts @@ -0,0 +1,120 @@ +import { + Array, + Union, + Literal, + String, + Boolean, + Number, + Optional, + Static, + Record +} from "runtypes" + +export const officeIds = { + President: 1, + "U.S. Senate": 6, + "U.S. House": 5, + Governor: 3, + "Lieutenant Governor": 4, + "Attorney General": 12, + "Secretary of the Commonwealth": 45, + Treasurer: 53, + Auditor: 90, + "Governor's Council": 529, + "State Senate": 9, + "State Representative": 8, + "Party State Committee Man": 521, + "Party State Committee Woman": 522, + "Delegate to the National Convention": 543, + "Alternate Delegate to the National Convention": 544, + "District Attorney": 530, + "Clerk of Courts": 15, + "Clerk of Superior Court (Civil)": 534, + "Clerk of Superior Court (Criminal)": 535, + "Clerk of Supreme Judicial Court": 536, + "County Charter Commission": 532, + "Register of Deeds": 384, + Sheriff: 386, + "County Treasurer": 389, + "Probate Judge": 434, + "Register of Probate": 537, + "Council of Governments Executive Committee": 531 +} as const +export const offices = Object.keys(officeIds) as (keyof typeof officeIds)[] +export type Office = keyof typeof officeIds + +export const parties = [ + "General", + "American", + "Democratic", + "Green-rainbow", // Green-rainbow has the case Green-Rainbow in some scenarios + "Independent Voters", + "Libertarian", + "Republican", + "Working Families", + "United Independent Party", + "United Independent", + "Independent", + "Green", + "Workers Party" +] as const +export type Party = (typeof parties)[number] +export const Party = Union( + Literal(parties[0]), + ...parties.slice(1).map(Literal) +) + +export const stages = ["Primaries", ...parties] +export type StageSelection = (typeof stages)[number] +export const StageSelection = Union( + Literal(stages[0]), + ...stages.slice(1).map(Literal) +) + +export const ElectionCandidate = Record({ + name: String, + writeIn: Boolean, + votes: Number, + // Note: During a primary election, no candidate is assigned a party + party: Optional(String) +}) + +export type ElectionCandidate = Static + +export const ElectionResult = Record({ + candidates: Array(ElectionCandidate), + otherVotes: Number, + blankVotes: Number, + noPreferenceVotes: Number.optional(), + totalVotes: Number +}) + +export type ElectionStage = Static + +export const ElectionStage = Record({ + party: Party, + special: Boolean +}) + +export type ElectionResult = Static + +export const ElectionInfo = Record({ + id: Number, + // Aligned with Candidates[], for use with Firestore array-contains + // More specific than name; for example, a dual election (such as for president/vice president) + // has a name "Harris and Walz", but the link is to the page for Kamala Harris + candidateUrls: Array(String), + // As far as I can tell, the only place exact date is shown + // is the search menu and PDFs + year: Number, + office: String, + // Seemingly non-standardized + districts: String, + // For general elections, party === "General" + party: Party, + special: Boolean, + // If this is missing, candidateUrls is deliberately [] + result: Optional(ElectionResult) +}) + +export type ElectionInfo = Static diff --git a/functions/src/legislators/index.ts b/functions/src/legislators/index.ts new file mode 100644 index 000000000..7cd688a9a --- /dev/null +++ b/functions/src/legislators/index.ts @@ -0,0 +1 @@ +export { scrapeElections } from "./ElectionScraper" diff --git a/functions/src/legislators/scrapeElections.test.ts b/functions/src/legislators/scrapeElections.test.ts new file mode 100644 index 000000000..d932a1629 --- /dev/null +++ b/functions/src/legislators/scrapeElections.test.ts @@ -0,0 +1,55 @@ +import { electionsPageInfo } from "./scrapeElections" + +import fs from "fs" +import path from "path" +import { JSDOM, VirtualConsole } from "jsdom" + +const FIXTURES_DIR = path.resolve( + __dirname, + "../../../tests/fixtures/electionResults" +) + +const electionTable = fs.readFileSync( + path.join(FIXTURES_DIR, "2016_general_table.html"), + "utf8" +) + +global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => electionTable +}) as jest.Mock + +describe("electionsPageInfo test", () => { + for (const htmlFile of [ + "2016_general.html", + "2022_to_2026.html", + "2024_republican_primaries.html" + ]) { + const basename = path.basename(htmlFile, ".html") + + it(basename, async () => { + const html = fs.readFileSync(path.join(FIXTURES_DIR, htmlFile), "utf8") + + const virtualConsole = new VirtualConsole() + virtualConsole.on("jsdomError", error => { + if (error.message.includes("Could not parse CSS stylesheet")) { + return + } + console.error(error) + }) + const dom = new JSDOM(html, { virtualConsole }) + + const actual = await electionsPageInfo(dom) + const expected = JSON.parse( + fs.readFileSync(path.join(FIXTURES_DIR, `${basename}.json`), "utf8") + ) + + expect(actual).toHaveLength(expected.length) + + for (const [i, expectedItem] of expected.entries()) { + expect(actual[i]).toEqual(expectedItem) + } + }) + } +}) diff --git a/functions/src/legislators/scrapeElections.ts b/functions/src/legislators/scrapeElections.ts new file mode 100644 index 000000000..fcbe7dbdf --- /dev/null +++ b/functions/src/legislators/scrapeElections.ts @@ -0,0 +1,377 @@ +import { JSDOM, VirtualConsole } from "jsdom" +import { logger } from "firebase-functions" +import { + ElectionStage, + parties, + Party, + ElectionInfo, + StageSelection, + ElectionResult, + ElectionCandidate, + Office, + officeIds +} from "./electionTypes" + +const REQUEST_DELAY_MS = process.env.NODE_ENV === "test" ? 0 : 3000 + +let queue: Promise = Promise.resolve() + +function waitAfterPrevious( + fn: () => Promise, + delayMs: number +): Promise { + const run = queue.then(async () => { + const result = await fn() + + await new Promise(resolve => setTimeout(resolve, delayMs)) + + return result + }) + + // Keep the queue alive even if this request fails + queue = run.catch(() => {}) + + return run +} + +const limitedFetch = (url: string) => + waitAfterPrevious(async () => (await fetch(url)).text(), REQUEST_DELAY_MS) + +const baseURL = "https://electionstats.state.ma.us" + +function parsePartyString(affiliationText: string): { + writeIn: boolean + party?: string +} { + const writeIn = /\bwrite-in\b/i.test(affiliationText) + + let party + affiliationText = affiliationText + .replace(/\(?write-in\)?/i, "") + .replace(/unenrolled/i, "") + .trim() + if (affiliationText) { + party = affiliationText + } + + return { + writeIn, + ...(party ? { party } : {}) + } +} + +function precinctHeaderText(th: Element | undefined): string | undefined { + const a = th?.querySelector("a[title]") ?? th?.querySelector("a[oldtitle]") + return ( + a?.getAttribute("title") ?? + a?.getAttribute("oldtitle") ?? + th?.textContent?.trim() + ) +} + +async function fetchElectionData( + url: string +): Promise<[ElectionResult, string[]]> { + const text = await limitedFetch(url) + + const virtualConsole = new VirtualConsole() + virtualConsole.on("jsdomError", error => { + if (error.message.includes("Could not parse CSS stylesheet")) { + return + } + console.error(error) + }) + + const dom = new JSDOM(text, { virtualConsole }) + const document = dom.window.document + + const table = document.querySelector("table.precinct_data") + if (!table) { + throw new Error(`No result table in ${url}`) + } + const headers = Array.from(table.querySelectorAll("thead tr th")).map( + th => precinctHeaderText(th) ?? "" + ) + const totalRow = table.querySelector("tbody tr.total") + if (!totalRow) { + throw new Error(`${url} has no table row for 'total'`) + } + const cells = Array.from(totalRow.querySelectorAll("td")) + const values = new Map() + // Avoid leftward descriptive titles + headers.reverse() + cells.reverse() + headers.forEach((header, i) => { + const text = cells[i].textContent?.replace(/,/g, "").trim() + if (text && /^\d+$/.test(text)) { + values.set(header, parseInt(text)) + } + }) + + const candidates = Array.from( + document.querySelectorAll(".candidate_key .item") + ).map(item => { + const nameElem = item.querySelector(".display_name > a") + const name = nameElem?.textContent?.trim() + if (!nameElem || !name) { + throw new Error(`${item.outerHTML} does not have ".display_name > a"`) + } + if (!values.has(name)) { + throw new Error( + `The table ${ + item.outerHTML + } does not have votes for the name ${name} (from ${JSON.stringify( + values, + null, + 2 + )})` + ) + } + const votes = values.get(name)! + return { + name, + party: parsePartyString( + item.querySelector(".party")?.textContent?.trim() ?? "" + ), + votes, + candidateUrl: `${baseURL}${nameElem.href}` + } + }) + + candidates.sort((a, b) => b.votes - a.votes) + + const candidateVotes = candidates.map(candidate => { + return { + name: candidate.name, + votes: candidate.votes, + ...candidate.party + } + }) + + const noPreference = values.has("No Preference") + ? { noPreferenceVotes: values.get("No Preference") } + : {} + + const [otherVotes, blankVotes, totalVotes] = [ + values.get("All Others"), + values.get("Blanks"), + values.get("Total Votes Cast") + ] + if (!totalVotes) { + throw new Error(`${url} has no 'Total' column`) + } + return [ + { + candidates: candidateVotes, + otherVotes: otherVotes ?? 0, + blankVotes: blankVotes ?? 0, + totalVotes, + ...noPreference + }, + candidates.map(candidate => candidate.candidateUrl) + ] +} + +function parseElectionStage(input: string): ElectionStage | null { + const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + + const partyPattern = parties + .map(escape) + .sort((a, b) => b.length - a.length) + .join("|") + + const regex = new RegExp(`^(Special )?(${partyPattern}) (Primary|Election)$`) + + const match = input.match(regex) + if (!match) { + return null + } + + const [, special, party, stage] = match + + // Only "General Election" is valid. + if (stage === "Election" && party !== "General") { + return null + } + + // Only non-General parties have primaries. + if (stage === "Primary" && party === "General") { + return null + } + + return { + party: Party.check(party), + special: special !== undefined + } +} + +function parseElectionTable(table: Element): [ElectionResult, string[]] | null { + if (table.querySelector(".other-candidates")) { + return null + } + const candidateRows = table.querySelectorAll( + ":scope > tr:not(.non_candidate):not(.more_info)" + ) + + const candidates = Array.from(candidateRows).map(row => { + const nameElem = row.querySelector(".candidate .name a") + const name = nameElem?.textContent?.trim() || "" + const partyText = + row.querySelector(".candidate .party")?.textContent?.trim() || "" + const link = nameElem?.href + + const voteText = row + .querySelector("td:nth-child(2)") + ?.textContent?.replace(/,/g, "") + if (!name || !voteText || !link) { + throw new Error( + `One of name, voteText, or candidate link is missing from ${row.outerHTML}` + ) + } + + const candidate = { + name, + votes: parseInt(voteText, 10), + ...parsePartyString(partyText) + } + + const ret: [ElectionCandidate, string] = [candidate, `${baseURL}${link}`] + return ret + }) + + candidates.sort((a, b) => b[0].votes - a[0].votes) + + const getSummaryValue = (selector: string): number | null => { + const row = table.querySelector(selector) + + const text = row + ?.querySelector("td:nth-child(2)") + ?.textContent?.replace(/,/g, "") + if (!text) { + return null + } + + return parseInt(text, 10) + } + + const [otherVotes, blankVotes, totalVotes] = [ + getSummaryValue("tr.n_all_other_votes"), + getSummaryValue("tr.n_blank_votes"), + getSummaryValue("tr.n_total_votes") + ] + const noPreference = getSummaryValue("tr.n_no_preference_votes") + if (!totalVotes) { + throw new Error(`No total votes row in ${table.outerHTML}`) + } + return [ + { + candidates: candidates.map(item => item[0]), + otherVotes: otherVotes ?? 0, + blankVotes: blankVotes ?? 0, + totalVotes, + ...(noPreference ? { noPreferenceVotes: noPreference } : {}) + }, + candidates.map(item => item[1]) + ] +} + +export async function electionsPageInfo( + dom: JSDOM +): Promise<(ElectionInfo | null)[]> { + const elements = Array.from( + dom.window.document.querySelectorAll('[id^="election-id-"]') + ) + const info = elements.map(async electionElem => { + try { + const match = electionElem.id.match(/^election-id-(\d+)$/) + if (!match) { + throw new Error( + `In ${electionElem.id}, the id was not parseable as an integer` + ) + } + const electionId = parseInt(match[1], 10) + const electTDs = Array.from(electionElem.children).filter( + child => child.tagName === "TD" + ) + const yearText = electTDs[0].textContent + if (!yearText) { + throw new Error(`Year not present in ${electionElem.outerHTML}`) + } + const year = parseInt(yearText, 10) + const office = electTDs[1].textContent?.trim() + const districts = electTDs[2].textContent?.trim() + if (!year || !office || !districts) { + throw new Error( + `Year, office, or districts not present in ${electionElem.outerHTML}` + ) + } + const stage = parseElectionStage(electTDs[3].textContent?.trim() ?? "") + if (!stage) { + throw new Error( + `${stage} is not a recognized election stage: ${electTDs[3].outerHTML}` + ) + } + if (electTDs[4].querySelector(":scope > .no_candidates")) { + return ElectionInfo.check({ + id: electionId, + year, + office, + districts, + candidateUrls: [], + ...stage + }) + } + const candidateTable = electTDs[4].querySelector(":scope tbody") + if (!candidateTable) { + throw new Error(`No candidate table in ${electionElem.outerHTML}`) + } + const [result, candidateUrls] = + parseElectionTable(candidateTable) ?? + (await (async () => { + const electionDetailsUrl = `${baseURL}/elections/view/${electionId}/` + return await fetchElectionData(electionDetailsUrl) + })()) + + return ElectionInfo.check({ + id: electionId, + year, + office, + districts, + ...stage, + candidateUrls, + result + }) + } catch (error) { + logger.error(error) + return null + } + }) + return Promise.all(info) +} + +export async function fetchElectionsData( + startYear: number, + endYear: number, + office?: Office, + stage: StageSelection | null = "General" +): Promise { + const officeId = office ? `/office_id:${officeIds[office]}` : "" + const electionStage = stage ? `/stage:${stage}` : "" + const url = `${baseURL}/elections/search/year_from:${startYear}/year_to:${endYear}${officeId}${electionStage}` + const text = await limitedFetch(url) + const virtualConsole = new VirtualConsole() + virtualConsole.on("jsdomError", error => { + if (error.message.includes("Could not parse CSS stylesheet")) { + return + } + console.error(error) + }) + const dom = new JSDOM(text, { virtualConsole }) + const elections: (ElectionInfo | null)[] = await electionsPageInfo(dom) + if (elections.length === 1200) { + logger.error( + `The url ${url} has reached the maximum number of election results provided, please use a more refined query` + ) + } + return elections.filter((item): item is ElectionInfo => item !== null) +} diff --git a/scripts/firebase-admin/backfillElections.ts b/scripts/firebase-admin/backfillElections.ts new file mode 100644 index 000000000..bb604a39a --- /dev/null +++ b/scripts/firebase-admin/backfillElections.ts @@ -0,0 +1,22 @@ +import { Record, Number } from "runtypes" +import { Script } from "./types" +import { fetchElectionsData } from "functions/src/legislators/scrapeElections" + +const Args = Record({ + startYear: Number +}) + +export const script: Script = async ({ db, args }) => { + const { startYear } = Args.check(args) + const currentYear = new Date().getFullYear() + const writer = db.bulkWriter() + for (let year = startYear; year <= currentYear; year++) { + const data = await fetchElectionsData(year, year) + for (const item of data) { + ;(await db.doc(`/electionResults/${item.id}`).get()).ref.set(item, { + merge: true + }) + } + } + await writer.close() +} diff --git a/test.log b/test.log new file mode 100644 index 000000000..bc56b738f --- /dev/null +++ b/test.log @@ -0,0 +1,3759 @@ +yarn run v1.22.22 +$ ts-node --swc -P tsconfig.script.json scripts/firebase-admin run-script backfillElections --env local --startYear 2024 +https://electionstats.state.ma.us/elections/search/year_from:2024/year_to:2024 +
+
+
 
+
+
+ + + + + + + + +
+ + + Years:  +2026 +2025 +2024 +2023 +2022 +2021 +2020 +2018 +2017 +2016 +2015 +2014 +2013 +2012 +2011 +2010 +2009 +2008 +2007 +2006 +2005 +2004 +2003 +2002 +2001 +2000 +1999 +1998 +1997 +1996 +1995 +1994 +1993 +1992 +1991 +1990 +1988 +1987 +1986 +1985 +1984 +1983 +1982 +1981 +1980 +1979 +1978 +1977 +1976 +1975 +1974 +1972 +1971 +1970 +  to   +2026 +2025 +2024 +2023 +2022 +2021 +2020 +2018 +2017 +2016 +2015 +2014 +2013 +2012 +2011 +2010 +2009 +2008 +2007 +2006 +2005 +2004 +2003 +2002 +2001 +2000 +1999 +1998 +1997 +1996 +1995 +1994 +1993 +1992 +1991 +1990 +1988 +1987 +1986 +1985 +1984 +1983 +1982 +1981 +1980 +1979 +1978 +1977 +1976 +1975 +1974 +1972 +1971 +1970 +Date:   +Mar 31, 2026 +Mar 3, 2026 +Feb 3, 2026 +Jun 10, 2025 +May 13, 2025 +Apr 15, 2025 +Nov 5, 2024 +Sep 3, 2024 +Mar 5, 2024 +Feb 6, 2024 +Nov 7, 2023 +Oct 10, 2023 +May 30, 2023 +May 2, 2023 +Nov 8, 2022 +Sep 6, 2022 +Jan 11, 2022 +Dec 14, 2021 +Nov 30, 2021 +Nov 2, 2021 +Mar 30, 2021 +Mar 2, 2021 +Nov 3, 2020 +Sep 1, 2020 +Jun 2, 2020 +May 19, 2020 +Mar 3, 2020 +Feb 4, 2020 +Nov 6, 2018 +Sep 4, 2018 +Apr 3, 2018 +Mar 6, 2018 +Feb 6, 2018 +Dec 5, 2017 +Nov 7, 2017 +Oct 17, 2017 +Oct 10, 2017 +Sep 19, 2017 +Jul 25, 2017 +Jun 27, 2017 +Nov 8, 2016 +Sep 8, 2016 +May 10, 2016 +Apr 12, 2016 +Mar 1, 2016 +Feb 2, 2016 +Nov 3, 2015 +Oct 6, 2015 +Mar 31, 2015 +Mar 3, 2015 +Nov 4, 2014 +Sep 9, 2014 +Apr 29, 2014 +Apr 1, 2014 +Mar 4, 2014 +Jan 7, 2014 +Dec 10, 2013 +Nov 5, 2013 +Oct 15, 2013 +Oct 8, 2013 +Sep 10, 2013 +Aug 13, 2013 +Jun 25, 2013 +May 28, 2013 +Apr 30, 2013 +Apr 2, 2013 +Mar 5, 2013 +Nov 6, 2012 +Sep 6, 2012 +Mar 6, 2012 +Jan 10, 2012 +Dec 13, 2011 +Oct 18, 2011 +Sep 20, 2011 +Aug 23, 2011 +May 10, 2011 +Apr 12, 2011 +Nov 2, 2010 +Sep 14, 2010 +Jun 15, 2010 +May 18, 2010 +May 11, 2010 +Apr 13, 2010 +Jan 19, 2010 +Dec 8, 2009 +Jun 16, 2009 +May 19, 2009 +Nov 4, 2008 +Sep 16, 2008 +Mar 4, 2008 +Feb 5, 2008 +Dec 11, 2007 +Nov 13, 2007 +Oct 23, 2007 +Oct 16, 2007 +Oct 9, 2007 +Sep 25, 2007 +Sep 11, 2007 +Sep 4, 2007 +Jun 26, 2007 +May 29, 2007 +May 15, 2007 +Apr 17, 2007 +Mar 20, 2007 +Nov 7, 2006 +Sep 19, 2006 +Feb 7, 2006 +Jan 10, 2006 +Sep 27, 2005 +Aug 30, 2005 +Apr 12, 2005 +Mar 15, 2005 +Nov 2, 2004 +Sep 14, 2004 +Mar 2, 2004 +Feb 3, 2004 +May 13, 2003 +Apr 1, 2003 +Nov 5, 2002 +Sep 17, 2002 +Apr 23, 2002 +Mar 12, 2002 +Jan 8, 2002 +Dec 11, 2001 +Oct 23, 2001 +Oct 16, 2001 +Sep 25, 2001 +May 22, 2001 +Apr 24, 2001 +Nov 7, 2000 +Mar 7, 2000 +Feb 8, 2000 +Dec 21, 1999 +Nov 23, 1999 +Oct 26, 1999 +Aug 31, 1999 +Jul 6, 1999 +Jun 8, 1999 +Jun 1, 1999 +May 25, 1999 +May 11, 1999 +May 4, 1999 +Apr 27, 1999 +Apr 13, 1999 +Mar 16, 1999 +Nov 3, 1998 +Sep 15, 1998 +Mar 10, 1998 +Feb 10, 1998 +Jan 6, 1998 +Dec 9, 1997 +Apr 8, 1997 +Mar 11, 1997 +Nov 5, 1996 +Apr 23, 1996 +Apr 16, 1996 +Mar 26, 1996 +Mar 19, 1996 +Mar 5, 1996 +Jan 9, 1996 +Dec 12, 1995 +Dec 10, 1995 +Nov 7, 1995 +Sep 22, 1995 +Sep 19, 1995 +Sep 12, 1995 +Nov 8, 1994 +Jun 7, 1994 +May 10, 1994 +Mar 1, 1994 +Feb 15, 1994 +Feb 1, 1994 +Jan 18, 1994 +Nov 30, 1993 +Nov 2, 1993 +Oct 12, 1993 +Sep 14, 1993 +Aug 17, 1993 +May 11, 1993 +Nov 3, 1992 +Sep 15, 1992 +Apr 7, 1992 +Mar 17, 1992 +Mar 10, 1992 +Feb 11, 1992 +Jan 7, 1992 +Nov 12, 1991 +Oct 22, 1991 +Oct 15, 1991 +Sep 24, 1991 +Sep 17, 1991 +Aug 27, 1991 +Jun 4, 1991 +Apr 30, 1991 +Nov 6, 1990 +May 1, 1990 +Apr 3, 1990 +Nov 8, 1988 +Mar 8, 1988 +Sep 15, 1987 +Aug 18, 1987 +Nov 4, 1986 +Jun 4, 1985 +May 7, 1985 +Apr 9, 1985 +Mar 12, 1985 +Nov 6, 1984 +May 29, 1984 +May 1, 1984 +Mar 13, 1984 +Feb 28, 1984 +Jan 31, 1984 +Sep 20, 1983 +Sep 13, 1983 +Aug 16, 1983 +Aug 13, 1983 +May 10, 1983 +May 3, 1983 +Apr 19, 1983 +Apr 12, 1983 +Apr 5, 1983 +Mar 22, 1983 +Nov 2, 1982 +Jun 16, 1981 +May 26, 1981 +Apr 28, 1981 +Nov 4, 1980 +Apr 15, 1980 +Mar 18, 1980 +Mar 4, 1980 +Feb 19, 1980 +Feb 5, 1980 +Jan 22, 1980 +Jan 8, 1980 +Oct 16, 1979 +Sep 18, 1979 +Sep 19, 1978 +Nov 1, 1977 +Oct 4, 1977 +Aug 16, 1977 +Aug 11, 1977 +Aug 2, 1977 +Jul 19, 1977 +Jun 21, 1977 +Jun 14, 1977 +May 24, 1977 +May 10, 1977 +Apr 26, 1977 +Sep 14, 1976 +Nov 18, 1975 +Aug 26, 1975 +Feb 25, 1975 +Sep 10, 1974 +Sep 19, 1972 +Apr 25, 1972 +Apr 18, 1972 +Mar 28, 1972 +Mar 21, 1972 +Feb 29, 1972 +Jul 27, 1971 +Jun 29, 1971 +Sep 15, 1970 +Office: +All Offices + +President +U.S. Senate +U.S. House + + +Governor +Lieutenant Governor +Attorney General +Secretary of the Commonwealth +Treasurer +Auditor +Governor's Council +State Senate +State Representative + + +Party State Committee Man +Party State Committee Woman +Delegate to the National Convention +Alternate Delegate to the National Convention +District Attorney +Clerk of Courts +Clerk of Superior Court (Civil) +Clerk of Superior Court (Criminal) +Clerk of Supreme Judicial Court +County Charter Commission +Register of Deeds +Sheriff +County Treasurer +Probate Judge +Register of Probate +Council of Governments Executive Committee + + District: +All Districts +Berkshire, Hampden, Franklin & Hampshire +Berkshire, Hampshire, Franklin & Hampden +First Plymouth & Norfolk +Hampden, Hampshire & Worcester +Norfolk & Middlesex +Norfolk, Plymouth & Bristol +Norfolk, Worcester & Middlesex +Second Plymouth & Norfolk +Third Bristol & Plymouth +Worcester & Hampden +Worcester & Hampshire +Berkshire +Berkshire, Hampshire and Franklin +Berkshire, Hampden, Hampshire and Franklin +Bristol +1st Bristol +2nd Bristol +Bristol and Norfolk +Bristol and Plymouth +1st Bristol and Plymouth +2nd Bristol and Plymouth +3rd Bristol +Bristol, Plymouth, and Norfolk +Cape and Islands +Cape, Plymouth, and Islands +1st Essex +2nd Essex +3rd Essex +4th Essex +5th Essex +1st Essex and Middlesex +2nd Essex and Middlesex +3rd Essex and Middlesex +Franklin and Hampshire +Hampden +Hampden and Berkshire +Hampden and Hampshire +1st Hampden +2nd Hampden +1st Hampden and Hampshire +2nd Hampden and Hampshire +Hampshire and Franklin +Hampshire, Franklin and Worcester +1st Middlesex +2nd Middlesex +3rd Middlesex +4th Middlesex +6th Middlesex +7th Middlesex +8th Middlesex +5th Middlesex +Middlesex and Essex +1st Middlesex and Norfolk +2nd Middlesex and Norfolk +3rd Middlesex and Norfolk +Middlesex and Norfolk +Middlesex, Norfolk & Worcester +Middlesex and Worcester +Middlesex and Suffolk +Middlesex, Suffolk and Essex +Norfolk +Norfolk and Bristol +Norfolk, Bristol and Middlesex +Norfolk, Bristol and Plymouth +Norfolk and Plymouth +Norfolk and Suffolk +Plymouth +1st Plymouth +2nd Plymouth +Plymouth and Barnstable +Plymouth and Norfolk +1st Plymouth and Bristol +2nd Plymouth and Bristol +1st Suffolk +2nd Suffolk +3rd Suffolk +4th Suffolk +5th Suffolk +6th Suffolk +Suffolk and Middlesex +Suffolk and Norfolk +1st Suffolk and Middlesex +1st Suffolk and Norfolk +2nd Suffolk and Middlesex +2nd Suffolk and Norfolk +Suffolk, Essex and Middlesex +Worcester +Worcester and Middlesex +Worcester and Norfolk +Worcester, Hampden and Hampshire +Worcester, Hampden, Hampshire and Franklin +Worcester, Hampden, Hampshire and Middlesex +1st Worcester +2nd Worcester +3rd Worcester +4th Worcester +1st Worcester and Middlesex +2nd Worcester and Middlesex + District: +All Districts +Berkshire, Hampden, Franklin & Hampshire +Berkshire, Hampshire, Franklin & Hampden +First Plymouth & Norfolk +Hampden, Hampshire & Worcester +Norfolk & Middlesex +Norfolk, Plymouth & Bristol +Norfolk, Worcester & Middlesex +Second Plymouth & Norfolk +Third Bristol & Plymouth +Worcester & Hampden +Worcester & Hampshire +Berkshire +Berkshire, Hampshire and Franklin +Berkshire, Hampden, Hampshire and Franklin +Bristol +1st Bristol +2nd Bristol +Bristol and Norfolk +Bristol and Plymouth +1st Bristol and Plymouth +2nd Bristol and Plymouth +3rd Bristol +Bristol, Plymouth, and Norfolk +Cape and Islands +Cape, Plymouth, and Islands +1st Essex +2nd Essex +3rd Essex +4th Essex +5th Essex +1st Essex and Middlesex +2nd Essex and Middlesex +3rd Essex and Middlesex +Franklin and Hampshire +Hampden +Hampden and Berkshire +Hampden and Hampshire +1st Hampden +2nd Hampden +1st Hampden and Hampshire +2nd Hampden and Hampshire +Hampshire and Franklin +Hampshire, Franklin and Worcester +1st Middlesex +2nd Middlesex +3rd Middlesex +4th Middlesex +6th Middlesex +7th Middlesex +8th Middlesex +5th Middlesex +Middlesex and Essex +1st Middlesex and Norfolk +2nd Middlesex and Norfolk +3rd Middlesex and Norfolk +Middlesex and Norfolk +Middlesex, Norfolk & Worcester +Middlesex and Worcester +Middlesex and Suffolk +Middlesex, Suffolk and Essex +Norfolk +Norfolk and Bristol +Norfolk, Bristol and Middlesex +Norfolk, Bristol and Plymouth +Norfolk and Plymouth +Norfolk and Suffolk +Plymouth +1st Plymouth +2nd Plymouth +Plymouth and Barnstable +Plymouth and Norfolk +1st Plymouth and Bristol +2nd Plymouth and Bristol +1st Suffolk +2nd Suffolk +3rd Suffolk +4th Suffolk +5th Suffolk +6th Suffolk +Suffolk and Middlesex +Suffolk and Norfolk +1st Suffolk and Middlesex +1st Suffolk and Norfolk +2nd Suffolk and Middlesex +2nd Suffolk and Norfolk +Suffolk, Essex and Middlesex +Worcester +Worcester and Middlesex +Worcester and Norfolk +Worcester, Hampden and Hampshire +Worcester, Hampden, Hampshire and Franklin +Worcester, Hampden, Hampshire and Middlesex +1st Worcester +2nd Worcester +3rd Worcester +4th Worcester +1st Worcester and Middlesex +2nd Worcester and Middlesex + District: +All Districts +1st Congressional +2nd Congressional +3rd Congressional +4th Congressional +5th Congressional +6th Congressional +7th Congressional +8th Congressional +9th Congressional +10th Congressional +11th Congressional +12th Congressional + District: +All Districts + 1st + 2nd + 3rd + 4th + 5th + 6th + 7th + 8th + District: +All Districts +Berkshire, Hampden, Franklin & Hampshire +Berkshire, Hampshire, Franklin & Hampden +First Plymouth & Norfolk +Hampden, Hampshire & Worcester +Norfolk & Middlesex +Norfolk, Plymouth & Bristol +Norfolk, Worcester & Middlesex +Second Plymouth & Norfolk +Third Bristol & Plymouth +Worcester & Hampden +Worcester & Hampshire +Berkshire +Berkshire, Hampshire and Franklin +Berkshire, Hampden, Hampshire and Franklin +Bristol +1st Bristol +2nd Bristol +Bristol and Norfolk +Bristol and Plymouth +1st Bristol and Plymouth +2nd Bristol and Plymouth +3rd Bristol +Bristol, Plymouth, and Norfolk +Cape and Islands +Cape and Plymouth +Cape, Plymouth, and Islands +1st Essex +2nd Essex +3rd Essex +4th Essex +5th Essex +1st Essex and Middlesex +2nd Essex and Middlesex +3rd Essex and Middlesex +Franklin and Hampshire +Franklin, Hampden, and Hampshire +Hampden +Hampden and Berkshire +Hampden and Hampshire +1st Hampden +2nd Hampden +1st Hampden and Hampshire +2nd Hampden and Hampshire +Hampshire +Hampshire and Franklin +Hampshire, Franklin and Worcester +1st Middlesex +2nd Middlesex +3rd Middlesex +4th Middlesex +6th Middlesex +7th Middlesex +8th Middlesex +5th Middlesex +Middlesex and Essex +1st Middlesex and Norfolk +2nd Middlesex and Norfolk +3rd Middlesex and Norfolk +Middlesex and Norfolk +Middlesex, Norfolk & Worcester +Middlesex and Worcester +Middlesex and Suffolk +Middlesex, Suffolk and Essex +Norfolk +Norfolk and Bristol +Norfolk, Bristol and Middlesex +Norfolk, Bristol and Plymouth +Norfolk and Plymouth +Norfolk and Suffolk +Plymouth +1st Plymouth +2nd Plymouth +Plymouth and Barnstable +Plymouth and Norfolk +1st Plymouth and Bristol +2nd Plymouth and Bristol +1st Suffolk +2nd Suffolk +3rd Suffolk +4th Suffolk +5th Suffolk +6th Suffolk +Suffolk and Middlesex +Suffolk and Norfolk +1st Suffolk and Middlesex +1st Suffolk and Norfolk +2nd Suffolk and Middlesex +2nd Suffolk and Norfolk +Suffolk, Essex and Middlesex +Worcester +Worcester and Middlesex +Worcester and Norfolk +Worcester, Hampden and Hampshire +Worcester, Hampden, Hampshire and Franklin +Worcester, Hampden, Hampshire and Middlesex +1st Worcester +2nd Worcester +3rd Worcester +4th Worcester +1st Worcester and Middlesex +2nd Worcester and Middlesex + District: +All Districts +1st Barnstable +2nd Barnstable +3rd Barnstable +4th Barnstable +5th Barnstable +Barnstable, Dukes and Nantucket +1st Berkshire +2nd Berkshire +3rd Berkshire +4th Berkshire +5th Berkshire +6th Berkshire +1st Bristol +2nd Bristol +3rd Bristol +4th Bristol +5th Bristol +6th Bristol +7th Bristol +8th Bristol +9th Bristol +10th Bristol +11th Bristol +12th Bristol +13th Bristol +14th Bristol +15th Bristol +16th Bristol +17th Bristol +18th Bristol +Cape and Islands +1st Dukes +1st Essex +2nd Essex +3rd Essex +4th Essex +5th Essex +6th Essex +7th Essex +8th Essex +9th Essex +10th Essex +11th Essex +12th Essex +13th Essex +14th Essex +15th Essex +16th Essex +17th Essex +18th Essex +19th Essex +20th Essex +21st Essex +22nd Essex +23rd Essex +24th Essex +25th Essex +26th Essex +27th Essex +1st Franklin +2nd Franklin +3rd Franklin +1st Hampden +2nd Hampden +3rd Hampden +4th Hampden +5th Hampden +6th Hampden +7th Hampden +8th Hampden +9th Hampden +10th Hampden +11th Hampden +12th Hampden +13th Hampden +14th Hampden +15th Hampden +16th Hampden +17th Hampden +18th Hampden +19th Hampden +20th Hampden +1st Hampshire +2nd Hampshire +3rd Hampshire +4th Hampshire +1st Middlesex +2nd Middlesex +3rd Middlesex +4th Middlesex +5th Middlesex +6th Middlesex +7th Middlesex +8th Middlesex +9th Middlesex +10th Middlesex +11th Middlesex +12th Middlesex +13th Middlesex +14th Middlesex +15th Middlesex +16th Middlesex +17th Middlesex +18th Middlesex +19th Middlesex +20th Middlesex +21st Middlesex +22nd Middlesex +23rd Middlesex +24th Middlesex +25th Middlesex +26th Middlesex +27th Middlesex +28th Middlesex +29th Middlesex +30th Middlesex +31st Middlesex +32nd Middlesex +33rd Middlesex +34th Middlesex +35th Middlesex +36th Middlesex +37th Middlesex +38th Middlesex +39th Middlesex +40th Middlesex +41st Middlesex +42nd Middlesex +43rd Middlesex +44th Middlesex +45th Middlesex +46th Middlesex +47th Middlesex +48th Middlesex +49th Middlesex +50th Middlesex +51st Middlesex +52nd Middlesex +53rd Middlesex +54th Middlesex +55th Middlesex +56th Middlesex +57th Middlesex +58th Middlesex +59th Middlesex +1st Nantucket +1st Norfolk +2nd Norfolk +3rd Norfolk +4th Norfolk +5th Norfolk +6th Norfolk +7th Norfolk +8th Norfolk +9th Norfolk +10th Norfolk +11th Norfolk +12th Norfolk +13th Norfolk +14th Norfolk +15th Norfolk +16th Norfolk +17th Norfolk +18th Norfolk +19th Norfolk +20th Norfolk +21st Norfolk +22nd Norfolk +23rd Norfolk +24th Norfolk +1st Plymouth +2nd Plymouth +3rd Plymouth +4th Plymouth +5th Plymouth +6th Plymouth +7th Plymouth +8th Plymouth +9th Plymouth +10th Plymouth +11th Plymouth +12th Plymouth +13th Plymouth +14th Plymouth +15th Plymouth +1st Suffolk +2nd Suffolk +3rd Suffolk +4th Suffolk +5th Suffolk +6th Suffolk +7th Suffolk +8th Suffolk +9th Suffolk +10th Suffolk +11th Suffolk +12th Suffolk +13th Suffolk +14th Suffolk +15th Suffolk +16th Suffolk +17th Suffolk +18th Suffolk +19th Suffolk +20th Suffolk +21st Suffolk +22nd Suffolk +23rd Suffolk +24th Suffolk +25th Suffolk +26th Suffolk +27th Suffolk +28th Suffolk +29th Suffolk +30th Suffolk +31st Suffolk +1st Worcester +2nd Worcester +3rd Worcester +4th Worcester +5th Worcester +6th Worcester +7th Worcester +8th Worcester +9th Worcester +10th Worcester +11th Worcester +12th Worcester +13th Worcester +14th Worcester +15th Worcester +16th Worcester +17th Worcester +18th Worcester +19th Worcester +20th Worcester +21st Worcester +22nd Worcester +23rd Worcester +24th Worcester +25th Worcester +26th Worcester +27th Worcester + District: +All Districts +Berkshire +Bristol +Cape and Islands +Eastern +Hampden +Middle +Norfolk +Northern +Northwestern +Plymouth +Southern +Suffolk +Western + County: +All Counties +Barnstable +Berkshire +Bristol +Dukes +Essex +Franklin +Hampden +Hampshire +Middlesex +Nantucket +Norfolk +Plymouth +Suffolk +Worcester + County: +All Counties +Suffolk + County: +All Counties +Suffolk + County: +All Counties +Suffolk + District: +All Districts +Barnstable County - Barnstable +Barnstable County - Bourne +Barnstable County - Brewster +Barnstable County - Chatham +Barnstable County - Dennis +Barnstable County - Eastham +Barnstable County - Falmouth +Barnstable County - Harwich +Barnstable County - Mashpee +Barnstable County - Orleans +Barnstable County - Provincetown +Barnstable County - Sandwich +Barnstable County - Truro +Barnstable County - Wellfleet +Barnstable County - Yarmouth +Berkshire County - At Large +Berkshire County (8th) +Berkshire County (5th) +Berkshire County (1st) +Berkshire County (4th) +Berkshire County (9th) +Berkshire County (2nd) +Berkshire County (7th) +Berkshire County (6th) +Berkshire County (10th) +Berkshire County (3rd) +Bristol County (8th) +Bristol County (11th) +Bristol County (15th) +Bristol County (5th) +Bristol County (1st) +Bristol County (4th) +Bristol County (9th) +Bristol County (2nd) +Bristol County (7th) +Bristol County (6th) +Bristol County (10th) +Bristol County (3rd) +Bristol County (13th) +Bristol County (12th) +Dukes County at Large +Essex County (8th) +Essex County (11th) +Essex County (15th) +Essex County (5th) +Essex County (1st) +Essex County (14th) +Essex County (4th) +Essex County (9th) +Essex County (2nd) +Essex County (7th) +Essex County (6th) +Essex County (10th) +Essex County (3rd) +Essex County (13th) +Essex County (12th) +Franklin County (8th) +Franklin County (5th) +Franklin County (1st) +Franklin County (4th) +Franklin County (9th) +Franklin County (2nd) +Franklin County (7th) +Franklin County (6th) +Franklin County (10th) +Franklin County (3rd) +Franklin County at Large +Hampshire County - At Large +Hampshire County (8th) +Hampshire County (11th) +Hampshire County (15th) +Hampshire County (5th) +Hampshire County (1st) +Hampshire County (14th) +Hampshire County (4th) +Hampshire County (9th) +Hampshire County (2nd) +Hampshire County (7th) +Hampshire County (6th) +Hampshire County (10th) +Hampshire County (3rd) +Hampshire County (13th) +Hampshire County (12th) +Nantucket County at Large +Norfolk County (8th) +Norfolk County (11th) +Norfolk County (15th) +Norfolk County (5th) +Norfolk County (1st) +Norfolk County (14th) +Norfolk County (4th) +Norfolk County (9th) +Norfolk County (2nd) +Norfolk County (7th) +Norfolk County (6th) +Norfolk County (10th) +Norfolk County (3rd) +Norfolk County (12th) +Plymouth County (8th) +Plymouth County (11th) +Plymouth County (15th) +Plymouth County (5th) +Plymouth County (1st) +Plymouth County (14th) +Plymouth County (4th) +Plymouth County (9th) +Plymouth County (2nd) +Plymouth County (7th) +Plymouth County (6th) +Plymouth County (10th) +Plymouth County (3rd) +Plymouth County (13th) +Plymouth County (12th) +Worcester County (8th) +Worcester County (11th) +Worcester County (15th) +Worcester County (5th) +Worcester County (1st) +Worcester County (14th) +Worcester County (4th) +Worcester County (9th) +Worcester County (2nd) +Worcester County (7th) +Worcester County (6th) +Worcester County (10th) +Worcester County (3rd) +Worcester County (13th) +Worcester County (12th) + District: +All Districts +Barnstable +Berkshire Middle +Berkshire Northern +Berkshire Southern +Bristol Fall River +Bristol Northern +Bristol Southern +Dukes +Essex Northern +Essex Southern +Fall River +Franklin +Hampden +Hampshire +Middlesex Northern +Middlesex Southern +Nantucket +Norfolk +Plymouth +Suffolk +Worcester +Worcester Northern + County: +All Counties +Barnstable +Berkshire +Bristol +Dukes +Essex +Franklin +Hampden +Hampshire +Middlesex +Nantucket +Norfolk +Plymouth +Suffolk +Worcester + County: +All Counties +Barnstable +Berkshire +Bristol +Dukes +Essex +Franklin +Hampden +Hampshire +Middlesex +Norfolk +Plymouth +Worcester + County: +All Counties +Franklin + County: +All Counties +Barnstable +Berkshire +Bristol +Dukes +Essex +Franklin +Hampden +Hampshire +Middlesex +Nantucket +Norfolk +Plymouth +Suffolk +Worcester + County: +All Counties +Franklin + Stage: +All Elections +All General Elections +All Primary Elections +American Primaries +Democratic Primaries +Green-Rainbow Primaries +Independent Voters Primaries +Libertarian Primaries +Republican Primaries +United Independent Primaries +Working Families Primaries + Stage: +All Elections +American Party +Democratic Party +Green-Rainbow Party +Libertarian Party +Republican Party +United Independent Party +Working Families Party + Stage: +All Elections +American Party +Democratic Party +Green-Rainbow Party +Libertarian Party +Republican Party +United Independent Party +Working Families Party + Stage: +All Elections +All General Elections +All Primary Elections +Democratic Primaries +Republican Primaries + Stage: +All Elections +All General Elections +All Primary Elections +Democratic Primaries +Republican Primaries + Stage: +All Elections +All General Elections +All Primary Elections +American Primaries +Democratic Primaries +Green-Rainbow Primaries +Libertarian Primaries +Republican Primaries +Working Families Primaries + Stage: +All Elections +All General Elections +All Primary Elections +Democratic Primaries +Green-Rainbow Primaries +Libertarian Primaries +Republican Primaries + Stage: +All Elections +All General Elections +All Primary Elections +Democratic Primaries +Green-Rainbow Primaries +Libertarian Primaries +Republican Primaries + Stage: +All Elections +All General Elections +All Primary Elections +Democratic Primaries +Green-Rainbow Primaries +Libertarian Primaries +Republican Primaries + Stage: +All Elections +All General Elections +All Primary Elections +Democratic Primaries +Green-Rainbow Primaries +Libertarian Primaries +Republican Primaries + Stage: +All Elections +All General Elections +All Primary Elections +Democratic Primaries +Green-Rainbow Primaries +Libertarian Primaries +Republican Primaries + Stage: +All Elections +All General Elections +All Primary Elections +Democratic Primaries +Green-Rainbow Primaries +Libertarian Primaries +Republican Primaries + Stage: +All Elections +All General Elections +All Primary Elections +American Primaries +Democratic Primaries +Green-Rainbow Primaries +Libertarian Primaries +Republican Primaries +United Independent Party Primaries +Working Families Primaries +Independent Primaries + Stage: +All Elections +All General Elections +All Primary Elections +American Primaries +Democratic Primaries +Green-Rainbow Primaries +Libertarian Primaries +Republican Primaries +United Independent Party Primaries +Working Families Primaries + Stage: +All Elections +All General Elections +All Primary Elections +American Primaries +Democratic Primaries +Green-Rainbow Primaries +Libertarian Primaries +Republican Primaries +United Independent Party Primaries +Working Families Primaries +Independent Primaries +United Independent Primaries +Independent Voters Primaries + Stage: +All Elections +All General Elections +All Primary Elections +American Primaries +Democratic Primaries +Green-Rainbow Primaries +Libertarian Primaries +Republican Primaries +United Independent Party Primaries +Working Families Primaries +Independent Voters Primaries +Independent Primaries +United Independent Primaries +Green Primaries + Stage: +All Elections +All General Elections +All Primary Elections +Democratic Primaries +Green-Rainbow Primaries +Libertarian Primaries +Republican Primaries +United Independent Party Primaries + Stage: +All Elections +All General Elections +All Primary Elections +American Primaries +Democratic Primaries +Green-Rainbow Primaries +Libertarian Primaries +Republican Primaries +Working Families Primaries + Stage: +All Elections +All General Elections +All Primary Elections +Democratic Primaries +Green-Rainbow Primaries +Libertarian Primaries +Republican Primaries + Stage: +All Elections +All General Elections +All Primary Elections +Democratic Primaries +Green-Rainbow Primaries +Libertarian Primaries +Republican Primaries + Stage: +All Elections +All General Elections +All Primary Elections +Democratic Primaries +Green-Rainbow Primaries +Libertarian Primaries +Republican Primaries + Stage: +All Elections +All General Elections +All Primary Elections + Stage: +All Elections +All General Elections +All Primary Elections +American Primaries +Democratic Primaries +Green-Rainbow Primaries +Libertarian Primaries +Republican Primaries +United Independent Party Primaries +Working Families Primaries + Stage: +All Elections +All General Elections +All Primary Elections +Democratic Primaries +Green-Rainbow Primaries +Libertarian Primaries +Republican Primaries +United Independent Party Primaries + Stage: +All Elections +All General Elections +All Primary Elections +Democratic Primaries +Republican Primaries +Green-Rainbow Primaries +Libertarian Primaries +Working Families Primaries + Stage: +All Elections +All General Elections +All Primary Elections +Democratic Primaries + Stage: +All Elections +All General Elections +All Primary Elections +Democratic Primaries +Green-Rainbow Primaries +Libertarian Primaries +Republican Primaries +Working Families Primaries + Stage: +All Elections +All General Elections +All Primary Elections + Stage: +All Elections +All General Elections +All Primary Elections +American Primaries +Democratic Primaries +Green-Rainbow Primaries +Independent Voters Primaries +Libertarian Primaries +Republican Primaries +United Independent Primaries +Working Families Primaries +United Independent Party Primaries +Independent Primaries +Green Primaries +Advanced Search Special Elections Only? # Candidates: + += +>= +<= + +any +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 + % Victory: + +>= +<= + +any +10 +20 +30 +40 +50 +60 +70 +80 +90 +% % Closeness: + +>= +<= + +any +1 +3 +5 +10 +20 +30 +40 +50 +60 +70 +80 +90 +% Display OptionsShow candidatesEnlarge data visualizations + + + + + + // Global + var E_DATE_LIST_HTML = ''; + + e_init_date_year_toggle(); + e_adjustYearSelect($('#SearchOfficeId').val()); + + + function e_enableDistrictSelect(office_id) { + $("#search_form_elections .wrapper_for_district_list").hide(); + $("#search_form_elections .district_list").hide(); + $("#search_form_elections .district_list").each(function(n,v) { + $(this).attr("disabled",true); //"district_id_off"; + }); + var find_target = "#search_form_elections #districtListForOffice"+office_id; + + if($(find_target).length) { + + $("#search_form_elections #districtListForOffice"+office_id).attr("disabled",false); + $("#search_form_elections #districtListForOffice"+office_id).show() + $("#search_form_elections #wrapperForDistrictListForOffice"+office_id).show(); + } + } + + function e_enableStageSelect(office_id) { + $("#search_form_elections .wrapper_for_stage_list").hide(); + $("#search_form_elections .stage_list").hide(); + $("#search_form_elections .stage_list").each(function(n,v) { + $(this).attr("disabled",true); //"district_id_off"; + }); + var find_target = "#search_form_elections #stageListForOffice"+office_id; + + if($(find_target).length) { + + $("#search_form_elections #stageListForOffice"+office_id).attr("disabled",false); + $("#search_form_elections #stageListForOffice"+office_id).show() + $("#search_form_elections #wrapperForStageListForOffice"+office_id).show(); + } + } + + /** + * Choices of years in year dropdowns change according to the office selected. + * @param oid + * @return {boolean} + */ + + + + function e_adjustYearSelect(oid) { + + if(!E_DATE_LIST_HTML) { E_DATE_LIST_HTML = $('#SearchDate').html(); } + + if(!oid) { return false; } + + var valid_years = {"2026":"2026","2025":"2025","2024":"2024","2023":"2023","2022":"2022","2021":"2021","2020":"2020","2018":"2018","2017":"2017","2016":"2016","2015":"2015","2014":"2014","2013":"2013","2012":"2012","2011":"2011","2010":"2010","2009":"2009","2008":"2008","2007":"2007","2006":"2006","2005":"2005","2004":"2004","2003":"2003","2002":"2002","2001":"2001","2000":"2000","1999":"1999","1998":"1998","1997":"1997","1996":"1996","1995":"1995","1994":"1994","1993":"1993","1992":"1992","1991":"1991","1990":"1990","1988":"1988","1987":"1987","1986":"1986","1985":"1985","1984":"1984","1983":"1983","1982":"1982","1981":"1981","1980":"1980","1979":"1979","1978":"1978","1977":"1977","1976":"1976","1975":"1975","1974":"1974","1972":"1972","1971":"1971","1970":"1970"}; + var office_year_ranges = {"1":["1972","2024"],"521":["1972","2024"],"522":["1972","2024"],"543":["1972","1972"],"544":["1972","1972"],"6":["1970","2024"],"3":["1970","2022"],"4":["1970","2022"],"12":["1970","2022"],"45":["1970","2022"],"53":["1970","2022"],"90":["1970","2022"],"5":["1970","2024"],"529":["1970","2024"],"9":["1970","2026"],"8":["1970","2026"],"530":["1970","2022"],"15":["1970","2024"],"534":["1970","2024"],"535":["1970","2024"],"536":["1970","2024"],"532":["1986","2010"],"384":["1970","2024"],"386":["1970","2022"],"389":["1972","2020"],"434":["1974","1974"],"537":["1970","2024"],"531":["1998","2024"]}; + var target_year_range = office_year_ranges[oid]; + var current_selected_from = $('#SearchYearFrom').val(); + var current_selected_to = $('#SearchYearTo').val(); + var current_selected_date = $('#SearchDate').val(); + + var options_html = ''; + var has_currently_selected_from = false; + var has_currently_selected_to = false; + + var date_options = $(window.E_DATE_LIST_HTML); + + var target_date_options = []; + + for(y=target_year_range[1];y>=target_year_range[0];y--) { + if(typeof valid_years[y] == 'undefined') { continue; } + + options_html += "\n"+''; + + y == current_selected_from? has_currently_selected_from = true: ''; + y == current_selected_to? has_currently_selected_to = true: ''; + + date_options.each(function(k,v) { if($(v).val().substr(0,4) == y) { target_date_options.push(v); } }); + } + + // console.log(target_date_options.length); + + $('#SearchDate').html(target_date_options); + $('#SearchDate').val(current_selected_date).change(); + + $('#SearchYearFrom').html(options_html); + $('#SearchYearTo').html(options_html); + + $('#SearchYearFrom').val(has_currently_selected_from? current_selected_from: target_year_range[0]).change(); + $('#SearchYearTo').val(has_currently_selected_to? current_selected_to: target_year_range[1]).change(); + + + return true; + + } + + function e_sanitizeYearSelect(year) { + if($("#search_form_elections #SearchYearFrom").val() > $("#search_form_elections #SearchYearTo").val()) { + $("#search_form_elections #SearchYearTo").val( $("#search_form_elections #SearchYearFrom").val()); + } + } + // Turn on the correct District and Stage form on page load, if applicable + e_enableDistrictSelect($("#search_form_elections #SearchOfficeId").val()); + e_enableStageSelect($("#search_form_elections #SearchOfficeId").val()); + + // Bind event handler to office select + $("#search_form_elections #SearchOfficeId").change(function() { + + e_enableDistrictSelect($(this).val()); + e_enableStageSelect($(this).val()); + e_adjustYearSelect($(this).val()); + + }); + + $("#search_form_elections .search_controls form").submit(function() { + e_sanitizeYearSelect($(this).val()); + loadingSearchResults(); + return true; + }); + + + function e_init_date_year_toggle() { + // Toggle between inputs + var sel_year = $('#search_form_elections .input.select-year-range'); + var sel_date = $('#search_form_elections .input.select-date'); + sel_year.before(''); + sel_date.before(''); + + // Default + sel_date.hide().appendTo($('body')); + + if(sel_year.length && sel_date.length) { + if(!sel_year.find('.toggle').length) { + sel_year.append('By Date'); + sel_year.find('.toggle').on('click',this,function(e) { + sel_year.hide().appendTo($('body')); + sel_date.insertAfter($('a.e.keep-sel-date')); + sel_date.show(); + }); + } + if(!sel_date.find('.toggle').length) { + sel_date.append('By Years'); + sel_date.find('.toggle').on('click',this,function(e) { + sel_date.hide().appendTo($('body')); + sel_year.insertAfter($('a.e.keep-sel-year')); + sel_year.show(); + }); + } + } + + } // END function init_date_year_toggle() + + + + + + + + + //$.cookie.defaults.path = '/'; + //$.cookie.defaults.domain = '.electionstats.state.ma.us'; + + // Not working (from Cake Cookie component) + //var msg = $.cookie('CakeCookie[Flash][message]'); + //var key = $.cookie('CakeCookie[Flash][key]'); + + + + //console.log(document.cookie); + + + var msg = $.cookie('elstats_flash_message'); + var key = $.cookie('elstats_flash_key'); + + if(msg) { + key = $.trim(key.replace(/"/g,'')); + msg = msg.replace(/^"/,'').replace(/"$/,''); + + //.replace(/'/g, "\\'") + + var data = '
'+msg+'
'; + $('#flash_from_js_98523').after(data); + + // Optional: jQueryUI effects + if(msg.search(/denied/i) > -1) { + $('#'+key+'.flash-message-98523').delay(5000).fadeOut(); + } + + if(key == 'flash_error_fixed_to_top') { + window.setTimeout(function() { $('#'+key).fadeOut(3000); }, 10000); + } + + + // Now delete them. (this was set from AccessComponent::setFlash()) + $.removeCookie('elstats_flash_message',{domain:'.electionstats.state.ma.us',path:'/'}); + $.removeCookie('elstats_flash_key',{domain:'.electionstats.state.ma.us',path:'/'}); + + // Not working (from Cake Cookie component) + //$.removeCookie('CakeCookie[Flash][message]',{domain:'.electionstats.state.ma.us',path:'/'}); + //$.removeCookie('CakeCookie[Flash][key]',{domain:'.electionstats.state.ma.us',path:'/'}); + + + + //console.log('after flash:'); + //console.log(document.cookie); + + } + + + /** + $.post('/flash_from_js/',{},function(data) { + if(data) { + $('#flash_from_ajax_98523').after(data); + } + }); + **/ + + + +https://electionstats.state.ma.us/elections/view/165300/ +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 diff --git a/tests/fixtures/electionResults/2016_general.html b/tests/fixtures/electionResults/2016_general.html new file mode 100644 index 000000000..79ab4e92a --- /dev/null +++ b/tests/fixtures/electionResults/2016_general.html @@ -0,0 +1,20209 @@ + + + + + + + + PD43+ » Search Elections + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ +  + + + + +
+ + +
+ + + + + + + + + + + + + + +
+ + + + +
+ + + + + +
+ + +
+
+ + + + + +
+ + +
+
+ + + + + +
 
+
+ + + +
 
+ + +
+
+ + + + + + +
+ + + + + +
+
+ +
+ + + + + + +
+
+
+
 
+
+
+ + + + + + + + +
+
+ + + +
+
+ SHARE THIS DATA: + + + + + + + + + +
+
+ + + + + +
+ + + + + +

To visualize the data, specify a state-wide office or a district.

+
+
+ +
+
+ + +
+
+ +
Please wait...
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
YearOfficeDistrictStageCandidates
2016PresidentStatewideGeneral Election +
+Clinton and Kaine won + (60%) against 8 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016U.S. House1st CongressionalGeneral Election +
+Richard E. Neal won + (73%) against 2 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016U.S. House2nd CongressionalGeneral Election +
+James P. McGovern won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016U.S. House3rd CongressionalGeneral Election +
+Nicola S. Tsongas won + (69%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016U.S. House4th CongressionalGeneral Election +
+Joseph P. Kennedy, III won + (70%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016U.S. House5th CongressionalGeneral Election +
+Katherine M. Clark won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016U.S. House6th CongressionalGeneral Election +
+Seth W. Moulton won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016U.S. House7th CongressionalGeneral Election +
+Michael E. Capuano won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016U.S. House8th CongressionalGeneral Election +
+Stephen F. Lynch won + (72%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016U.S. House9th CongressionalGeneral Election +
+William Richard Keating won + (56%) against 4 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016Governor's Council 1stGeneral Election +
+Joseph C. Ferreira won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016Governor's Council 2ndGeneral Election +
+Robert L. Jubinville won + (62%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016Governor's Council 3rdGeneral Election +
+Marilyn Petitto Devaney won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016Governor's Council 4thGeneral Election +
+Christopher A. Iannella, Jr. won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016Governor's Council 5thGeneral Election +
+Eileen R. Duff won + (57%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016Governor's Council 6thGeneral Election +
+Terrence W. Kennedy won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016Governor's Council 7thGeneral Election +
+Jennie L. Caissie won + (55%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016Governor's Council 8thGeneral Election +
+Mary E. Hurley won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State SenateBerkshire, Hampshire and FranklinGeneral Election +
+Adam G. Hinds won + (70%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State SenateBristol and NorfolkGeneral Election +
+James E. Timilty won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate1st Bristol and PlymouthGeneral Election +
+Michael J. Rodrigues won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate2nd Bristol and PlymouthGeneral Election +
+Mark C. Montigny won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State SenateCape and IslandsGeneral Election +
+Julian Andre Cyr won + (57%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate1st EssexGeneral Election +
+Kathleen A. O'Connor Ives won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate2nd EssexGeneral Election +
+Joan B. Lovely won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate3rd EssexGeneral Election +
+Thomas M. McGee won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate1st Essex and MiddlesexGeneral Election +
+Bruce E. Tarr won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate2nd Essex and MiddlesexGeneral Election +
+Barbara A. L'Italien won + (63%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State SenateHampdenGeneral Election +
+James T. Welch won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate1st Hampden and HampshireGeneral Election +
+Eric P. Lesser won + (56%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate2nd Hampden and HampshireGeneral Election +
+Donald F. Humason, Jr won + (59%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State SenateHampshire, Franklin and WorcesterGeneral Election +
+Stanley C. Rosenberg won + (82%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate1st MiddlesexGeneral Election +
+Eileen M. Donoghue won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate2nd MiddlesexGeneral Election +
+Patricia D. Jehlen won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate3rd MiddlesexGeneral Election +
+Michael J. Barrett won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate4th MiddlesexGeneral Election +
+Kenneth J. Donnelly won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate5th MiddlesexGeneral Election +
+Jason M. Lewis won + (69%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate1st Middlesex and NorfolkGeneral Election +
+Cynthia Stone Creem won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate2nd Middlesex and NorfolkGeneral Election +
+Karen E. Spilka won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State SenateMiddlesex and WorcesterGeneral Election +
+James B. Eldridge won + (64%) against 2 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State SenateMiddlesex and SuffolkGeneral Election +
+Sal N. DiDomenico won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State SenateNorfolk, Bristol and MiddlesexGeneral Election +
+Richard J. Ross won + (60%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State SenateNorfolk, Bristol and PlymouthGeneral Election +
+Walter F. Timilty, Jr. won + (74%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State SenateNorfolk and PlymouthGeneral Election +
+John F. Keenan won + (71%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State SenateNorfolk and SuffolkGeneral Election +
+Michael F. Rush won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State SenatePlymouth and BarnstableGeneral Election +
+Vinny M. deMacedo won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State SenatePlymouth and NorfolkGeneral Election +
+Patrick M. O'Connor won + (57%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State SenatePlymouth and NorfolkSpecial General Election +
+Patrick M. O'Connor won + (53%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate1st Plymouth and BristolGeneral Election +
+Marc R. Pacheco won + (65%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate2nd Plymouth and BristolGeneral Election +
+Michael D. Brady won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate1st SuffolkGeneral Election +
+Linda Dorcena Forry won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate2nd SuffolkGeneral Election +
+Sonia Rosa Chang-Díaz won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate1st Suffolk and MiddlesexGeneral Election +
+Joseph A. Boncore won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate1st Suffolk and MiddlesexSpecial General Election +
+Joseph A. Boncore won + (89%) against 2 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate2nd Suffolk and MiddlesexGeneral Election +
+William N. Brownsberger won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State SenateWorcester and MiddlesexGeneral Election +
+Jennifer L. Flanagan won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State SenateWorcester and NorfolkGeneral Election +
+Ryan C. Fattman won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State SenateWorcester, Hampden, Hampshire and MiddlesexGeneral Election +
+Anne M. Gobi won + (54%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate1st WorcesterGeneral Election +
+Harriette L. Chandler won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Senate2nd WorcesterGeneral Election +
+Michael O. Moore won + (74%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative1st BarnstableGeneral Election +
+Timothy R. Whelan won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative2nd BarnstableGeneral Election +
+William L. Crocker, Jr. won + (55%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative3rd BarnstableGeneral Election +
+David T. Vieira won + (53%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative4th BarnstableGeneral Election +
+Sarah K. Peake won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative5th BarnstableGeneral Election +
+Randy Hunt won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State RepresentativeBarnstable, Dukes and NantucketGeneral Election +
+Dylan A. Fernandes won + (52%) against 2 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative1st BerkshireGeneral Election +
+Gailanne M. Cariddi won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative2nd BerkshireGeneral Election +
+Paul W. Mark won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative3rd BerkshireGeneral Election +
+Tricia Farley-Bouvier won + (66%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative4th BerkshireGeneral Election +
+William "Smitty" Pignatelli won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative1st BristolGeneral Election +
+Fred Jay Barrows won + (60%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative2nd BristolGeneral Election +
+Paul R. Heroux won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative3rd BristolGeneral Election +
+Shaunna L. O'Connell won + (59%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative4th BristolGeneral Election +
+Steven S. Howitt won + (62%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative5th BristolGeneral Election +
+Patricia A. Haddad won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative6th BristolGeneral Election +
+Carole A. Fiola won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative7th BristolGeneral Election +
+Alan Silvia won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative8th BristolGeneral Election +
+Paul Schmid, III won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative9th BristolGeneral Election +
+Christopher M. Markey won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative10th BristolGeneral Election +
+William M. Straus won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative11th BristolGeneral Election +
+Robert M. Koczera won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative12th BristolGeneral Election +
+Keiko M. Orrall won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative13th BristolGeneral Election +
+Antonio F.D. Cabral won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative14th BristolGeneral Election +
+Elizabeth A. Poirier won + (70%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative1st EssexGeneral Election +
+James M. Kelcourse won + (54%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative2nd EssexGeneral Election +
+Leonard Mirra won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative3rd EssexGeneral Election +
+Brian S. Dempsey won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative4th EssexGeneral Election +
+Bradford R. Hill won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative5th EssexGeneral Election +
+Ann-Margaret Ferrante won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative6th EssexGeneral Election +
+Jerald A. Parisella won + (75%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative7th EssexGeneral Election +
+Paul F. Tucker won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative8th EssexGeneral Election +
+Lori A. Ehrlich won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative9th EssexGeneral Election +
+Donald H. Wong won + (55%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative10th EssexGeneral Election +
+Daniel H. Cahill won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative10th EssexSpecial General Election +
+Daniel H. Cahill won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative11th EssexGeneral Election +
+Brendan P. Crighton won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative12th EssexSpecial General Election +
+Thomas P. Walsh won + (57%) against 2 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative12th EssexGeneral Election +
+Thomas P. Walsh won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative13th EssexGeneral Election +
+Theodore C. Speliotis won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative14th EssexGeneral Election +
+Diana DiZoglio won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative15th EssexGeneral Election +
+Linda Dean Campbell won + (63%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative16th EssexGeneral Election +
+Juana B. Matias won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative17th EssexGeneral Election +
+Frank A. Moran won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative18th EssexGeneral Election +
+James J. Lyons, Jr. won + (60%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative1st FranklinGeneral Election +
+Stephen Kulik won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative2nd FranklinGeneral Election +
+Susannah M. Whipps Lee won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative1st HampdenGeneral Election +
+Todd M. Smola won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative2nd HampdenGeneral Election +
+Brian M. Ashe won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative3rd HampdenGeneral Election +
+Nicholas A. Boldyga won + (60%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative4th HampdenGeneral Election +
+John C. Velis won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative5th HampdenGeneral Election +
+Aaron Vega won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative6th HampdenGeneral Election +
+Michael J. Finn won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative7th HampdenGeneral Election +
+Thomas M. Petrolati won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative8th HampdenGeneral Election +
+Joseph F. Wagner won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative9th HampdenGeneral Election +
+Jose F. Tosado won + (80%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative10th HampdenGeneral Election +
+Carlos Gonzalez won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative11th HampdenGeneral Election +
+Bud L. Williams won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative12th HampdenGeneral Election +
+Angelo J. Puppolo, Jr. won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative1st HampshireGeneral Election +
+Peter V. Kocot won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative2nd HampshireGeneral Election +
+John W. Scibak won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative3rd HampshireGeneral Election +
+Solomon Israel Goldstein-Rose won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative1st MiddlesexGeneral Election +
+Sheila C. Harrington won + (65%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative2nd MiddlesexGeneral Election +
+James Arciero won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative3rd MiddlesexGeneral Election +
+Kate Hogan won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative4th MiddlesexGeneral Election +
+Danielle W. Gregoire won + (60%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative5th MiddlesexGeneral Election +
+David Paul Linsky won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative6th MiddlesexGeneral Election +
+Chris Walsh won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative7th MiddlesexGeneral Election +
+Jack Patrick Lewis won + (62%) against 2 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative8th MiddlesexGeneral Election +
+Carolyn C. Dykema won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative9th MiddlesexGeneral Election +
+Thomas M. Stanley won + (63%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative10th MiddlesexGeneral Election +
+John J. Lawn, Jr. won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative11th MiddlesexGeneral Election +
+Kay S. Khan won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative12th MiddlesexGeneral Election +
+Ruth B. Balser won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative13th MiddlesexGeneral Election +
+Carmine Lawrence Gentile won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative14th MiddlesexGeneral Election +
+Cory Atkins won + (60%) against 2 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative15th MiddlesexGeneral Election +
+Jay R. Kaufman won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative16th MiddlesexGeneral Election +
+Thomas A. Golden, Jr. won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative17th MiddlesexGeneral Election +
+David M. Nangle won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative18th MiddlesexGeneral Election +
+Rady Mom won + (72%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative19th MiddlesexGeneral Election +
+James R. Miceli won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative20th MiddlesexGeneral Election +
+Bradley H. Jones, Jr won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative21st MiddlesexGeneral Election +
+Kenneth I. Gordon won + (59%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative22nd MiddlesexGeneral Election +
+Marc T. Lombardo won + (59%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative23rd MiddlesexGeneral Election +
+Sean Garballey won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative24th MiddlesexGeneral Election +
+David M. Rogers won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative25th MiddlesexGeneral Election +
+Marjorie C. Decker won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative26th MiddlesexGeneral Election +
+Mike Connolly won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative27th MiddlesexGeneral Election +
+Denise Provost won + (87%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative28th MiddlesexGeneral Election +
+Joseph W. Mcgonagle, Jr won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative29th MiddlesexGeneral Election +
+Jonathan Hecht won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative30th MiddlesexGeneral Election +
+James J. Dwyer won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative31st MiddlesexGeneral Election +
+Michael Seamus Day won + (59%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative32nd MiddlesexGeneral Election +
+Paul A. Brodeur won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative33rd MiddlesexGeneral Election +
+Steven Ultrino won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative34th MiddlesexGeneral Election +
+Christine P. Barber won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative35th MiddlesexGeneral Election +
+Paul J. Donato, Sr. won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative36th MiddlesexGeneral Election +
+Colleen M. Garry won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative37th MiddlesexGeneral Election +
+Jennifer E. Benson won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative1st NorfolkGeneral Election +
+Bruce J. Ayers won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative2nd NorfolkGeneral Election +
+Tackey Chan won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative3rd NorfolkGeneral Election +
+Ronald Mariano won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative4th NorfolkGeneral Election +
+James Michael Murphy won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative5th NorfolkGeneral Election +
+Mark J. Cusack won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative6th NorfolkGeneral Election +
+William C. Galvin won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative7th NorfolkGeneral Election +
+William J. Driscoll, Jr won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative8th NorfolkGeneral Election +
+Louis L. Kafka won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative9th NorfolkGeneral Election +
+Shawn C. Dooley won + (61%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative10th NorfolkGeneral Election +
+Jeffrey N. Roy won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative11th NorfolkGeneral Election +
+Paul McMurtry won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative12th NorfolkGeneral Election +
+John H. Rogers won + (66%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative13th NorfolkGeneral Election +
+Denise C. Garlick won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative14th NorfolkGeneral Election +
+Alice Hanlon Peisch won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative15th NorfolkGeneral Election +
+Frank Israel Smizik won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative1st PlymouthGeneral Election +
+Mathew J. Muratore won + (51%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative2nd PlymouthGeneral Election +
+Susan Williams Gifford won + (58%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative3rd PlymouthGeneral Election +
+Joan Meschino won + (54%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative4th PlymouthGeneral Election +
+James M. Cantwell won + (70%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative5th PlymouthGeneral Election +
+David F. Decoste won + (51%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative6th PlymouthGeneral Election +
+Josh S. Cutler won + (64%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative7th PlymouthGeneral Election +
+Geoff Diehl won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative8th PlymouthGeneral Election +
+Angelo L. D'Emilia won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative9th PlymouthSpecial General Election +
+Gerard J. Cassidy won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative9th PlymouthGeneral Election +
+Gerard J. Cassidy won + (86%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative10th PlymouthGeneral Election +
+Michelle M. Dubois won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative11th PlymouthGeneral Election +
+Claire D. Cronin won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative12th PlymouthGeneral Election +
+Thomas J. Calter, III won + (60%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative1st SuffolkGeneral Election +
+Adrian C. Madaro won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative2nd SuffolkGeneral Election +
+Daniel Joseph Ryan won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative3rd SuffolkGeneral Election +
+Aaron M. Michlewitz won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative4th SuffolkGeneral Election +
+Nick Collins won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative5th SuffolkGeneral Election +
+Evandro C. Carvalho won + (84%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative6th SuffolkGeneral Election +
+Russell E. Holmes won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative7th SuffolkGeneral Election +
+Chynah Tyler won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative8th SuffolkGeneral Election +
+Jay D. Livingstone won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative9th SuffolkGeneral Election +
+Byron Rushing won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative10th SuffolkGeneral Election +
+Edward F. Coppinger won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative11th SuffolkGeneral Election +
+Elizabeth A. Malia won + (89%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative12th SuffolkGeneral Election +
+Dan Cullinane won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative13th SuffolkGeneral Election +
+Daniel J. Hunt won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative14th SuffolkGeneral Election +
+Angelo M. Scaccia won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative15th SuffolkGeneral Election +
+Jeffrey Sanchez won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative16th SuffolkGeneral Election +
+Roselee Vincent won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative17th SuffolkGeneral Election +
+Kevin G. Honan won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative18th SuffolkGeneral Election +
+Michael J. Moran won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative19th SuffolkGeneral Election +
+Robert A. DeLeo won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative1st WorcesterGeneral Election +
+Kimberly N. Ferguson won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative2nd WorcesterGeneral Election +
+Jonathan D. Zlotnik won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative3rd WorcesterSpecial General Election +
+Stephan Hay won + (51%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative3rd WorcesterGeneral Election +
+Stephan Hay won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative4th WorcesterGeneral Election +
+Natalie Higgins won + (55%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative5th WorcesterGeneral Election +
+Donald R. Berthiaume, Jr won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative6th WorcesterGeneral Election +
+Peter J. Durant won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative7th WorcesterGeneral Election +
+Paul K. Frost won + (66%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative8th WorcesterGeneral Election +
+Kevin J. Kuros won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative9th WorcesterGeneral Election +
+David K. Muradian, Jr won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative10th WorcesterGeneral Election +
+Brian W. Murray won + (55%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative11th WorcesterGeneral Election +
+Hannah E. Kane won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative12th WorcesterGeneral Election +
+Harold P. Naughton, Jr. won + (80%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative13th WorcesterGeneral Election +
+John J. Mahoney, Jr. won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative14th WorcesterGeneral Election +
+James J. O'Day won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative15th WorcesterGeneral Election +
+Mary S. Keefe won + (78%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative16th WorcesterGeneral Election +
+Daniel M. Donahue won + (67%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative17th WorcesterGeneral Election +
+Kate D. Campanale won + (54%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016State Representative18th WorcesterGeneral Election +
+Joseph D. Mckenna won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016District AttorneyBristolGeneral Election +
+Thomas M. Quinn, III won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016Register of DeedsDukesGeneral Election +
+Paulo C. Deoliveira won + (77%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016Register of DeedsSuffolkGeneral Election +
+Stephen J. Murphy won + (72%) against 3 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016SheriffBarnstable CountyGeneral Election +
+James M. Cummings won + (59%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016SheriffBerkshire CountyGeneral Election +
+Thomas N. Bowler won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016SheriffBristol CountyGeneral Election +
+Thomas M. Hodgson won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016SheriffDukes CountyGeneral Election +
+Robert Ogden won + (64%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016SheriffEssex CountyGeneral Election +
+Kevin F. Coppinger won + (51%) against 3 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016SheriffFranklin CountyGeneral Election +
+Christopher J. Donelan won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016SheriffHampden CountyGeneral Election +
+Nick Cocchi won + (68%) against 2 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016SheriffHampshire CountyGeneral Election +
+Patrick J. Cahillane won + (76%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016SheriffMiddlesex CountyGeneral Election +
+Peter J. Koutoujian won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016SheriffNantucket CountyGeneral Election +
+James A. Perelman won + (88%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016SheriffNorfolk CountyGeneral Election +
+Michael G. Bellotti won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016SheriffPlymouth CountyGeneral Election +
+Joseph D. McDonald, Jr. won + (56%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016SheriffSuffolk CountyGeneral Election +
+Steven W. Tompkins won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016SheriffWorcester CountyGeneral Election +
+Lewis G. Evangelidis won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2016Council of Governments Executive CommitteeFranklin CountyGeneral Election +
+Jay D. Dipucchio won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
 
+ +
+ + + + + + + +
+ + + + + + + + + + + +
+
+
+ +
+ +
+ +
+
+
+ + + + + + + + + + + + + diff --git a/tests/fixtures/electionResults/2016_general.json b/tests/fixtures/electionResults/2016_general.json new file mode 100644 index 000000000..5bc1b6886 --- /dev/null +++ b/tests/fixtures/electionResults/2016_general.json @@ -0,0 +1,6497 @@ +[ + { + "id": 130243, + "year": 2016, + "office": "President", + "districts": "Statewide", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Clinton-and-Kaine", + "https://electionstats.state.ma.us/candidates/view/Trump-and-Pence", + "https://electionstats.state.ma.us/candidates/view/Johnson-and-Weld", + "https://electionstats.state.ma.us/candidates/view/Stein-and-Baraka", + "https://electionstats.state.ma.us/candidates/view/Mcmullin-and-Johnson", + "https://electionstats.state.ma.us/candidates/view/Kotlikoff-and-Leamer", + "https://electionstats.state.ma.us/candidates/view/Feegbeh-and-OBrien", + "https://electionstats.state.ma.us/candidates/view/Moorehead-and-Lilly", + "https://electionstats.state.ma.us/candidates/view/Schoenke-and-Mitchel" + ], + "result": { + "candidates": [ + { + "name": "Clinton and Kaine", + "votes": 1995196, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Trump and Pence", + "votes": 1090893, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Johnson and Weld", + "votes": 138018, + "writeIn": false, + "party": "Libertarian" + }, + { + "name": "Stein and Baraka", + "votes": 47661, + "writeIn": false, + "party": "Green-rainbow" + }, + { + "name": "Mcmullin and Johnson", + "votes": 2719, + "writeIn": true + }, + { + "name": "Kotlikoff and Leamer", + "votes": 28, + "writeIn": true + }, + { + "name": "Feegbeh and O'Brien", + "votes": 28, + "writeIn": true + }, + { + "name": "Moorehead and Lilly", + "votes": 15, + "writeIn": true + }, + { + "name": "Schoenke and Mitchel", + "votes": 0, + "writeIn": true + } + ], + "otherVotes": 50488, + "blankVotes": 53755, + "totalVotes": 3378801 + } + }, + { + "id": 130262, + "year": 2016, + "office": "U.S. House", + "districts": "1st Congressional", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Richard-E-Neal", + "https://electionstats.state.ma.us/candidates/view/Frederick-O-Mayock", + "https://electionstats.state.ma.us/candidates/view/Thomas-T-Simmons" + ], + "result": { + "candidates": [ + { + "name": "Richard E. Neal", + "votes": 235803, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Frederick O. Mayock", + "votes": 57504, + "writeIn": false + }, + { + "name": "Thomas T. Simmons", + "votes": 27511, + "writeIn": false, + "party": "Libertarian" + } + ], + "otherVotes": 721, + "blankVotes": 28137, + "totalVotes": 349676 + } + }, + { + "id": 130275, + "year": 2016, + "office": "U.S. House", + "districts": "2nd Congressional", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/James-P-McGovern" + ], + "result": { + "candidates": [ + { + "name": "James P. McGovern", + "votes": 275487, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 4924, + "blankVotes": 82786, + "totalVotes": 363197 + } + }, + { + "id": 130249, + "year": 2016, + "office": "U.S. House", + "districts": "3rd Congressional", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Nicola-S-Tsongas", + "https://electionstats.state.ma.us/candidates/view/Ann-Wofford" + ], + "result": { + "candidates": [ + { + "name": "Nicola S. Tsongas", + "votes": 236713, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Ann Wofford", + "votes": 107519, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 360, + "blankVotes": 15532, + "totalVotes": 360124 + } + }, + { + "id": 130302, + "year": 2016, + "office": "U.S. House", + "districts": "4th Congressional", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Joseph-P-Kennedy-III", + "https://electionstats.state.ma.us/candidates/view/David-A-Rosa" + ], + "result": { + "candidates": [ + { + "name": "Joseph P. Kennedy, III", + "votes": 265823, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "David A. Rosa", + "votes": 113055, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 335, + "blankVotes": 16091, + "totalVotes": 395304 + } + }, + { + "id": 130286, + "year": 2016, + "office": "U.S. House", + "districts": "5th Congressional", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Katherine-M-Clark" + ], + "result": { + "candidates": [ + { + "name": "Katherine M. Clark", + "votes": 285606, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 4201, + "blankVotes": 95648, + "totalVotes": 385455 + } + }, + { + "id": 130271, + "year": 2016, + "office": "U.S. House", + "districts": "6th Congressional", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Seth-W-Moulton" + ], + "result": { + "candidates": [ + { + "name": "Seth W. Moulton", + "votes": 308923, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 5132, + "blankVotes": 101694, + "totalVotes": 415749 + } + }, + { + "id": 130337, + "year": 2016, + "office": "U.S. House", + "districts": "7th Congressional", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-E-Capuano" + ], + "result": { + "candidates": [ + { + "name": "Michael E. Capuano", + "votes": 253354, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 3557, + "blankVotes": 52734, + "totalVotes": 309645 + } + }, + { + "id": 130244, + "year": 2016, + "office": "U.S. House", + "districts": "8th Congressional", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Stephen-F-Lynch", + "https://electionstats.state.ma.us/candidates/view/William-Burke" + ], + "result": { + "candidates": [ + { + "name": "Stephen F. Lynch", + "votes": 271019, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "William Burke", + "votes": 102744, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 502, + "blankVotes": 21744, + "totalVotes": 396009 + } + }, + { + "id": 130257, + "year": 2016, + "office": "U.S. House", + "districts": "9th Congressional", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/William-Richard-Keating", + "https://electionstats.state.ma.us/candidates/view/Mark-C-Alliegro", + "https://electionstats.state.ma.us/candidates/view/Paul-J-Harrington", + "https://electionstats.state.ma.us/candidates/view/Christopher-D-Cataldo", + "https://electionstats.state.ma.us/candidates/view/Anna-Grace-Raduc" + ], + "result": { + "candidates": [ + { + "name": "William Richard Keating", + "votes": 211790, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Mark C. Alliegro", + "votes": 127803, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Paul J. Harrington", + "votes": 26233, + "writeIn": false + }, + { + "name": "Christopher D. Cataldo", + "votes": 8338, + "writeIn": false + }, + { + "name": "Anna Grace Raduc", + "votes": 5320, + "writeIn": false + } + ], + "otherVotes": 411, + "blankVotes": 23747, + "totalVotes": 403642 + } + }, + { + "id": 130254, + "year": 2016, + "office": "Governor's Council", + "districts": "1st", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Joseph-C-Ferreira" + ], + "result": { + "candidates": [ + { + "name": "Joseph C. Ferreira", + "votes": 304970, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 2766, + "blankVotes": 130377, + "totalVotes": 438113 + } + }, + { + "id": 130298, + "year": 2016, + "office": "Governor's Council", + "districts": "2nd", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Robert-L-Jubinville", + "https://electionstats.state.ma.us/candidates/view/Brad-Williams" + ], + "result": { + "candidates": [ + { + "name": "Robert L. Jubinville", + "votes": 239729, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Brad Williams", + "votes": 149441, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 491, + "blankVotes": 55723, + "totalVotes": 445384 + } + }, + { + "id": 130248, + "year": 2016, + "office": "Governor's Council", + "districts": "3rd", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Marilyn-Petitto-Devaney" + ], + "result": { + "candidates": [ + { + "name": "Marilyn Petitto Devaney", + "votes": 317010, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 4429, + "blankVotes": 126144, + "totalVotes": 447583 + } + }, + { + "id": 130241, + "year": 2016, + "office": "Governor's Council", + "districts": "4th", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Christopher-A-Iannella-Jr" + ], + "result": { + "candidates": [ + { + "name": "Christopher A. Iannella, Jr.", + "votes": 298606, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 3316, + "blankVotes": 103132, + "totalVotes": 405054 + } + }, + { + "id": 130270, + "year": 2016, + "office": "Governor's Council", + "districts": "5th", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Eileen-R-Duff", + "https://electionstats.state.ma.us/candidates/view/Richard-A-Baker-Jr" + ], + "result": { + "candidates": [ + { + "name": "Eileen R. Duff", + "votes": 226341, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Richard A. Baker, Jr.", + "votes": 169502, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 576, + "blankVotes": 46098, + "totalVotes": 442517 + } + }, + { + "id": 130335, + "year": 2016, + "office": "Governor's Council", + "districts": "6th", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Terrence-W-Kennedy" + ], + "result": { + "candidates": [ + { + "name": "Terrence W. Kennedy", + "votes": 296092, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 3853, + "blankVotes": 108867, + "totalVotes": 408812 + } + }, + { + "id": 130290, + "year": 2016, + "office": "Governor's Council", + "districts": "7th", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jennie-L-Caissie", + "https://electionstats.state.ma.us/candidates/view/Matthew-Cj-Vance" + ], + "result": { + "candidates": [ + { + "name": "Jennie L. Caissie", + "votes": 203165, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Matthew Cj Vance", + "votes": 162521, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 614, + "blankVotes": 43270, + "totalVotes": 409570 + } + }, + { + "id": 130261, + "year": 2016, + "office": "Governor's Council", + "districts": "8th", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Mary-E-Hurley" + ], + "result": { + "candidates": [ + { + "name": "Mary E. Hurley", + "votes": 290544, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 3499, + "blankVotes": 87725, + "totalVotes": 381768 + } + }, + { + "id": 130264, + "year": 2016, + "office": "State Senate", + "districts": "Berkshire, Hampshire and Franklin", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Adam-G-Hinds", + "https://electionstats.state.ma.us/candidates/view/Christine-M-Canning" + ], + "result": { + "candidates": [ + { + "name": "Adam G. Hinds", + "votes": 53216, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Christine M. Canning", + "votes": 22624, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 62, + "blankVotes": 5886, + "totalVotes": 81788 + } + }, + { + "id": 130305, + "year": 2016, + "office": "State Senate", + "districts": "Bristol and Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/James-E-Timilty" + ], + "result": { + "candidates": [ + { + "name": "James E. Timilty", + "votes": 66611, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 445, + "blankVotes": 22381, + "totalVotes": 89437 + } + }, + { + "id": 130412, + "year": 2016, + "office": "State Senate", + "districts": "1st Bristol and Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-J-Rodrigues" + ], + "result": { + "candidates": [ + { + "name": "Michael J. Rodrigues", + "votes": 54618, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 355, + "blankVotes": 19358, + "totalVotes": 74331 + } + }, + { + "id": 130259, + "year": 2016, + "office": "State Senate", + "districts": "2nd Bristol and Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Mark-C-Montigny" + ], + "result": { + "candidates": [ + { + "name": "Mark C. Montigny", + "votes": 53882, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 526, + "blankVotes": 14542, + "totalVotes": 68950 + } + }, + { + "id": 130284, + "year": 2016, + "office": "State Senate", + "districts": "Cape and Islands", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Julian-Andre-Cyr", + "https://electionstats.state.ma.us/candidates/view/Anthony-E-Schiavi" + ], + "result": { + "candidates": [ + { + "name": "Julian Andre Cyr", + "votes": 59974, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Anthony E. Schiavi", + "votes": 45349, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 94, + "blankVotes": 6697, + "totalVotes": 112114 + } + }, + { + "id": 130273, + "year": 2016, + "office": "State Senate", + "districts": "1st Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Kathleen-Oconnor-A-Ives" + ], + "result": { + "candidates": [ + { + "name": "Kathleen A. O'Connor Ives", + "votes": 65131, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 622, + "blankVotes": 26105, + "totalVotes": 91858 + } + }, + { + "id": 130332, + "year": 2016, + "office": "State Senate", + "districts": "2nd Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Joan-B-Lovely" + ], + "result": { + "candidates": [ + { + "name": "Joan B. Lovely", + "votes": 67120, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 772, + "blankVotes": 25073, + "totalVotes": 92965 + } + }, + { + "id": 130441, + "year": 2016, + "office": "State Senate", + "districts": "3rd Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Thomas-M-McGee" + ], + "result": { + "candidates": [ + { + "name": "Thomas M. McGee", + "votes": 58844, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 545, + "blankVotes": 21632, + "totalVotes": 81021 + } + }, + { + "id": 130364, + "year": 2016, + "office": "State Senate", + "districts": "1st Essex and Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Bruce-E-Tarr" + ], + "result": { + "candidates": [ + { + "name": "Bruce E. Tarr", + "votes": 77851, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 647, + "blankVotes": 27087, + "totalVotes": 105585 + } + }, + { + "id": 130281, + "year": 2016, + "office": "State Senate", + "districts": "2nd Essex and Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Barbara-A-LItalien", + "https://electionstats.state.ma.us/candidates/view/Susan-M-Laplante" + ], + "result": { + "candidates": [ + { + "name": "Barbara A. L'Italien", + "votes": 45218, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Susan M. Laplante", + "votes": 25973, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 100, + "blankVotes": 6863, + "totalVotes": 78154 + } + }, + { + "id": 130389, + "year": 2016, + "office": "State Senate", + "districts": "Hampden", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/James-T-Welch" + ], + "result": { + "candidates": [ + { + "name": "James T. Welch", + "votes": 45061, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 782, + "blankVotes": 10328, + "totalVotes": 56171 + } + }, + { + "id": 130322, + "year": 2016, + "office": "State Senate", + "districts": "1st Hampden and Hampshire", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Eric-Philip-Lesser", + "https://electionstats.state.ma.us/candidates/view/James-Chip-Harrington" + ], + "result": { + "candidates": [ + { + "name": "Eric P. Lesser", + "votes": 44602, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "James Chip Harrington", + "votes": 35188, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 100, + "blankVotes": 4315, + "totalVotes": 84205 + } + }, + { + "id": 130267, + "year": 2016, + "office": "State Senate", + "districts": "2nd Hampden and Hampshire", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Donald-F-Humason-Jr", + "https://electionstats.state.ma.us/candidates/view/Jerome-Parker-Ogrady" + ], + "result": { + "candidates": [ + { + "name": "Donald F. Humason, Jr", + "votes": 43097, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Jerome Parker-O'grady", + "votes": 29285, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 67, + "blankVotes": 5497, + "totalVotes": 77946 + } + }, + { + "id": 130277, + "year": 2016, + "office": "State Senate", + "districts": "Hampshire, Franklin and Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Stanley-C-Rosenberg", + "https://electionstats.state.ma.us/candidates/view/Donald-Peltier" + ], + "result": { + "candidates": [ + { + "name": "Stanley C. Rosenberg", + "votes": 62286, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Donald Peltier", + "votes": 13908, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 105, + "blankVotes": 5359, + "totalVotes": 81658 + } + }, + { + "id": 130399, + "year": 2016, + "office": "State Senate", + "districts": "1st Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Eileen-M-Donoghue" + ], + "result": { + "candidates": [ + { + "name": "Eileen M. Donoghue", + "votes": 56759, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 819, + "blankVotes": 16377, + "totalVotes": 73955 + } + }, + { + "id": 130379, + "year": 2016, + "office": "State Senate", + "districts": "2nd Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Patricia-D-Jehlen" + ], + "result": { + "candidates": [ + { + "name": "Patricia D. Jehlen", + "votes": 75777, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1146, + "blankVotes": 19706, + "totalVotes": 96629 + } + }, + { + "id": 130320, + "year": 2016, + "office": "State Senate", + "districts": "3rd Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-J-Barrett" + ], + "result": { + "candidates": [ + { + "name": "Michael J. Barrett", + "votes": 64554, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 822, + "blankVotes": 27888, + "totalVotes": 93264 + } + }, + { + "id": 130289, + "year": 2016, + "office": "State Senate", + "districts": "4th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Kenneth-J-Donnelly" + ], + "result": { + "candidates": [ + { + "name": "Kenneth J. Donnelly", + "votes": 68540, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 675, + "blankVotes": 28221, + "totalVotes": 97436 + } + }, + { + "id": 130446, + "year": 2016, + "office": "State Senate", + "districts": "5th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jason-M-Lewis", + "https://electionstats.state.ma.us/candidates/view/Vincent-Lawrence-Dixon" + ], + "result": { + "candidates": [ + { + "name": "Jason M. Lewis", + "votes": 52954, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Vincent Lawrence Dixon", + "votes": 23628, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 290, + "blankVotes": 13939, + "totalVotes": 90811 + } + }, + { + "id": 130375, + "year": 2016, + "office": "State Senate", + "districts": "1st Middlesex and Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Cynthia-Stone-Creem" + ], + "result": { + "candidates": [ + { + "name": "Cynthia Stone Creem", + "votes": 62668, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 647, + "blankVotes": 24341, + "totalVotes": 87656 + } + }, + { + "id": 130300, + "year": 2016, + "office": "State Senate", + "districts": "2nd Middlesex and Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Karen-E-Spilka" + ], + "result": { + "candidates": [ + { + "name": "Karen E. Spilka", + "votes": 61230, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 947, + "blankVotes": 21552, + "totalVotes": 83729 + } + }, + { + "id": 130252, + "year": 2016, + "office": "State Senate", + "districts": "Middlesex and Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/James-B-Eldridge", + "https://electionstats.state.ma.us/candidates/view/Ted-Busiek", + "https://electionstats.state.ma.us/candidates/view/Terra-Friedrichs" + ], + "result": { + "candidates": [ + { + "name": "James B. Eldridge", + "votes": 55698, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Ted Busiek", + "votes": 26865, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Terra Friedrichs", + "votes": 4033, + "writeIn": false + } + ], + "otherVotes": 117, + "blankVotes": 8927, + "totalVotes": 95640 + } + }, + { + "id": 130357, + "year": 2016, + "office": "State Senate", + "districts": "Middlesex and Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Sal-N-DiDomenico" + ], + "result": { + "candidates": [ + { + "name": "Sal N. DiDomenico", + "votes": 53022, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 577, + "blankVotes": 15098, + "totalVotes": 68697 + } + }, + { + "id": 130306, + "year": 2016, + "office": "State Senate", + "districts": "Norfolk, Bristol and Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Richard-J-Ross", + "https://electionstats.state.ma.us/candidates/view/Kristopher-K-Aleksov" + ], + "result": { + "candidates": [ + { + "name": "Richard J. Ross", + "votes": 49776, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Kristopher K. Aleksov", + "votes": 33083, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 95, + "blankVotes": 9626, + "totalVotes": 92580 + } + }, + { + "id": 130311, + "year": 2016, + "office": "State Senate", + "districts": "Norfolk, Bristol and Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Walter-F-Timilty-Jr", + "https://electionstats.state.ma.us/candidates/view/Jonathan-D-Lott" + ], + "result": { + "candidates": [ + { + "name": "Walter F. Timilty, Jr.", + "votes": 56466, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Jonathan D. Lott", + "votes": 19960, + "writeIn": false + } + ], + "otherVotes": 167, + "blankVotes": 12071, + "totalVotes": 88664 + } + }, + { + "id": 130246, + "year": 2016, + "office": "State Senate", + "districts": "Norfolk and Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-F-Keenan", + "https://electionstats.state.ma.us/candidates/view/Alexander-N-Mendez" + ], + "result": { + "candidates": [ + { + "name": "John F. Keenan", + "votes": 50148, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Alexander N. Mendez", + "votes": 20433, + "writeIn": false + } + ], + "otherVotes": 71, + "blankVotes": 9436, + "totalVotes": 80088 + } + }, + { + "id": 130358, + "year": 2016, + "office": "State Senate", + "districts": "Norfolk and Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-F-Rush" + ], + "result": { + "candidates": [ + { + "name": "Michael F. Rush", + "votes": 66792, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 867, + "blankVotes": 23315, + "totalVotes": 90974 + } + }, + { + "id": 130362, + "year": 2016, + "office": "State Senate", + "districts": "Plymouth and Barnstable", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Vinny-M-deMacedo" + ], + "result": { + "candidates": [ + { + "name": "Vinny M. deMacedo", + "votes": 69474, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 508, + "blankVotes": 25623, + "totalVotes": 95605 + } + }, + { + "id": 130391, + "year": 2016, + "office": "State Senate", + "districts": "Plymouth and Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Patrick-M-OConnor", + "https://electionstats.state.ma.us/candidates/view/Paul-J-Gannon" + ], + "result": { + "candidates": [ + { + "name": "Patrick M. O'Connor", + "votes": 52440, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Paul J. Gannon", + "votes": 40193, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 140, + "blankVotes": 8429, + "totalVotes": 101202 + } + }, + { + "id": 130483, + "year": 2016, + "office": "State Senate", + "districts": "Plymouth and Norfolk", + "party": "General", + "special": true, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Patrick-M-OConnor", + "https://electionstats.state.ma.us/candidates/view/Joan-Meschino" + ], + "result": { + "candidates": [ + { + "name": "Patrick M. O'Connor", + "votes": 9047, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Joan Meschino", + "votes": 8108, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 30, + "blankVotes": 30, + "totalVotes": 17215 + } + }, + { + "id": 130327, + "year": 2016, + "office": "State Senate", + "districts": "1st Plymouth and Bristol", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Marc-R-Pacheco", + "https://electionstats.state.ma.us/candidates/view/Sandra-M-Wright" + ], + "result": { + "candidates": [ + { + "name": "Marc R. Pacheco", + "votes": 52915, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Sandra M. Wright", + "votes": 28059, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 87, + "blankVotes": 6052, + "totalVotes": 87113 + } + }, + { + "id": 130373, + "year": 2016, + "office": "State Senate", + "districts": "2nd Plymouth and Bristol", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-D-Brady" + ], + "result": { + "candidates": [ + { + "name": "Michael D. Brady", + "votes": 56156, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 330, + "blankVotes": 19230, + "totalVotes": 75716 + } + }, + { + "id": 130356, + "year": 2016, + "office": "State Senate", + "districts": "1st Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Linda-Dorcena-Forry" + ], + "result": { + "candidates": [ + { + "name": "Linda Dorcena Forry", + "votes": 59874, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 966, + "blankVotes": 15030, + "totalVotes": 75870 + } + }, + { + "id": 130359, + "year": 2016, + "office": "State Senate", + "districts": "2nd Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Sonia-Rosa-Chang-Diaz" + ], + "result": { + "candidates": [ + { + "name": "Sonia Rosa Chang-Díaz", + "votes": 58705, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 713, + "blankVotes": 12760, + "totalVotes": 72178 + } + }, + { + "id": 130355, + "year": 2016, + "office": "State Senate", + "districts": "1st Suffolk and Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Joseph-A-Boncore" + ], + "result": { + "candidates": [ + { + "name": "Joseph A. Boncore", + "votes": 50978, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 951, + "blankVotes": 19725, + "totalVotes": 71654 + } + }, + { + "id": 130482, + "year": 2016, + "office": "State Senate", + "districts": "1st Suffolk and Middlesex", + "party": "General", + "special": true, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Joseph-A-Boncore", + "https://electionstats.state.ma.us/candidates/view/Lydia-Marie-Edwards", + "https://electionstats.state.ma.us/candidates/view/Jay-D-Livingstone" + ], + "result": { + "candidates": [ + { + "name": "Joseph A. Boncore", + "votes": 2429, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Lydia Marie Edwards", + "votes": 27, + "writeIn": true + }, + { + "name": "Jay D. Livingstone", + "votes": 16, + "writeIn": true + } + ], + "otherVotes": 246, + "blankVotes": 22, + "totalVotes": 2740 + } + }, + { + "id": 130325, + "year": 2016, + "office": "State Senate", + "districts": "2nd Suffolk and Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/William-N-Brownsberger" + ], + "result": { + "candidates": [ + { + "name": "William N. Brownsberger", + "votes": 56011, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 804, + "blankVotes": 16772, + "totalVotes": 73587 + } + }, + { + "id": 130329, + "year": 2016, + "office": "State Senate", + "districts": "Worcester and Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jennifer-L-Flanagan" + ], + "result": { + "candidates": [ + { + "name": "Jennifer L. Flanagan", + "votes": 59860, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 527, + "blankVotes": 18383, + "totalVotes": 78770 + } + }, + { + "id": 130324, + "year": 2016, + "office": "State Senate", + "districts": "Worcester and Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Ryan-C-Fattman" + ], + "result": { + "candidates": [ + { + "name": "Ryan C. Fattman", + "votes": 64665, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 1021, + "blankVotes": 19840, + "totalVotes": 85526 + } + }, + { + "id": 130292, + "year": 2016, + "office": "State Senate", + "districts": "Worcester, Hampden, Hampshire and Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Anne-M-Gobi", + "https://electionstats.state.ma.us/candidates/view/James-P-Ehrhard" + ], + "result": { + "candidates": [ + { + "name": "Anne M. Gobi", + "votes": 44021, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "James P. Ehrhard", + "votes": 36883, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 68, + "blankVotes": 5349, + "totalVotes": 86321 + } + }, + { + "id": 130365, + "year": 2016, + "office": "State Senate", + "districts": "1st Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Harriette-L-Chandler" + ], + "result": { + "candidates": [ + { + "name": "Harriette L. Chandler", + "votes": 56545, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1104, + "blankVotes": 18001, + "totalVotes": 75650 + } + }, + { + "id": 130308, + "year": 2016, + "office": "State Senate", + "districts": "2nd Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-O-Moore", + "https://electionstats.state.ma.us/candidates/view/Mesfin-H-Beshir" + ], + "result": { + "candidates": [ + { + "name": "Michael O. Moore", + "votes": 55093, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Mesfin H. Beshir", + "votes": 19250, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 125, + "blankVotes": 8835, + "totalVotes": 83303 + } + }, + { + "id": 130315, + "year": 2016, + "office": "State Representative", + "districts": "1st Barnstable", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Timothy-R-Whelan" + ], + "result": { + "candidates": [ + { + "name": "Timothy R. Whelan", + "votes": 19640, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 187, + "blankVotes": 7379, + "totalVotes": 27206 + } + }, + { + "id": 130316, + "year": 2016, + "office": "State Representative", + "districts": "2nd Barnstable", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/William-L-Crocker-Jr", + "https://electionstats.state.ma.us/candidates/view/Aaron-S-Kanzer" + ], + "result": { + "candidates": [ + { + "name": "William L. Crocker, Jr.", + "votes": 11879, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Aaron S. Kanzer", + "votes": 9799, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 25, + "blankVotes": 1099, + "totalVotes": 22802 + } + }, + { + "id": 130361, + "year": 2016, + "office": "State Representative", + "districts": "3rd Barnstable", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/David-T-Vieira", + "https://electionstats.state.ma.us/candidates/view/Matthew-C-Patrick" + ], + "result": { + "candidates": [ + { + "name": "David T. Vieira", + "votes": 12739, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Matthew C. Patrick", + "votes": 11317, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 11, + "blankVotes": 1540, + "totalVotes": 25607 + } + }, + { + "id": 130367, + "year": 2016, + "office": "State Representative", + "districts": "4th Barnstable", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Sarah-K-Peake" + ], + "result": { + "candidates": [ + { + "name": "Sarah K. Peake", + "votes": 22948, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 150, + "blankVotes": 6894, + "totalVotes": 29992 + } + }, + { + "id": 130314, + "year": 2016, + "office": "State Representative", + "districts": "5th Barnstable", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Randy-Hunt" + ], + "result": { + "candidates": [ + { + "name": "Randy Hunt", + "votes": 18096, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 141, + "blankVotes": 6432, + "totalVotes": 24669 + } + }, + { + "id": 130283, + "year": 2016, + "office": "State Representative", + "districts": "Barnstable, Dukes and Nantucket", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Dylan-A-Fernandes", + "https://electionstats.state.ma.us/candidates/view/Tobias-B-Glidden", + "https://electionstats.state.ma.us/candidates/view/Jacob-Norman-Ferry" + ], + "result": { + "candidates": [ + { + "name": "Dylan A. Fernandes", + "votes": 13030, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Tobias B. Glidden", + "votes": 9601, + "writeIn": false + }, + { + "name": "Jacob Norman Ferry", + "votes": 2566, + "writeIn": false + } + ], + "otherVotes": 29, + "blankVotes": 2732, + "totalVotes": 27958 + } + }, + { + "id": 130263, + "year": 2016, + "office": "State Representative", + "districts": "1st Berkshire", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Gailanne-M-Cariddi" + ], + "result": { + "candidates": [ + { + "name": "Gailanne M. Cariddi", + "votes": 15675, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 95, + "blankVotes": 3304, + "totalVotes": 19074 + } + }, + { + "id": 130330, + "year": 2016, + "office": "State Representative", + "districts": "2nd Berkshire", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Paul-W-Mark" + ], + "result": { + "candidates": [ + { + "name": "Paul W. Mark", + "votes": 17631, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 76, + "blankVotes": 4677, + "totalVotes": 22384 + } + }, + { + "id": 130462, + "year": 2016, + "office": "State Representative", + "districts": "3rd Berkshire", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Tricia-Farley-Bouvier", + "https://electionstats.state.ma.us/candidates/view/Christopher-J-Connell" + ], + "result": { + "candidates": [ + { + "name": "Tricia Farley-Bouvier", + "votes": 11827, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Christopher J. Connell", + "votes": 5986, + "writeIn": false + } + ], + "otherVotes": 14, + "blankVotes": 1176, + "totalVotes": 19003 + } + }, + { + "id": 130269, + "year": 2016, + "office": "State Representative", + "districts": "4th Berkshire", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/William-Pignatelli" + ], + "result": { + "candidates": [ + { + "name": "William \"Smitty\" Pignatelli", + "votes": 19356, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 54, + "blankVotes": 3355, + "totalVotes": 22765 + } + }, + { + "id": 130414, + "year": 2016, + "office": "State Representative", + "districts": "1st Bristol", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Fred-Jay-Barrows", + "https://electionstats.state.ma.us/candidates/view/Michael-E-Toole" + ], + "result": { + "candidates": [ + { + "name": "Fred Jay Barrows", + "votes": 12561, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Michael E. Toole", + "votes": 8361, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 8, + "blankVotes": 1752, + "totalVotes": 22682 + } + }, + { + "id": 130304, + "year": 2016, + "office": "State Representative", + "districts": "2nd Bristol", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Paul-R-Heroux" + ], + "result": { + "candidates": [ + { + "name": "Paul R. Heroux", + "votes": 15301, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 68, + "blankVotes": 3744, + "totalVotes": 19113 + } + }, + { + "id": 130405, + "year": 2016, + "office": "State Representative", + "districts": "3rd Bristol", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Shaunna-L-OConnell", + "https://electionstats.state.ma.us/candidates/view/Estele-C-Borges" + ], + "result": { + "candidates": [ + { + "name": "Shaunna L. O'Connell", + "votes": 10520, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Estele C. Borges", + "votes": 7417, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 18, + "blankVotes": 987, + "totalVotes": 18942 + } + }, + { + "id": 130459, + "year": 2016, + "office": "State Representative", + "districts": "4th Bristol", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Steven-S-Howitt", + "https://electionstats.state.ma.us/candidates/view/Paul-W-Jacques" + ], + "result": { + "candidates": [ + { + "name": "Steven S. Howitt", + "votes": 13253, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Paul W. Jacques", + "votes": 8030, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 14, + "blankVotes": 1596, + "totalVotes": 22893 + } + }, + { + "id": 130395, + "year": 2016, + "office": "State Representative", + "districts": "5th Bristol", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Patricia-A-Haddad" + ], + "result": { + "candidates": [ + { + "name": "Patricia A. Haddad", + "votes": 16907, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 160, + "blankVotes": 5299, + "totalVotes": 22366 + } + }, + { + "id": 130411, + "year": 2016, + "office": "State Representative", + "districts": "6th Bristol", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Carole-A-Fiola" + ], + "result": { + "candidates": [ + { + "name": "Carole A. Fiola", + "votes": 12457, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 89, + "blankVotes": 4073, + "totalVotes": 16619 + } + }, + { + "id": 130410, + "year": 2016, + "office": "State Representative", + "districts": "7th Bristol", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Alan-Silvia" + ], + "result": { + "candidates": [ + { + "name": "Alan Silvia", + "votes": 9594, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 40, + "blankVotes": 2395, + "totalVotes": 12029 + } + }, + { + "id": 130409, + "year": 2016, + "office": "State Representative", + "districts": "8th Bristol", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Paul-Schmid-III" + ], + "result": { + "candidates": [ + { + "name": "Paul Schmid, III", + "votes": 14788, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 86, + "blankVotes": 5399, + "totalVotes": 20273 + } + }, + { + "id": 130393, + "year": 2016, + "office": "State Representative", + "districts": "9th Bristol", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Christopher-M-Markey" + ], + "result": { + "candidates": [ + { + "name": "Christopher M. Markey", + "votes": 15010, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 90, + "blankVotes": 4601, + "totalVotes": 19701 + } + }, + { + "id": 130408, + "year": 2016, + "office": "State Representative", + "districts": "10th Bristol", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/William-M-Straus" + ], + "result": { + "candidates": [ + { + "name": "William M. Straus", + "votes": 16429, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 166, + "blankVotes": 5591, + "totalVotes": 22186 + } + }, + { + "id": 130258, + "year": 2016, + "office": "State Representative", + "districts": "11th Bristol", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Robert-M-Koczera" + ], + "result": { + "candidates": [ + { + "name": "Robert M. Koczera", + "votes": 11907, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 106, + "blankVotes": 3270, + "totalVotes": 15283 + } + }, + { + "id": 130326, + "year": 2016, + "office": "State Representative", + "districts": "12th Bristol", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Keiko-M-Orrall" + ], + "result": { + "candidates": [ + { + "name": "Keiko M. Orrall", + "votes": 15855, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 192, + "blankVotes": 5066, + "totalVotes": 21113 + } + }, + { + "id": 130455, + "year": 2016, + "office": "State Representative", + "districts": "13th Bristol", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Antonio-FD-Cabral" + ], + "result": { + "candidates": [ + { + "name": "Antonio F.D. Cabral", + "votes": 12021, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 114, + "blankVotes": 2724, + "totalVotes": 14859 + } + }, + { + "id": 130303, + "year": 2016, + "office": "State Representative", + "districts": "14th Bristol", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Elizabeth-A-Poirier", + "https://electionstats.state.ma.us/candidates/view/Scott-P-Dubuc" + ], + "result": { + "candidates": [ + { + "name": "Elizabeth A. Poirier", + "votes": 13865, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Scott P. Dubuc", + "votes": 5805, + "writeIn": false + } + ], + "otherVotes": 14, + "blankVotes": 1824, + "totalVotes": 21508 + } + }, + { + "id": 130272, + "year": 2016, + "office": "State Representative", + "districts": "1st Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/James-M-Kelcourse", + "https://electionstats.state.ma.us/candidates/view/Brianna-Sullivan" + ], + "result": { + "candidates": [ + { + "name": "James M. Kelcourse", + "votes": 13272, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Brianna Sullivan", + "votes": 11280, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 7, + "blankVotes": 1237, + "totalVotes": 25796 + } + }, + { + "id": 130363, + "year": 2016, + "office": "State Representative", + "districts": "2nd Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Leonard-Mirra" + ], + "result": { + "candidates": [ + { + "name": "Leonard Mirra", + "votes": 19363, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 204, + "blankVotes": 8156, + "totalVotes": 27723 + } + }, + { + "id": 130424, + "year": 2016, + "office": "State Representative", + "districts": "3rd Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Brian-S-Dempsey" + ], + "result": { + "candidates": [ + { + "name": "Brian S. Dempsey", + "votes": 15136, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 207, + "blankVotes": 3897, + "totalVotes": 19240 + } + }, + { + "id": 130419, + "year": 2016, + "office": "State Representative", + "districts": "4th Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Bradford-R-Hill" + ], + "result": { + "candidates": [ + { + "name": "Bradford R. Hill", + "votes": 21813, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 97, + "blankVotes": 5918, + "totalVotes": 27828 + } + }, + { + "id": 130406, + "year": 2016, + "office": "State Representative", + "districts": "5th Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Ann-Margaret-Ferrante" + ], + "result": { + "candidates": [ + { + "name": "Ann-Margaret Ferrante", + "votes": 18239, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 269, + "blankVotes": 5480, + "totalVotes": 23988 + } + }, + { + "id": 130331, + "year": 2016, + "office": "State Representative", + "districts": "6th Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jerald-A-Parisella", + "https://electionstats.state.ma.us/candidates/view/Daniel-Fishman" + ], + "result": { + "candidates": [ + { + "name": "Jerald A. Parisella", + "votes": 15012, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Daniel Fishman", + "votes": 5045, + "writeIn": false, + "party": "United Independent" + } + ], + "otherVotes": 12, + "blankVotes": 2344, + "totalVotes": 22413 + } + }, + { + "id": 130468, + "year": 2016, + "office": "State Representative", + "districts": "7th Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Paul-F-Tucker" + ], + "result": { + "candidates": [ + { + "name": "Paul F. Tucker", + "votes": 18208, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 64, + "blankVotes": 3911, + "totalVotes": 22183 + } + }, + { + "id": 130437, + "year": 2016, + "office": "State Representative", + "districts": "8th Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Lori-A-Ehrlich" + ], + "result": { + "candidates": [ + { + "name": "Lori A. Ehrlich", + "votes": 18365, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 131, + "blankVotes": 6229, + "totalVotes": 24725 + } + }, + { + "id": 130439, + "year": 2016, + "office": "State Representative", + "districts": "9th Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Donald-H-Wong", + "https://electionstats.state.ma.us/candidates/view/Jennifer-Migliore" + ], + "result": { + "candidates": [ + { + "name": "Donald H. Wong", + "votes": 12816, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Jennifer Migliore", + "votes": 10513, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 178, + "blankVotes": 1242, + "totalVotes": 24749 + } + }, + { + "id": 130440, + "year": 2016, + "office": "State Representative", + "districts": "10th Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Daniel-H-Cahill" + ], + "result": { + "candidates": [ + { + "name": "Daniel H. Cahill", + "votes": 11556, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 186, + "blankVotes": 3212, + "totalVotes": 14954 + } + }, + { + "id": 130484, + "year": 2016, + "office": "State Representative", + "districts": "10th Essex", + "party": "General", + "special": true, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Daniel-H-Cahill" + ], + "result": { + "candidates": [ + { + "name": "Daniel H. Cahill", + "votes": 320, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 11, + "blankVotes": 0, + "totalVotes": 331 + } + }, + { + "id": 130438, + "year": 2016, + "office": "State Representative", + "districts": "11th Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Brendan-P-Crighton" + ], + "result": { + "candidates": [ + { + "name": "Brendan P. Crighton", + "votes": 11223, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 202, + "blankVotes": 3533, + "totalVotes": 14958 + } + }, + { + "id": 127018, + "year": 2016, + "office": "State Representative", + "districts": "12th Essex", + "party": "General", + "special": true, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Thomas-P-Walsh", + "https://electionstats.state.ma.us/candidates/view/Stephanie-R-Peach", + "https://electionstats.state.ma.us/candidates/view/Christopher-M-Gallagher" + ], + "result": { + "candidates": [ + { + "name": "Thomas P. Walsh", + "votes": 7138, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Stephanie R. Peach", + "votes": 4644, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Christopher M. Gallagher", + "votes": 576, + "writeIn": false + } + ], + "otherVotes": 70, + "blankVotes": 93, + "totalVotes": 12521 + } + }, + { + "id": 130461, + "year": 2016, + "office": "State Representative", + "districts": "12th Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Thomas-P-Walsh" + ], + "result": { + "candidates": [ + { + "name": "Thomas P. Walsh", + "votes": 16480, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 241, + "blankVotes": 5032, + "totalVotes": 21753 + } + }, + { + "id": 130392, + "year": 2016, + "office": "State Representative", + "districts": "13th Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Theodore-C-Speliotis" + ], + "result": { + "candidates": [ + { + "name": "Theodore C. Speliotis", + "votes": 18163, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 361, + "blankVotes": 6503, + "totalVotes": 25027 + } + }, + { + "id": 130423, + "year": 2016, + "office": "State Representative", + "districts": "14th Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Diana-Dizoglio" + ], + "result": { + "candidates": [ + { + "name": "Diana DiZoglio", + "votes": 15927, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 126, + "blankVotes": 4633, + "totalVotes": 20686 + } + }, + { + "id": 130422, + "year": 2016, + "office": "State Representative", + "districts": "15th Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Linda-Dean-Campbell", + "https://electionstats.state.ma.us/candidates/view/Nicholas-S-Torresi" + ], + "result": { + "candidates": [ + { + "name": "Linda Dean Campbell", + "votes": 12904, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Nicholas S. Torresi", + "votes": 7421, + "writeIn": false + } + ], + "otherVotes": 53, + "blankVotes": 2094, + "totalVotes": 22472 + } + }, + { + "id": 130431, + "year": 2016, + "office": "State Representative", + "districts": "16th Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Juana-B-Matias" + ], + "result": { + "candidates": [ + { + "name": "Juana B. Matias", + "votes": 11205, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 220, + "blankVotes": 1942, + "totalVotes": 13367 + } + }, + { + "id": 130280, + "year": 2016, + "office": "State Representative", + "districts": "17th Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Frank-A-Moran" + ], + "result": { + "candidates": [ + { + "name": "Frank A. Moran", + "votes": 12033, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 160, + "blankVotes": 3601, + "totalVotes": 15794 + } + }, + { + "id": 130279, + "year": 2016, + "office": "State Representative", + "districts": "18th Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/James-J-Lyons-Jr", + "https://electionstats.state.ma.us/candidates/view/Oscar-Camargo" + ], + "result": { + "candidates": [ + { + "name": "James J. Lyons, Jr.", + "votes": 14218, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Oscar Camargo", + "votes": 9615, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 36, + "blankVotes": 1879, + "totalVotes": 25748 + } + }, + { + "id": 130296, + "year": 2016, + "office": "State Representative", + "districts": "1st Franklin", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Stephen-Kulik" + ], + "result": { + "candidates": [ + { + "name": "Stephen Kulik", + "votes": 20798, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 51, + "blankVotes": 4020, + "totalVotes": 24869 + } + }, + { + "id": 130301, + "year": 2016, + "office": "State Representative", + "districts": "2nd Franklin", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Susannah-M-Whipps" + ], + "result": { + "candidates": [ + { + "name": "Susannah M. Whipps Lee", + "votes": 15490, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 200, + "blankVotes": 4885, + "totalVotes": 20575 + } + }, + { + "id": 130369, + "year": 2016, + "office": "State Representative", + "districts": "1st Hampden", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Todd-M-Smola" + ], + "result": { + "candidates": [ + { + "name": "Todd M. Smola", + "votes": 17633, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 126, + "blankVotes": 4126, + "totalVotes": 21885 + } + }, + { + "id": 130402, + "year": 2016, + "office": "State Representative", + "districts": "2nd Hampden", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Brian-M-Ashe" + ], + "result": { + "candidates": [ + { + "name": "Brian M. Ashe", + "votes": 18058, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 239, + "blankVotes": 6118, + "totalVotes": 24415 + } + }, + { + "id": 130266, + "year": 2016, + "office": "State Representative", + "districts": "3rd Hampden", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Nicholas-A-Boldyga", + "https://electionstats.state.ma.us/candidates/view/Rosemary-Sandlin" + ], + "result": { + "candidates": [ + { + "name": "Nicholas A. Boldyga", + "votes": 12136, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Rosemary Sandlin", + "votes": 8089, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 20, + "blankVotes": 945, + "totalVotes": 21190 + } + }, + { + "id": 130478, + "year": 2016, + "office": "State Representative", + "districts": "4th Hampden", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-C-Velis" + ], + "result": { + "candidates": [ + { + "name": "John C. Velis", + "votes": 15399, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 95, + "blankVotes": 3812, + "totalVotes": 19306 + } + }, + { + "id": 130429, + "year": 2016, + "office": "State Representative", + "districts": "5th Hampden", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Aaron-Vega" + ], + "result": { + "candidates": [ + { + "name": "Aaron Vega", + "votes": 13687, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 40, + "blankVotes": 3312, + "totalVotes": 17039 + } + }, + { + "id": 130388, + "year": 2016, + "office": "State Representative", + "districts": "6th Hampden", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-J-Finn" + ], + "result": { + "candidates": [ + { + "name": "Michael J. Finn", + "votes": 12819, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 144, + "blankVotes": 4033, + "totalVotes": 16996 + } + }, + { + "id": 130321, + "year": 2016, + "office": "State Representative", + "districts": "7th Hampden", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Thomas-M-Petrolati" + ], + "result": { + "candidates": [ + { + "name": "Thomas M. Petrolati", + "votes": 16180, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 231, + "blankVotes": 4904, + "totalVotes": 21315 + } + }, + { + "id": 130386, + "year": 2016, + "office": "State Representative", + "districts": "8th Hampden", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Joseph-F-Wagner" + ], + "result": { + "candidates": [ + { + "name": "Joseph F. Wagner", + "votes": 14479, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 278, + "blankVotes": 3032, + "totalVotes": 17789 + } + }, + { + "id": 130387, + "year": 2016, + "office": "State Representative", + "districts": "9th Hampden", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jose-F-Tosado", + "https://electionstats.state.ma.us/candidates/view/Robert-J-Underwood" + ], + "result": { + "candidates": [ + { + "name": "Jose F. Tosado", + "votes": 12020, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Robert J. Underwood", + "votes": 2885, + "writeIn": false + } + ], + "otherVotes": 69, + "blankVotes": 1682, + "totalVotes": 16656 + } + }, + { + "id": 130472, + "year": 2016, + "office": "State Representative", + "districts": "10th Hampden", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Carlos-Gonzalez" + ], + "result": { + "candidates": [ + { + "name": "Carlos Gonzalez", + "votes": 10286, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 165, + "blankVotes": 1960, + "totalVotes": 12411 + } + }, + { + "id": 130471, + "year": 2016, + "office": "State Representative", + "districts": "11th Hampden", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Bud-L-Williams" + ], + "result": { + "candidates": [ + { + "name": "Bud L. Williams", + "votes": 11634, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 299, + "blankVotes": 2001, + "totalVotes": 13934 + } + }, + { + "id": 130403, + "year": 2016, + "office": "State Representative", + "districts": "12th Hampden", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Angelo-J-Puppolo-Jr" + ], + "result": { + "candidates": [ + { + "name": "Angelo J. Puppolo, Jr.", + "votes": 16368, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 167, + "blankVotes": 4340, + "totalVotes": 20875 + } + }, + { + "id": 130421, + "year": 2016, + "office": "State Representative", + "districts": "1st Hampshire", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Peter-V-Kocot" + ], + "result": { + "candidates": [ + { + "name": "Peter V. Kocot", + "votes": 20145, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 89, + "blankVotes": 4420, + "totalVotes": 24654 + } + }, + { + "id": 130404, + "year": 2016, + "office": "State Representative", + "districts": "2nd Hampshire", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-W-Scibak" + ], + "result": { + "candidates": [ + { + "name": "John W. Scibak", + "votes": 19023, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 138, + "blankVotes": 5056, + "totalVotes": 24217 + } + }, + { + "id": 130276, + "year": 2016, + "office": "State Representative", + "districts": "3rd Hampshire", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Solomon-Israel-Goldstein-Rose" + ], + "result": { + "candidates": [ + { + "name": "Solomon Israel Goldstein-Rose", + "votes": 14601, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 186, + "blankVotes": 3165, + "totalVotes": 17952 + } + }, + { + "id": 130294, + "year": 2016, + "office": "State Representative", + "districts": "1st Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Sheila-C-Harrington", + "https://electionstats.state.ma.us/candidates/view/Matthew-T-Meneghini" + ], + "result": { + "candidates": [ + { + "name": "Sheila C. Harrington", + "votes": 14984, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Matthew T. Meneghini", + "votes": 8003, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 15, + "blankVotes": 1919, + "totalVotes": 24921 + } + }, + { + "id": 130382, + "year": 2016, + "office": "State Representative", + "districts": "2nd Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/James-Arciero" + ], + "result": { + "candidates": [ + { + "name": "James Arciero", + "votes": 20337, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 139, + "blankVotes": 6592, + "totalVotes": 27068 + } + }, + { + "id": 130334, + "year": 2016, + "office": "State Representative", + "districts": "3rd Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Kate-Hogan" + ], + "result": { + "candidates": [ + { + "name": "Kate Hogan", + "votes": 19161, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 242, + "blankVotes": 5384, + "totalVotes": 24787 + } + }, + { + "id": 130448, + "year": 2016, + "office": "State Representative", + "districts": "4th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Danielle-W-Gregoire", + "https://electionstats.state.ma.us/candidates/view/Paul-R-Ferro" + ], + "result": { + "candidates": [ + { + "name": "Danielle W. Gregoire", + "votes": 11531, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Paul R. Ferro", + "votes": 7801, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 27, + "blankVotes": 1547, + "totalVotes": 20906 + } + }, + { + "id": 130452, + "year": 2016, + "office": "State Representative", + "districts": "5th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/David-Paul-Linsky" + ], + "result": { + "candidates": [ + { + "name": "David Paul Linsky", + "votes": 19566, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 170, + "blankVotes": 6457, + "totalVotes": 26193 + } + }, + { + "id": 130415, + "year": 2016, + "office": "State Representative", + "districts": "6th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Chris-Walsh" + ], + "result": { + "candidates": [ + { + "name": "Chris Walsh", + "votes": 16009, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 250, + "blankVotes": 4822, + "totalVotes": 21081 + } + }, + { + "id": 130299, + "year": 2016, + "office": "State Representative", + "districts": "7th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jack-Patrick-Lewis", + "https://electionstats.state.ma.us/candidates/view/Yolanda-Greaves", + "https://electionstats.state.ma.us/candidates/view/Clifford-c-T-Wilson" + ], + "result": { + "candidates": [ + { + "name": "Jack Patrick Lewis", + "votes": 9286, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Yolanda Greaves", + "votes": 4982, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Clifford c. T. Wilson", + "votes": 740, + "writeIn": false + } + ], + "otherVotes": 23, + "blankVotes": 1281, + "totalVotes": 16312 + } + }, + { + "id": 130428, + "year": 2016, + "office": "State Representative", + "districts": "8th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Carolyn-C-Dykema" + ], + "result": { + "candidates": [ + { + "name": "Carolyn C. Dykema", + "votes": 19524, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 289, + "blankVotes": 5938, + "totalVotes": 25751 + } + }, + { + "id": 130435, + "year": 2016, + "office": "State Representative", + "districts": "9th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Thomas-M-Stanley", + "https://electionstats.state.ma.us/candidates/view/Stacey-Gallagher-M-Tully" + ], + "result": { + "candidates": [ + { + "name": "Thomas M. Stanley", + "votes": 12654, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Stacey Gallagher M. Tully", + "votes": 7164, + "writeIn": false + } + ], + "otherVotes": 165, + "blankVotes": 1681, + "totalVotes": 21664 + } + }, + { + "id": 130457, + "year": 2016, + "office": "State Representative", + "districts": "10th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-J-Lawn-Jr" + ], + "result": { + "candidates": [ + { + "name": "John J. Lawn, Jr.", + "votes": 12665, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 192, + "blankVotes": 3968, + "totalVotes": 16825 + } + }, + { + "id": 130456, + "year": 2016, + "office": "State Representative", + "districts": "11th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Kay-S-Khan" + ], + "result": { + "candidates": [ + { + "name": "Kay S. Khan", + "votes": 17153, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 222, + "blankVotes": 5233, + "totalVotes": 22608 + } + }, + { + "id": 130458, + "year": 2016, + "office": "State Representative", + "districts": "12th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Ruth-B-Balser" + ], + "result": { + "candidates": [ + { + "name": "Ruth B. Balser", + "votes": 16582, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 235, + "blankVotes": 5108, + "totalVotes": 21925 + } + }, + { + "id": 130416, + "year": 2016, + "office": "State Representative", + "districts": "13th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Carmine-Lawrence-Gentile" + ], + "result": { + "candidates": [ + { + "name": "Carmine Lawrence Gentile", + "votes": 17561, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 240, + "blankVotes": 6840, + "totalVotes": 24641 + } + }, + { + "id": 130250, + "year": 2016, + "office": "State Representative", + "districts": "14th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Cory-Atkins", + "https://electionstats.state.ma.us/candidates/view/Helen-Brady", + "https://electionstats.state.ma.us/candidates/view/Daniel-L-Factor" + ], + "result": { + "candidates": [ + { + "name": "Cory Atkins", + "votes": 14831, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Helen Brady", + "votes": 8950, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Daniel L. Factor", + "votes": 824, + "writeIn": false, + "party": "Green-rainbow" + } + ], + "otherVotes": 22, + "blankVotes": 1128, + "totalVotes": 25755 + } + }, + { + "id": 130434, + "year": 2016, + "office": "State Representative", + "districts": "15th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jay-R-Kaufman" + ], + "result": { + "candidates": [ + { + "name": "Jay R. Kaufman", + "votes": 17736, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 8, + "blankVotes": 7071, + "totalVotes": 24815 + } + }, + { + "id": 130384, + "year": 2016, + "office": "State Representative", + "districts": "16th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Thomas-A-Golden-Jr" + ], + "result": { + "candidates": [ + { + "name": "Thomas A. Golden, Jr.", + "votes": 13962, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 233, + "blankVotes": 3600, + "totalVotes": 17795 + } + }, + { + "id": 130383, + "year": 2016, + "office": "State Representative", + "districts": "17th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/David-M-Nangle" + ], + "result": { + "candidates": [ + { + "name": "David M. Nangle", + "votes": 12305, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 317, + "blankVotes": 3335, + "totalVotes": 15957 + } + }, + { + "id": 130436, + "year": 2016, + "office": "State Representative", + "districts": "18th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Rady-Mom", + "https://electionstats.state.ma.us/candidates/view/Kamara-Kay" + ], + "result": { + "candidates": [ + { + "name": "Rady Mom", + "votes": 8107, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Kamara Kay", + "votes": 3115, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 77, + "blankVotes": 1077, + "totalVotes": 12376 + } + }, + { + "id": 130475, + "year": 2016, + "office": "State Representative", + "districts": "19th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/James-R-Miceli" + ], + "result": { + "candidates": [ + { + "name": "James R. Miceli", + "votes": 19349, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 248, + "blankVotes": 4597, + "totalVotes": 24194 + } + }, + { + "id": 130442, + "year": 2016, + "office": "State Representative", + "districts": "20th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Bradley-H-Jones-Jr" + ], + "result": { + "candidates": [ + { + "name": "Bradley H. Jones, Jr", + "votes": 19756, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 107, + "blankVotes": 7292, + "totalVotes": 27155 + } + }, + { + "id": 130319, + "year": 2016, + "office": "State Representative", + "districts": "21st Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Kenneth-I-Gordon", + "https://electionstats.state.ma.us/candidates/view/Paul-Girouard-Jr" + ], + "result": { + "candidates": [ + { + "name": "Kenneth I. Gordon", + "votes": 13476, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Paul Girouard, Jr", + "votes": 9526, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 24, + "blankVotes": 1432, + "totalVotes": 24458 + } + }, + { + "id": 130333, + "year": 2016, + "office": "State Representative", + "districts": "22nd Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Marc-T-Lombardo", + "https://electionstats.state.ma.us/candidates/view/George-Simolaris-Jr" + ], + "result": { + "candidates": [ + { + "name": "Marc T. Lombardo", + "votes": 12583, + "writeIn": false, + "party": "Republican" + }, + { + "name": "George Simolaris, Jr.", + "votes": 8671, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 92, + "blankVotes": 1012, + "totalVotes": 22358 + } + }, + { + "id": 130288, + "year": 2016, + "office": "State Representative", + "districts": "23rd Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Sean-Garballey" + ], + "result": { + "candidates": [ + { + "name": "Sean Garballey", + "votes": 20991, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 165, + "blankVotes": 5687, + "totalVotes": 26843 + } + }, + { + "id": 130287, + "year": 2016, + "office": "State Representative", + "districts": "24th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/David-M-Rogers" + ], + "result": { + "candidates": [ + { + "name": "David M. Rogers", + "votes": 18570, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 172, + "blankVotes": 6597, + "totalVotes": 25339 + } + }, + { + "id": 130376, + "year": 2016, + "office": "State Representative", + "districts": "25th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Marjorie-C-Decker" + ], + "result": { + "candidates": [ + { + "name": "Marjorie C. Decker", + "votes": 15968, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 135, + "blankVotes": 3271, + "totalVotes": 19374 + } + }, + { + "id": 130378, + "year": 2016, + "office": "State Representative", + "districts": "26th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Mike-Connolly" + ], + "result": { + "candidates": [ + { + "name": "Mike Connolly", + "votes": 15872, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 255, + "blankVotes": 3555, + "totalVotes": 19682 + } + }, + { + "id": 130470, + "year": 2016, + "office": "State Representative", + "districts": "27th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Denise-Provost", + "https://electionstats.state.ma.us/candidates/view/Aaron-James" + ], + "result": { + "candidates": [ + { + "name": "Denise Provost", + "votes": 18409, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Aaron James", + "votes": 2680, + "writeIn": false, + "party": "Pirate" + } + ], + "otherVotes": 74, + "blankVotes": 2531, + "totalVotes": 23694 + } + }, + { + "id": 130407, + "year": 2016, + "office": "State Representative", + "districts": "28th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Joseph-W-Mcgonagle-Jr" + ], + "result": { + "candidates": [ + { + "name": "Joseph W. Mcgonagle, Jr", + "votes": 10811, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 161, + "blankVotes": 3251, + "totalVotes": 14223 + } + }, + { + "id": 130377, + "year": 2016, + "office": "State Representative", + "districts": "29th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jonathan-Hecht" + ], + "result": { + "candidates": [ + { + "name": "Jonathan Hecht", + "votes": 18821, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 258, + "blankVotes": 5145, + "totalVotes": 24224 + } + }, + { + "id": 130466, + "year": 2016, + "office": "State Representative", + "districts": "30th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/James-J-Dwyer" + ], + "result": { + "candidates": [ + { + "name": "James J. Dwyer", + "votes": 15943, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 72, + "blankVotes": 6534, + "totalVotes": 22549 + } + }, + { + "id": 130473, + "year": 2016, + "office": "State Representative", + "districts": "31st Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-Seamus-Day", + "https://electionstats.state.ma.us/candidates/view/Caroline-Colarusso" + ], + "result": { + "candidates": [ + { + "name": "Michael Seamus Day", + "votes": 14528, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Caroline Colarusso", + "votes": 10163, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 9, + "blankVotes": 1581, + "totalVotes": 26281 + } + }, + { + "id": 130444, + "year": 2016, + "office": "State Representative", + "districts": "32nd Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Paul-A-Brodeur" + ], + "result": { + "candidates": [ + { + "name": "Paul A. Brodeur", + "votes": 18386, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 246, + "blankVotes": 7209, + "totalVotes": 25841 + } + }, + { + "id": 130445, + "year": 2016, + "office": "State Representative", + "districts": "33rd Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Steven-Ultrino" + ], + "result": { + "candidates": [ + { + "name": "Steven Ultrino", + "votes": 11435, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 180, + "blankVotes": 3688, + "totalVotes": 15303 + } + }, + { + "id": 130451, + "year": 2016, + "office": "State Representative", + "districts": "34th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Christine-P-Barber" + ], + "result": { + "candidates": [ + { + "name": "Christine P. Barber", + "votes": 16334, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 233, + "blankVotes": 4383, + "totalVotes": 20950 + } + }, + { + "id": 130443, + "year": 2016, + "office": "State Representative", + "districts": "35th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Paul-J-Donato-Sr" + ], + "result": { + "candidates": [ + { + "name": "Paul J. Donato, Sr.", + "votes": 15306, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 237, + "blankVotes": 4148, + "totalVotes": 19691 + } + }, + { + "id": 130398, + "year": 2016, + "office": "State Representative", + "districts": "36th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Colleen-M-Garry" + ], + "result": { + "candidates": [ + { + "name": "Colleen M. Garry", + "votes": 18003, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 31, + "blankVotes": 5248, + "totalVotes": 23282 + } + }, + { + "id": 130251, + "year": 2016, + "office": "State Representative", + "districts": "37th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jennifer-E-Benson" + ], + "result": { + "candidates": [ + { + "name": "Jennifer E. Benson", + "votes": 17814, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 180, + "blankVotes": 6088, + "totalVotes": 24082 + } + }, + { + "id": 130464, + "year": 2016, + "office": "State Representative", + "districts": "1st Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Bruce-J-Ayers" + ], + "result": { + "candidates": [ + { + "name": "Bruce J. Ayers", + "votes": 15497, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 27, + "blankVotes": 3856, + "totalVotes": 19380 + } + }, + { + "id": 130465, + "year": 2016, + "office": "State Representative", + "districts": "2nd Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Tackey-Chan" + ], + "result": { + "candidates": [ + { + "name": "Tackey Chan", + "votes": 15005, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 0, + "blankVotes": 4232, + "totalVotes": 19237 + } + }, + { + "id": 130426, + "year": 2016, + "office": "State Representative", + "districts": "3rd Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Ronald-Mariano" + ], + "result": { + "candidates": [ + { + "name": "Ronald Mariano", + "votes": 15810, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 129, + "blankVotes": 4941, + "totalVotes": 20880 + } + }, + { + "id": 130425, + "year": 2016, + "office": "State Representative", + "districts": "4th Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/James-Michael-Murphy" + ], + "result": { + "candidates": [ + { + "name": "James Michael Murphy", + "votes": 17771, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 240, + "blankVotes": 5586, + "totalVotes": 23597 + } + }, + { + "id": 130366, + "year": 2016, + "office": "State Representative", + "districts": "5th Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Mark-J-Cusack" + ], + "result": { + "candidates": [ + { + "name": "Mark J. Cusack", + "votes": 16652, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 246, + "blankVotes": 6475, + "totalVotes": 23373 + } + }, + { + "id": 130310, + "year": 2016, + "office": "State Representative", + "districts": "6th Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/William-C-Galvin" + ], + "result": { + "candidates": [ + { + "name": "William C. Galvin", + "votes": 17215, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 68, + "blankVotes": 5039, + "totalVotes": 22322 + } + }, + { + "id": 130453, + "year": 2016, + "office": "State Representative", + "districts": "7th Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/William-J-Driscoll-Jr" + ], + "result": { + "candidates": [ + { + "name": "William J. Driscoll, Jr", + "votes": 16318, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 128, + "blankVotes": 5482, + "totalVotes": 21928 + } + }, + { + "id": 130447, + "year": 2016, + "office": "State Representative", + "districts": "8th Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Louis-L-Kafka" + ], + "result": { + "candidates": [ + { + "name": "Louis L. Kafka", + "votes": 18169, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 152, + "blankVotes": 6050, + "totalVotes": 24371 + } + }, + { + "id": 130450, + "year": 2016, + "office": "State Representative", + "districts": "9th Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Shawn-C-Dooley", + "https://electionstats.state.ma.us/candidates/view/Brian-P-Hamlin" + ], + "result": { + "candidates": [ + { + "name": "Shawn C. Dooley", + "votes": 14427, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Brian P. Hamlin", + "votes": 9267, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 13, + "blankVotes": 1398, + "totalVotes": 25105 + } + }, + { + "id": 130417, + "year": 2016, + "office": "State Representative", + "districts": "10th Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jeffrey-N-Roy" + ], + "result": { + "candidates": [ + { + "name": "Jeffrey N. Roy", + "votes": 18581, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 349, + "blankVotes": 5454, + "totalVotes": 24384 + } + }, + { + "id": 130394, + "year": 2016, + "office": "State Representative", + "districts": "11th Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Paul-McMurtry" + ], + "result": { + "candidates": [ + { + "name": "Paul McMurtry", + "votes": 18918, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 153, + "blankVotes": 6936, + "totalVotes": 26007 + } + }, + { + "id": 130460, + "year": 2016, + "office": "State Representative", + "districts": "12th Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-H-Rogers", + "https://electionstats.state.ma.us/candidates/view/Tim-Hempton" + ], + "result": { + "candidates": [ + { + "name": "John H. Rogers", + "votes": 14534, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Tim Hempton", + "votes": 7486, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 27, + "blankVotes": 1707, + "totalVotes": 23754 + } + }, + { + "id": 130397, + "year": 2016, + "office": "State Representative", + "districts": "13th Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Denise-C-Garlick" + ], + "result": { + "candidates": [ + { + "name": "Denise C. Garlick", + "votes": 20144, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 273, + "blankVotes": 5638, + "totalVotes": 26055 + } + }, + { + "id": 130476, + "year": 2016, + "office": "State Representative", + "districts": "14th Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Alice-Hanlon-Peisch" + ], + "result": { + "candidates": [ + { + "name": "Alice Hanlon Peisch", + "votes": 17759, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 110, + "blankVotes": 6385, + "totalVotes": 24254 + } + }, + { + "id": 130374, + "year": 2016, + "office": "State Representative", + "districts": "15th Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Frank-Israel-Smizik" + ], + "result": { + "candidates": [ + { + "name": "Frank Israel Smizik", + "votes": 16079, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 79, + "blankVotes": 4116, + "totalVotes": 20274 + } + }, + { + "id": 130463, + "year": 2016, + "office": "State Representative", + "districts": "1st Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Mathew-J-Muratore", + "https://electionstats.state.ma.us/candidates/view/John-T-Mahoney-Jr" + ], + "result": { + "candidates": [ + { + "name": "Mathew J. Muratore", + "votes": 11873, + "writeIn": false, + "party": "Republican" + }, + { + "name": "John T. Mahoney, Jr", + "votes": 11471, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 8, + "blankVotes": 1533, + "totalVotes": 24885 + } + }, + { + "id": 130380, + "year": 2016, + "office": "State Representative", + "districts": "2nd Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Susan-Williams-Gifford", + "https://electionstats.state.ma.us/candidates/view/Sarah-G-Hewins" + ], + "result": { + "candidates": [ + { + "name": "Susan Williams Gifford", + "votes": 11894, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Sarah G. Hewins", + "votes": 8692, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 23, + "blankVotes": 1479, + "totalVotes": 22088 + } + }, + { + "id": 130390, + "year": 2016, + "office": "State Representative", + "districts": "3rd Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Joan-Meschino", + "https://electionstats.state.ma.us/candidates/view/Kristen-G-Arute" + ], + "result": { + "candidates": [ + { + "name": "Joan Meschino", + "votes": 13257, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Kristen G. Arute", + "votes": 11285, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 23, + "blankVotes": 1399, + "totalVotes": 25964 + } + }, + { + "id": 130449, + "year": 2016, + "office": "State Representative", + "districts": "4th Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/James-M-Cantwell", + "https://electionstats.state.ma.us/candidates/view/Michael-White" + ], + "result": { + "candidates": [ + { + "name": "James M. Cantwell", + "votes": 17388, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Michael White", + "votes": 7601, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 10, + "blankVotes": 1028, + "totalVotes": 26027 + } + }, + { + "id": 130420, + "year": 2016, + "office": "State Representative", + "districts": "5th Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/David-F-Decoste", + "https://electionstats.state.ma.us/candidates/view/Kara-L-Nyman" + ], + "result": { + "candidates": [ + { + "name": "David F. Decoste", + "votes": 12293, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Kara L. Nyman", + "votes": 11790, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 17, + "blankVotes": 1065, + "totalVotes": 25165 + } + }, + { + "id": 130400, + "year": 2016, + "office": "State Representative", + "districts": "6th Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Josh-S-Cutler", + "https://electionstats.state.ma.us/candidates/view/Vince-Cogliano" + ], + "result": { + "candidates": [ + { + "name": "Josh S. Cutler", + "votes": 15290, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Vince Cogliano", + "votes": 8582, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 14, + "blankVotes": 1122, + "totalVotes": 25008 + } + }, + { + "id": 130245, + "year": 2016, + "office": "State Representative", + "districts": "7th Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Geoff-Diehl" + ], + "result": { + "candidates": [ + { + "name": "Geoff Diehl", + "votes": 17088, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 144, + "blankVotes": 5770, + "totalVotes": 23002 + } + }, + { + "id": 130368, + "year": 2016, + "office": "State Representative", + "districts": "8th Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Angelo-L-DEmilia" + ], + "result": { + "candidates": [ + { + "name": "Angelo L. D'Emilia", + "votes": 14963, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 247, + "blankVotes": 5744, + "totalVotes": 20954 + } + }, + { + "id": 127016, + "year": 2016, + "office": "State Representative", + "districts": "9th Plymouth", + "party": "General", + "special": true, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Gerard-J-Cassidy" + ], + "result": { + "candidates": [ + { + "name": "Gerard J. Cassidy", + "votes": 4536, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 44, + "blankVotes": 392, + "totalVotes": 4972 + } + }, + { + "id": 130371, + "year": 2016, + "office": "State Representative", + "districts": "9th Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Gerard-J-Cassidy", + "https://electionstats.state.ma.us/candidates/view/Danny-J-Yoon" + ], + "result": { + "candidates": [ + { + "name": "Gerard J. Cassidy", + "votes": 10346, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Danny J. Yoon", + "votes": 1745, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 2, + "blankVotes": 1612, + "totalVotes": 13705 + } + }, + { + "id": 130372, + "year": 2016, + "office": "State Representative", + "districts": "10th Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michelle-M-Dubois" + ], + "result": { + "candidates": [ + { + "name": "Michelle M. Dubois", + "votes": 13945, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 114, + "blankVotes": 4859, + "totalVotes": 18918 + } + }, + { + "id": 130370, + "year": 2016, + "office": "State Representative", + "districts": "11th Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Claire-D-Cronin" + ], + "result": { + "candidates": [ + { + "name": "Claire D. Cronin", + "votes": 15361, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 33, + "blankVotes": 5422, + "totalVotes": 20816 + } + }, + { + "id": 130401, + "year": 2016, + "office": "State Representative", + "districts": "12th Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Thomas-J-Calter-III", + "https://electionstats.state.ma.us/candidates/view/Peter-J-Boncek" + ], + "result": { + "candidates": [ + { + "name": "Thomas J. Calter, III", + "votes": 13908, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Peter J. Boncek", + "votes": 9297, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 26, + "blankVotes": 1634, + "totalVotes": 24865 + } + }, + { + "id": 130343, + "year": 2016, + "office": "State Representative", + "districts": "1st Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Adrian-C-Madaro" + ], + "result": { + "candidates": [ + { + "name": "Adrian C. Madaro", + "votes": 9946, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 205, + "blankVotes": 2671, + "totalVotes": 12822 + } + }, + { + "id": 130347, + "year": 2016, + "office": "State Representative", + "districts": "2nd Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Daniel-Joseph-Ryan" + ], + "result": { + "candidates": [ + { + "name": "Daniel Joseph Ryan", + "votes": 12755, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 118, + "blankVotes": 4344, + "totalVotes": 17217 + } + }, + { + "id": 130352, + "year": 2016, + "office": "State Representative", + "districts": "3rd Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Aaron-M-Michlewitz" + ], + "result": { + "candidates": [ + { + "name": "Aaron M. Michlewitz", + "votes": 15542, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 199, + "blankVotes": 5015, + "totalVotes": 20756 + } + }, + { + "id": 130345, + "year": 2016, + "office": "State Representative", + "districts": "4th Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Nick-Collins" + ], + "result": { + "candidates": [ + { + "name": "Nick Collins", + "votes": 18781, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 264, + "blankVotes": 3969, + "totalVotes": 23014 + } + }, + { + "id": 130342, + "year": 2016, + "office": "State Representative", + "districts": "5th Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Evandro-C-Carvalho", + "https://electionstats.state.ma.us/candidates/view/Althea-Garrison" + ], + "result": { + "candidates": [ + { + "name": "Evandro C. Carvalho", + "votes": 10855, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Althea Garrison", + "votes": 2014, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 70, + "blankVotes": 1861, + "totalVotes": 14800 + } + }, + { + "id": 130350, + "year": 2016, + "office": "State Representative", + "districts": "6th Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Russell-E-Holmes" + ], + "result": { + "candidates": [ + { + "name": "Russell E. Holmes", + "votes": 13044, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 160, + "blankVotes": 3433, + "totalVotes": 16637 + } + }, + { + "id": 130349, + "year": 2016, + "office": "State Representative", + "districts": "7th Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Chynah-Tyler" + ], + "result": { + "candidates": [ + { + "name": "Chynah Tyler", + "votes": 9150, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 132, + "blankVotes": 2138, + "totalVotes": 11420 + } + }, + { + "id": 130339, + "year": 2016, + "office": "State Representative", + "districts": "8th Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jay-D-Livingstone" + ], + "result": { + "candidates": [ + { + "name": "Jay D. Livingstone", + "votes": 16201, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 209, + "blankVotes": 4360, + "totalVotes": 20770 + } + }, + { + "id": 130346, + "year": 2016, + "office": "State Representative", + "districts": "9th Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Byron-Rushing" + ], + "result": { + "candidates": [ + { + "name": "Byron Rushing", + "votes": 12617, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 190, + "blankVotes": 3488, + "totalVotes": 16295 + } + }, + { + "id": 130351, + "year": 2016, + "office": "State Representative", + "districts": "10th Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Edward-F-Coppinger" + ], + "result": { + "candidates": [ + { + "name": "Edward F. Coppinger", + "votes": 17070, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 203, + "blankVotes": 5333, + "totalVotes": 22606 + } + }, + { + "id": 130340, + "year": 2016, + "office": "State Representative", + "districts": "11th Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Elizabeth-A-Malia", + "https://electionstats.state.ma.us/candidates/view/Stephen-Charles-Bedell" + ], + "result": { + "candidates": [ + { + "name": "Elizabeth A. Malia", + "votes": 15628, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Stephen Charles Bedell", + "votes": 1884, + "writeIn": false + } + ], + "otherVotes": 60, + "blankVotes": 2707, + "totalVotes": 20279 + } + }, + { + "id": 130354, + "year": 2016, + "office": "State Representative", + "districts": "12th Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Dan-Cullinane" + ], + "result": { + "candidates": [ + { + "name": "Dan Cullinane", + "votes": 15286, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 213, + "blankVotes": 4058, + "totalVotes": 19557 + } + }, + { + "id": 130353, + "year": 2016, + "office": "State Representative", + "districts": "13th Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Daniel-J-Hunt" + ], + "result": { + "candidates": [ + { + "name": "Daniel J. Hunt", + "votes": 14507, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 232, + "blankVotes": 4072, + "totalVotes": 18811 + } + }, + { + "id": 130344, + "year": 2016, + "office": "State Representative", + "districts": "14th Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Angelo-M-Scaccia" + ], + "result": { + "candidates": [ + { + "name": "Angelo M. Scaccia", + "votes": 15784, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 259, + "blankVotes": 4048, + "totalVotes": 20091 + } + }, + { + "id": 130341, + "year": 2016, + "office": "State Representative", + "districts": "15th Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jeffrey-Sanchez" + ], + "result": { + "candidates": [ + { + "name": "Jeffrey Sanchez", + "votes": 15675, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 179, + "blankVotes": 3594, + "totalVotes": 19448 + } + }, + { + "id": 130385, + "year": 2016, + "office": "State Representative", + "districts": "16th Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Roselee-Vincent" + ], + "result": { + "candidates": [ + { + "name": "Roselee Vincent", + "votes": 11220, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 162, + "blankVotes": 6435, + "totalVotes": 17817 + } + }, + { + "id": 130348, + "year": 2016, + "office": "State Representative", + "districts": "17th Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Kevin-G-Honan" + ], + "result": { + "candidates": [ + { + "name": "Kevin G. Honan", + "votes": 13367, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 183, + "blankVotes": 2762, + "totalVotes": 16312 + } + }, + { + "id": 130338, + "year": 2016, + "office": "State Representative", + "districts": "18th Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-J-Moran" + ], + "result": { + "candidates": [ + { + "name": "Michael J. Moran", + "votes": 10714, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 154, + "blankVotes": 2960, + "totalVotes": 13828 + } + }, + { + "id": 130467, + "year": 2016, + "office": "State Representative", + "districts": "19th Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Robert-A-DeLeo" + ], + "result": { + "candidates": [ + { + "name": "Robert A. DeLeo", + "votes": 13000, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 241, + "blankVotes": 4453, + "totalVotes": 17694 + } + }, + { + "id": 130427, + "year": 2016, + "office": "State Representative", + "districts": "1st Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Kimberly-N-Ferguson" + ], + "result": { + "candidates": [ + { + "name": "Kimberly N. Ferguson", + "votes": 20065, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 144, + "blankVotes": 6012, + "totalVotes": 26221 + } + }, + { + "id": 130291, + "year": 2016, + "office": "State Representative", + "districts": "2nd Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jonathan-D-Zlotnik" + ], + "result": { + "candidates": [ + { + "name": "Jonathan D. Zlotnik", + "votes": 14470, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 152, + "blankVotes": 4444, + "totalVotes": 19066 + } + }, + { + "id": 127017, + "year": 2016, + "office": "State Representative", + "districts": "3rd Worcester", + "party": "General", + "special": true, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Stephan-Hay", + "https://electionstats.state.ma.us/candidates/view/Dean-Tran" + ], + "result": { + "candidates": [ + { + "name": "Stephan Hay", + "votes": 4613, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Dean Tran", + "votes": 4488, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 25, + "blankVotes": 69, + "totalVotes": 9195 + } + }, + { + "id": 130413, + "year": 2016, + "office": "State Representative", + "districts": "3rd Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Stephan-Hay" + ], + "result": { + "candidates": [ + { + "name": "Stephan Hay", + "votes": 13498, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 70, + "blankVotes": 3589, + "totalVotes": 17157 + } + }, + { + "id": 130433, + "year": 2016, + "office": "State Representative", + "districts": "4th Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Natalie-Higgins", + "https://electionstats.state.ma.us/candidates/view/Thomas-F-Ardinger" + ], + "result": { + "candidates": [ + { + "name": "Natalie Higgins", + "votes": 10382, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Thomas F. Ardinger", + "votes": 8426, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 51, + "blankVotes": 1092, + "totalVotes": 19951 + } + }, + { + "id": 130318, + "year": 2016, + "office": "State Representative", + "districts": "5th Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Donald-R-Berthiaume-Jr" + ], + "result": { + "candidates": [ + { + "name": "Donald R. Berthiaume, Jr", + "votes": 17133, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 91, + "blankVotes": 5146, + "totalVotes": 22370 + } + }, + { + "id": 130381, + "year": 2016, + "office": "State Representative", + "districts": "6th Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Peter-J-Durant" + ], + "result": { + "candidates": [ + { + "name": "Peter J. Durant", + "votes": 15106, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 225, + "blankVotes": 4229, + "totalVotes": 19560 + } + }, + { + "id": 130307, + "year": 2016, + "office": "State Representative", + "districts": "7th Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Paul-K-Frost", + "https://electionstats.state.ma.us/candidates/view/Terry-Burke-Dotson" + ], + "result": { + "candidates": [ + { + "name": "Paul K. Frost", + "votes": 13691, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Terry Burke Dotson", + "votes": 6951, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 19, + "blankVotes": 1376, + "totalVotes": 22037 + } + }, + { + "id": 130323, + "year": 2016, + "office": "State Representative", + "districts": "8th Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Kevin-J-Kuros" + ], + "result": { + "candidates": [ + { + "name": "Kevin J. Kuros", + "votes": 17436, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 203, + "blankVotes": 5436, + "totalVotes": 23075 + } + }, + { + "id": 130418, + "year": 2016, + "office": "State Representative", + "districts": "9th Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/David-K-Muradian-Jr" + ], + "result": { + "candidates": [ + { + "name": "David K. Muradian, Jr", + "votes": 18513, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 79, + "blankVotes": 4778, + "totalVotes": 23370 + } + }, + { + "id": 130430, + "year": 2016, + "office": "State Representative", + "districts": "10th Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Brian-W-Murray", + "https://electionstats.state.ma.us/candidates/view/Sandra-Slattery-E-Biagetti" + ], + "result": { + "candidates": [ + { + "name": "Brian W. Murray", + "votes": 11869, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Sandra Slattery E. Biagetti", + "votes": 9708, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 25, + "blankVotes": 1168, + "totalVotes": 22770 + } + }, + { + "id": 130469, + "year": 2016, + "office": "State Representative", + "districts": "11th Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Hannah-E-Kane" + ], + "result": { + "candidates": [ + { + "name": "Hannah E. Kane", + "votes": 17835, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 349, + "blankVotes": 5850, + "totalVotes": 24034 + } + }, + { + "id": 130328, + "year": 2016, + "office": "State Representative", + "districts": "12th Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Harold-P-Naughton-Jr", + "https://electionstats.state.ma.us/candidates/view/Charlene-R-Dicalogero" + ], + "result": { + "candidates": [ + { + "name": "Harold P. Naughton, Jr.", + "votes": 15976, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Charlene R. Dicalogero", + "votes": 3916, + "writeIn": false, + "party": "Green-rainbow" + } + ], + "otherVotes": 119, + "blankVotes": 3140, + "totalVotes": 23151 + } + }, + { + "id": 130481, + "year": 2016, + "office": "State Representative", + "districts": "13th Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-J-Mahoney-Jr" + ], + "result": { + "candidates": [ + { + "name": "John J. Mahoney, Jr.", + "votes": 12943, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 268, + "blankVotes": 4594, + "totalVotes": 17805 + } + }, + { + "id": 130477, + "year": 2016, + "office": "State Representative", + "districts": "14th Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/James-J-ODay" + ], + "result": { + "candidates": [ + { + "name": "James J. O'Day", + "votes": 13360, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 257, + "blankVotes": 4535, + "totalVotes": 18152 + } + }, + { + "id": 130479, + "year": 2016, + "office": "State Representative", + "districts": "15th Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Mary-S-Keefe", + "https://electionstats.state.ma.us/candidates/view/Ralph-Perez" + ], + "result": { + "candidates": [ + { + "name": "Mary S. Keefe", + "votes": 8039, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Ralph Perez", + "votes": 2184, + "writeIn": false + } + ], + "otherVotes": 55, + "blankVotes": 1843, + "totalVotes": 12121 + } + }, + { + "id": 130480, + "year": 2016, + "office": "State Representative", + "districts": "16th Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Daniel-M-Donahue", + "https://electionstats.state.ma.us/candidates/view/John-P-Fresolo" + ], + "result": { + "candidates": [ + { + "name": "Daniel M. Donahue", + "votes": 7783, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "John P. Fresolo", + "votes": 3804, + "writeIn": false, + "party": "United Independent" + } + ], + "otherVotes": 32, + "blankVotes": 1136, + "totalVotes": 12755 + } + }, + { + "id": 130432, + "year": 2016, + "office": "State Representative", + "districts": "17th Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Kate-D-Campanale", + "https://electionstats.state.ma.us/candidates/view/Moses-S-Dixon" + ], + "result": { + "candidates": [ + { + "name": "Kate D. Campanale", + "votes": 8011, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Moses S. Dixon", + "votes": 6671, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 31, + "blankVotes": 1007, + "totalVotes": 15720 + } + }, + { + "id": 130396, + "year": 2016, + "office": "State Representative", + "districts": "18th Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Joseph-D-Mckenna" + ], + "result": { + "candidates": [ + { + "name": "Joseph D. Mckenna", + "votes": 16504, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 168, + "blankVotes": 4910, + "totalVotes": 21582 + } + }, + { + "id": 130256, + "year": 2016, + "office": "District Attorney", + "districts": "Bristol", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Thomas-M-Quinn-III" + ], + "result": { + "candidates": [ + { + "name": "Thomas M. Quinn, III", + "votes": 187993, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1146, + "blankVotes": 67547, + "totalVotes": 256686 + } + }, + { + "id": 130282, + "year": 2016, + "office": "Register of Deeds", + "districts": "Dukes", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Paulo-C-Deoliveira", + "https://electionstats.state.ma.us/candidates/view/Martina-Thornton" + ], + "result": { + "candidates": [ + { + "name": "Paulo C. Deoliveira", + "votes": 8229, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Martina Thornton", + "votes": 2467, + "writeIn": false + } + ], + "otherVotes": 12, + "blankVotes": 1143, + "totalVotes": 11851 + } + }, + { + "id": 130336, + "year": 2016, + "office": "Register of Deeds", + "districts": "Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Stephen-J-Murphy", + "https://electionstats.state.ma.us/candidates/view/Margherita-Ciampa-Coyne", + "https://electionstats.state.ma.us/candidates/view/John-A-Keith", + "https://electionstats.state.ma.us/candidates/view/Joseph-M-Donnelly-Jr" + ], + "result": { + "candidates": [ + { + "name": "Stephen J. Murphy", + "votes": 180667, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Margherita Ciampa-Coyne", + "votes": 32689, + "writeIn": false + }, + { + "name": "John A. Keith", + "votes": 18576, + "writeIn": false + }, + { + "name": "Joseph M. Donnelly, Jr", + "votes": 18222, + "writeIn": false + } + ], + "otherVotes": 1685, + "blankVotes": 65368, + "totalVotes": 317207 + } + }, + { + "id": 130317, + "year": 2016, + "office": "Sheriff", + "districts": "Barnstable County", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/James-M-Cummings", + "https://electionstats.state.ma.us/candidates/view/Randy-P-Azzato" + ], + "result": { + "candidates": [ + { + "name": "James M. Cummings", + "votes": 77210, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Randy P. Azzato", + "votes": 52211, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 796, + "blankVotes": 7695, + "totalVotes": 137912 + } + }, + { + "id": 130265, + "year": 2016, + "office": "Sheriff", + "districts": "Berkshire County", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Thomas-N-Bowler" + ], + "result": { + "candidates": [ + { + "name": "Thomas N. Bowler", + "votes": 54402, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 208, + "blankVotes": 11898, + "totalVotes": 66508 + } + }, + { + "id": 130260, + "year": 2016, + "office": "Sheriff", + "districts": "Bristol County", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Thomas-M-Hodgson" + ], + "result": { + "candidates": [ + { + "name": "Thomas M. Hodgson", + "votes": 188055, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 2542, + "blankVotes": 66089, + "totalVotes": 256686 + } + }, + { + "id": 130285, + "year": 2016, + "office": "Sheriff", + "districts": "Dukes County", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Robert-Ogden", + "https://electionstats.state.ma.us/candidates/view/Neal-J-Maciel" + ], + "result": { + "candidates": [ + { + "name": "Robert Ogden", + "votes": 7136, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Neal J. Maciel", + "votes": 3907, + "writeIn": false + } + ], + "otherVotes": 21, + "blankVotes": 787, + "totalVotes": 11851 + } + }, + { + "id": 130274, + "year": 2016, + "office": "Sheriff", + "districts": "Essex County", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Kevin-F-Coppinger", + "https://electionstats.state.ma.us/candidates/view/Anne-M-Manning-Martin", + "https://electionstats.state.ma.us/candidates/view/Mark-E-Archer", + "https://electionstats.state.ma.us/candidates/view/Kelvin-J-Leach" + ], + "result": { + "candidates": [ + { + "name": "Kevin F. Coppinger", + "votes": 178699, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Anne M. Manning-Martin", + "votes": 119795, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Mark E. Archer", + "votes": 26036, + "writeIn": false + }, + { + "name": "Kelvin J. Leach", + "votes": 22242, + "writeIn": false + } + ], + "otherVotes": 750, + "blankVotes": 45267, + "totalVotes": 392789 + } + }, + { + "id": 130297, + "year": 2016, + "office": "Sheriff", + "districts": "Franklin County", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Christopher-J-Donelan" + ], + "result": { + "candidates": [ + { + "name": "Christopher J. Donelan", + "votes": 32254, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 124, + "blankVotes": 7153, + "totalVotes": 39531 + } + }, + { + "id": 130268, + "year": 2016, + "office": "Sheriff", + "districts": "Hampden County", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Nick-Cocchi", + "https://electionstats.state.ma.us/candidates/view/John-M-Comerford", + "https://electionstats.state.ma.us/candidates/view/James-L-Gill-Jr" + ], + "result": { + "candidates": [ + { + "name": "Nick Cocchi", + "votes": 132396, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "John M. Comerford", + "votes": 33459, + "writeIn": false, + "party": "Republican" + }, + { + "name": "James L. Gill, Jr", + "votes": 28225, + "writeIn": false + } + ], + "otherVotes": 415, + "blankVotes": 14841, + "totalVotes": 209336 + } + }, + { + "id": 130278, + "year": 2016, + "office": "Sheriff", + "districts": "Hampshire County", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Patrick-J-Cahillane", + "https://electionstats.state.ma.us/candidates/view/David-F-Isakson" + ], + "result": { + "candidates": [ + { + "name": "Patrick J. Cahillane", + "votes": 59951, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "David F. Isakson", + "votes": 18868, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 175, + "blankVotes": 6630, + "totalVotes": 85624 + } + }, + { + "id": 130253, + "year": 2016, + "office": "Sheriff", + "districts": "Middlesex County", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Peter-J-Koutoujian" + ], + "result": { + "candidates": [ + { + "name": "Peter J. Koutoujian", + "votes": 589594, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 7780, + "blankVotes": 211256, + "totalVotes": 808630 + } + }, + { + "id": 130454, + "year": 2016, + "office": "Sheriff", + "districts": "Nantucket County", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/James-A-Perelman", + "https://electionstats.state.ma.us/candidates/view/David-J-Aguiar" + ], + "result": { + "candidates": [ + { + "name": "James A. Perelman", + "votes": 5633, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "David J. Aguiar", + "votes": 730, + "writeIn": false + } + ], + "otherVotes": 10, + "blankVotes": 210, + "totalVotes": 6583 + } + }, + { + "id": 130312, + "year": 2016, + "office": "Sheriff", + "districts": "Norfolk County", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-G-Bellotti" + ], + "result": { + "candidates": [ + { + "name": "Michael G. Bellotti", + "votes": 278782, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1994, + "blankVotes": 93766, + "totalVotes": 374542 + } + }, + { + "id": 130247, + "year": 2016, + "office": "Sheriff", + "districts": "Plymouth County", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Joseph-D-McDonald-Jr", + "https://electionstats.state.ma.us/candidates/view/Scott-M-Vecchi" + ], + "result": { + "candidates": [ + { + "name": "Joseph D. McDonald, Jr.", + "votes": 143334, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Scott M. Vecchi", + "votes": 110540, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 174, + "blankVotes": 21458, + "totalVotes": 275506 + } + }, + { + "id": 130360, + "year": 2016, + "office": "Sheriff", + "districts": "Suffolk County", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Steven-W-Tompkins" + ], + "result": { + "candidates": [ + { + "name": "Steven W. Tompkins", + "votes": 231807, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 3836, + "blankVotes": 81564, + "totalVotes": 317207 + } + }, + { + "id": 130293, + "year": 2016, + "office": "Sheriff", + "districts": "Worcester County", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Lewis-G-Evangelidis" + ], + "result": { + "candidates": [ + { + "name": "Lewis G. Evangelidis", + "votes": 288432, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 4140, + "blankVotes": 103524, + "totalVotes": 396096 + } + }, + { + "id": 130295, + "year": 2016, + "office": "Council of Governments Executive Committee", + "districts": "Franklin County", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jay-D-Dipucchio" + ], + "result": { + "candidates": [ + { + "name": "Jay D. Dipucchio", + "votes": 29130, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 57, + "blankVotes": 10344, + "totalVotes": 39531 + } + } +] \ No newline at end of file diff --git a/tests/fixtures/electionResults/2016_general_table.html b/tests/fixtures/electionResults/2016_general_table.html new file mode 100644 index 000000000..30a5e4a7a --- /dev/null +++ b/tests/fixtures/electionResults/2016_general_table.html @@ -0,0 +1,3491 @@ + + + + + + + + PD43+ » 2016 President General Election + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ +  + + + + +
+ + +
+ + + + + + + + + + + + + + +
+ + + + +
+ + + + + +
+ + +
+
+ + + + + +
+ + +
+
+ + + + + +
 
+
+ + + +
 
+ + +
+
+ + + + + + +
+ + + + + + + + +
+
+ +
+ +« Go Back +  +« Go Back + + + +
+ +
+
+ + + + + + + + + + + + +
+
+ + + + +
+
+ SHARE THIS DATA: + + + + + + + + + +
+
+ + + + + + + +
+
Candidate Key
+
+ +
+
 
+
Clinton and Kaine +
+ Democratic +
+ +
 
+
+ +
+
 
+
Trump and Pence +
+ Republican +
+ +
 
+
+ +
+
 
+
Johnson and Weld +
+ Libertarian +
+ +
 
+
+ +
+
 
+
Stein and Baraka +
+ Green-rainbow +
+ +
 
+
+ +
+
 
+
Mcmullin and Johnson +
+ Write-In +
+ +
 
+
+ +
+
 
+
Kotlikoff and Leamer +
+ Write-In +
+ +
 
+
+ +
+
 
+
Feegbeh and O'Brien +
+ Write-In +
+ +
 
+
+ +
+
 
+
Moorehead and Lilly +
+ Write-In +
+ +
 
+
+ +
+
 
+
Schoenke and Mitchel +
+ Write-In +
+ +
 
+
+ +
 
+
+
+ + + + + + + + +
+
+ + +
+
+

2016 President General Election

+ + + + + + + +
+ +
+ + + + + + + + + + + + +
+ + + + + + +
View as: # | % 
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CountyAll OthersBlanksTotal Votes Cast
Barnstable + More » +
 
72,430
54,099
5,337
1,919
77
0
0
0
0
1,9192,131137,912
Berkshire + More » +
 
43,714
16,839
2,555
1,627
68
0
0
0
0
81489166,508
Bristol + More » +
 
129,540
105,443
10,180
3,689
107
0
0
0
0
3,1814,546256,686
Dukes + More » +
 
8,400
2,477
366
259
0
0
0
0
0
17217711,851
Essex + More » +
 
222,310
136,316
16,215
4,679
348
3
0
0
0
5,4997,419392,789
Franklin + More » +
 
24,478
10,364
1,902
1,412
9
0
0
12
0
64471039,531
Hampden + More » +
 
112,590
78,685
8,322
3,456
64
0
0
0
0
2,9843,235209,336
Hampshire + More » +
 
55,367
21,790
3,098
2,624
46
8
22
1
0
1,2371,43185,624
Middlesex + More » +
 
520,360
219,793
32,441
10,511
908
15
6
0
0
12,70111,895808,630
Nantucket + More » +
 
4,146
1,892
260
108
0
0
0
0
0
102756,583
Norfolk + More » +
 
221,819
119,723
15,606
4,051
363
0
0
0
0
6,1336,847374,542
Plymouth + More » +
 
135,513
115,369
12,542
3,203
235
1
0
0
0
4,3144,329275,506
Suffolk + More » +
 
245,751
50,421
7,996
4,465
106
0
0
2
0
4,5423,924317,207
Worcester + More » +
 
198,778
157,682
21,198
5,658
388
1
0
0
0
6,2466,145396,096
Totals
1,995,196
1,090,893
138,018
47,661
2,719
28
28
15
0
50,48853,7553,378,801
+
+
+ + +
+
+ +
+
+ + +
 
+ +
+ + + + + + + +
+ + + + + + + + + + + +
+
+
+ +
+ +
+ +
+
+
+ + + + + + + + + + + + + diff --git a/tests/fixtures/electionResults/2022_to_2026.html b/tests/fixtures/electionResults/2022_to_2026.html new file mode 100644 index 000000000..05b8aeffa --- /dev/null +++ b/tests/fixtures/electionResults/2022_to_2026.html @@ -0,0 +1,9867 @@ + + + + + + + + PD43+ » Search Elections + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ +  + + + + +
+ + +
+ + + + + + + + + + + + + + +
+ + + + +
+ + + + + +
+ + +
+
+ + + + + +
+ + +
+
+ + + + + +
 
+
+ + + +
 
+ + +
+
+ + + + + + +
+ + + + + +
+
+ +
+ + + + + + +
+
+
+
 
+
+
+ + + + + + + + +
+
+ + + +
+
+ SHARE THIS DATA: + + + + + + + + + +
+
+ + + + + +
+ + + + + + + + + +
+ +
+ + + + + + + + +
+ +
+ + + + + +
+
+ +
+
+ + +
+
+ +
Please wait...
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
YearOfficeDistrictStageCandidates
2026State Senate1st MiddlesexSpecial General Election +
+Vanna Howard won + (58%) against 2 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateBerkshire, Hampden, Franklin & HampshireGeneral Election +
+Paul W. Mark won + (71%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateFirst Plymouth & NorfolkGeneral Election +
+Patrick A. O'Connor won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateHampden, Hampshire & WorcesterGeneral Election +
+Jacob R. Oliveira won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateNorfolk & MiddlesexGeneral Election +
+Cynthia Stone Creem won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateNorfolk, Plymouth & BristolGeneral Election +
+William J. Driscoll, Jr won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateNorfolk, Worcester & MiddlesexGeneral Election +
+Rebecca L. Rausch won + (59%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateSecond Plymouth & NorfolkGeneral Election +
+Michael D. Brady won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateThird Bristol & PlymouthGeneral Election +
+Kelly A. Dooner won + (48%) against 2 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateWorcester & HampdenGeneral Election +
+Ryan C. Fattman won + (66%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateWorcester & HampshireGeneral Election +
+Peter J. Durant won + (58%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateBristol and NorfolkGeneral Election +
+Paul R. Feeney won + (73%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate1st Bristol and PlymouthGeneral Election +
+Michael J. Rodrigues won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate2nd Bristol and PlymouthGeneral Election +
+Mark C. Montigny won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateCape and IslandsGeneral Election +
+Julian Andre Cyr won + (61%) against 2 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate1st EssexGeneral Election +
+Pavel M. Payano won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate2nd EssexGeneral Election +
+Joan B. Lovely won + (67%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate3rd EssexGeneral Election +
+Brendan Peter Crighton won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate1st Essex and MiddlesexGeneral Election +
+Bruce E. Tarr won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate2nd Essex and MiddlesexGeneral Election +
+Barry R. Finegold won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateHampdenGeneral Election +
+Adam Gomez won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateHampden and HampshireGeneral Election +
+John C. Velis won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateHampshire, Franklin and WorcesterGeneral Election +
+Jo Comerford won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate1st MiddlesexGeneral Election +
+Edward J. Kennedy, Jr won + (63%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate2nd MiddlesexGeneral Election +
+Patricia D. Jehlen won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate3rd MiddlesexGeneral Election +
+Michael J. Barrett won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate4th MiddlesexGeneral Election +
+Cindy F. Friedman won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate5th MiddlesexGeneral Election +
+Jason M. Lewis won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateMiddlesex and NorfolkGeneral Election +
+Karen E. Spilka won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateMiddlesex and WorcesterGeneral Election +
+James B. Eldridge won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateMiddlesex and SuffolkGeneral Election +
+Sal N. DiDomenico won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateNorfolk and PlymouthGeneral Election +
+John F. Keenan won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateNorfolk and SuffolkGeneral Election +
+Michael F. Rush won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenatePlymouth and BarnstableGeneral Election +
+Dylan A. Fernandes won + (51%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate1st SuffolkGeneral Election +
+Nicholas P. Collins won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate2nd SuffolkGeneral Election +
+Liz Miranda won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate3rd SuffolkGeneral Election +
+Lydia Marie Edwards won + (70%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateSuffolk and MiddlesexGeneral Election +
+William N. Brownsberger won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateWorcester and MiddlesexGeneral Election +
+John J. Cronin won + (59%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate1st WorcesterGeneral Election +
+Robyn K. Kennedy won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate2nd WorcesterGeneral Election +
+Michael O. Moore won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2023State SenateWorcester & HampshireSpecial General Election +
+Peter J. Durant won + (54%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateBerkshire, Hampden, Franklin & HampshireGeneral Election +
+Paul W. Mark won + (76%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateFirst Plymouth & NorfolkGeneral Election +
+Patrick Michael O'Connor won + (61%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateHampden, Hampshire & WorcesterGeneral Election +
+Jacob R. Oliveira won + (56%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateNorfolk & MiddlesexGeneral Election +
+Cynthia Stone Creem won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateNorfolk, Plymouth & BristolGeneral Election +
+Walter F. Timilty, Jr. won + (66%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateNorfolk, Worcester & MiddlesexGeneral Election +
+Rebecca L. Rausch won + (55%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateSecond Plymouth & NorfolkGeneral Election +
+Michael D. Brady won + (64%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateThird Bristol & PlymouthGeneral Election +
+Marc R. Pacheco won + (54%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateWorcester & HampdenGeneral Election +
+Ryan C. Fattman won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateWorcester & HampshireGeneral Election +
+Anne M. Gobi won + (54%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateBristol and NorfolkGeneral Election +
+Paul R. Feeney won + (59%) against 2 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State Senate1st Bristol and PlymouthGeneral Election +
+Michael J. Rodrigues won + (58%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State Senate2nd Bristol and PlymouthGeneral Election +
+Mark C. Montigny won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateCape and IslandsGeneral Election +
+Julian Andre Cyr won + (64%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State Senate1st EssexGeneral Election +
+Pavel M. Payano won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State Senate2nd EssexGeneral Election +
+Joan B. Lovely won + (68%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State Senate3rd EssexGeneral Election +
+Brendan P. Crighton won + (71%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State Senate1st Essex and MiddlesexGeneral Election +
+Bruce E. Tarr won + (71%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State Senate2nd Essex and MiddlesexGeneral Election +
+Barry R. Finegold won + (57%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateHampdenGeneral Election +
+Adam Gomez won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateHampden and HampshireGeneral Election +
+John C. Velis won + (66%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateHampshire, Franklin and WorcesterGeneral Election +
+Jo Comerford won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State Senate1st MiddlesexGeneral Election +
+Edward J. Kennedy, Jr won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State Senate2nd MiddlesexGeneral Election +
+Patricia D. Jehlen won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State Senate3rd MiddlesexGeneral Election +
+Michael J. Barrett won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State Senate4th MiddlesexGeneral Election +
+Cindy F. Friedman won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State Senate5th MiddlesexGeneral Election +
+Jason M. Lewis won + (64%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateMiddlesex and NorfolkGeneral Election +
+Karen E. Spilka won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateMiddlesex and WorcesterGeneral Election +
+James B. Eldridge won + (70%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateMiddlesex and SuffolkGeneral Election +
+Sal N. DiDomenico won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateNorfolk and PlymouthGeneral Election +
+John F. Keenan won + (64%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateNorfolk and SuffolkGeneral Election +
+Michael F. Rush won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenatePlymouth and BarnstableGeneral Election +
+Susan Lynn Moran won + (56%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State Senate1st SuffolkGeneral Election +
+Nicholas P. Collins won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State Senate2nd SuffolkGeneral Election +
+Liz Miranda won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State Senate3rd SuffolkGeneral Election +
+Lydia Marie Edwards won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateSuffolk and MiddlesexGeneral Election +
+William N. Brownsberger won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State Senate1st Suffolk and MiddlesexSpecial General Election +
+Lydia Marie Edwards won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State SenateWorcester and MiddlesexGeneral Election +
+John J. Cronin won + (60%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State Senate1st WorcesterGeneral Election +
+Robyn K. Kennedy won + (73%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2022State Senate2nd WorcesterGeneral Election +
+Michael O. Moore won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
 
+ +
+ + + + + + + +
+ + + + + + + + + + + +
+
+
+ +
+ +
+ +
+
+
+ + + + + + + + + + + + + diff --git a/tests/fixtures/electionResults/2022_to_2026.json b/tests/fixtures/electionResults/2022_to_2026.json new file mode 100644 index 000000000..db6ac66e9 --- /dev/null +++ b/tests/fixtures/electionResults/2022_to_2026.json @@ -0,0 +1,2267 @@ +[ + { + "id": 171923, + "year": 2026, + "office": "State Senate", + "districts": "1st Middlesex", + "party": "General", + "special": true, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Vanna-Howard", + "https://electionstats.state.ma.us/candidates/view/Sam-S-Meas", + "https://electionstats.state.ma.us/candidates/view/Joe-Espinola" + ], + "result": { + "candidates": [ + { + "name": "Vanna Howard", + "votes": 4306, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Sam S. Meas", + "votes": 1699, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Joe Espinola", + "votes": 1379, + "writeIn": false + } + ], + "otherVotes": 16, + "blankVotes": 19, + "totalVotes": 7419 + } + }, + { + "id": 165325, + "year": 2024, + "office": "State Senate", + "districts": "Berkshire, Hampden, Franklin & Hampshire", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Paul-W-Mark", + "https://electionstats.state.ma.us/candidates/view/David-A-Rosa" + ], + "result": { + "candidates": [ + { + "name": "Paul W. Mark", + "votes": 62249, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "David A. Rosa", + "votes": 25494, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 68, + "blankVotes": 7170, + "totalVotes": 94981 + } + }, + { + "id": 165464, + "year": 2024, + "office": "State Senate", + "districts": "First Plymouth & Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Patrick-A-OConnor" + ], + "result": { + "candidates": [ + { + "name": "Patrick A. O'Connor", + "votes": 82720, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 935, + "blankVotes": 25443, + "totalVotes": 109098 + } + }, + { + "id": 165393, + "year": 2024, + "office": "State Senate", + "districts": "Hampden, Hampshire & Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jacob-R-Oliveira" + ], + "result": { + "candidates": [ + { + "name": "Jacob R. Oliveira", + "votes": 68420, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1380, + "blankVotes": 25855, + "totalVotes": 95655 + } + }, + { + "id": 165448, + "year": 2024, + "office": "State Senate", + "districts": "Norfolk & Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Cynthia-Stone-Creem" + ], + "result": { + "candidates": [ + { + "name": "Cynthia Stone Creem", + "votes": 68002, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 924, + "blankVotes": 22533, + "totalVotes": 91459 + } + }, + { + "id": 165439, + "year": 2024, + "office": "State Senate", + "districts": "Norfolk, Plymouth & Bristol", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/William-J-Driscoll-Jr" + ], + "result": { + "candidates": [ + { + "name": "William J. Driscoll, Jr", + "votes": 63145, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1010, + "blankVotes": 25835, + "totalVotes": 89990 + } + }, + { + "id": 165395, + "year": 2024, + "office": "State Senate", + "districts": "Norfolk, Worcester & Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Rebecca-L-Rausch", + "https://electionstats.state.ma.us/candidates/view/Dashe-M-Videira" + ], + "result": { + "candidates": [ + { + "name": "Rebecca L. Rausch", + "votes": 57309, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Dashe M. Videira", + "votes": 39983, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 184, + "blankVotes": 7675, + "totalVotes": 105151 + } + }, + { + "id": 165382, + "year": 2024, + "office": "State Senate", + "districts": "Second Plymouth & Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-D-Brady" + ], + "result": { + "candidates": [ + { + "name": "Michael D. Brady", + "votes": 55766, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 487, + "blankVotes": 17457, + "totalVotes": 73710 + } + }, + { + "id": 165398, + "year": 2024, + "office": "State Senate", + "districts": "Third Bristol & Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Kelly-A-Dooner", + "https://electionstats.state.ma.us/candidates/view/Joseph-Richard-Pacheco", + "https://electionstats.state.ma.us/candidates/view/James-B-Dupont" + ], + "result": { + "candidates": [ + { + "name": "Kelly A. Dooner", + "votes": 45777, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Joseph Richard Pacheco", + "votes": 44137, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "James B. Dupont", + "votes": 4737, + "writeIn": false + } + ], + "otherVotes": 86, + "blankVotes": 6737, + "totalVotes": 101474 + } + }, + { + "id": 165405, + "year": 2024, + "office": "State Senate", + "districts": "Worcester & Hampden", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Ryan-C-Fattman", + "https://electionstats.state.ma.us/candidates/view/Anthony-Jm-Allard" + ], + "result": { + "candidates": [ + { + "name": "Ryan C. Fattman", + "votes": 65438, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Anthony Jm Allard", + "votes": 33911, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 153, + "blankVotes": 4958, + "totalVotes": 104460 + } + }, + { + "id": 165388, + "year": 2024, + "office": "State Senate", + "districts": "Worcester & Hampshire", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Peter-J-Durant", + "https://electionstats.state.ma.us/candidates/view/Sheila-H-Dibb" + ], + "result": { + "candidates": [ + { + "name": "Peter J. Durant", + "votes": 52465, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Sheila H. Dibb", + "votes": 37346, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 160, + "blankVotes": 4494, + "totalVotes": 94465 + } + }, + { + "id": 165376, + "year": 2024, + "office": "State Senate", + "districts": "Bristol and Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Paul-R-Feeney", + "https://electionstats.state.ma.us/candidates/view/Laura-L-Saylor" + ], + "result": { + "candidates": [ + { + "name": "Paul R. Feeney", + "votes": 61252, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Laura L. Saylor", + "votes": 22661, + "writeIn": false, + "party": "Workers Party" + } + ], + "otherVotes": 432, + "blankVotes": 17348, + "totalVotes": 101693 + } + }, + { + "id": 165484, + "year": 2024, + "office": "State Senate", + "districts": "1st Bristol and Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-J-Rodrigues" + ], + "result": { + "candidates": [ + { + "name": "Michael J. Rodrigues", + "votes": 55377, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1346, + "blankVotes": 23986, + "totalVotes": 80709 + } + }, + { + "id": 165319, + "year": 2024, + "office": "State Senate", + "districts": "2nd Bristol and Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Mark-C-Montigny" + ], + "result": { + "candidates": [ + { + "name": "Mark C. Montigny", + "votes": 55918, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1229, + "blankVotes": 17198, + "totalVotes": 74345 + } + }, + { + "id": 165352, + "year": 2024, + "office": "State Senate", + "districts": "Cape and Islands", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Julian-Andre-Cyr", + "https://electionstats.state.ma.us/candidates/view/Christopher-Robert-Lauzon", + "https://electionstats.state.ma.us/candidates/view/Joe-Van-Nes" + ], + "result": { + "candidates": [ + { + "name": "Julian Andre Cyr", + "votes": 64636, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Christopher Robert Lauzon", + "votes": 38893, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Joe Van Nes", + "votes": 2493, + "writeIn": false + } + ], + "otherVotes": 158, + "blankVotes": 4780, + "totalVotes": 110960 + } + }, + { + "id": 165497, + "year": 2024, + "office": "State Senate", + "districts": "1st Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Pavel-M-Payano" + ], + "result": { + "candidates": [ + { + "name": "Pavel M. Payano", + "votes": 39632, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1515, + "blankVotes": 12405, + "totalVotes": 53552 + } + }, + { + "id": 165402, + "year": 2024, + "office": "State Senate", + "districts": "2nd Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Joan-B-Lovely", + "https://electionstats.state.ma.us/candidates/view/Damian-Mitchell-Anketell" + ], + "result": { + "candidates": [ + { + "name": "Joan B. Lovely", + "votes": 58109, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Damian Mitchell Anketell", + "votes": 28813, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 86, + "blankVotes": 5937, + "totalVotes": 92945 + } + }, + { + "id": 165514, + "year": 2024, + "office": "State Senate", + "districts": "3rd Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Brendan-Peter-Crighton" + ], + "result": { + "candidates": [ + { + "name": "Brendan Peter Crighton", + "votes": 56430, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1277, + "blankVotes": 23058, + "totalVotes": 80765 + } + }, + { + "id": 165437, + "year": 2024, + "office": "State Senate", + "districts": "1st Essex and Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Bruce-E-Tarr" + ], + "result": { + "candidates": [ + { + "name": "Bruce E. Tarr", + "votes": 88918, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 1112, + "blankVotes": 27172, + "totalVotes": 117202 + } + }, + { + "id": 165339, + "year": 2024, + "office": "State Senate", + "districts": "2nd Essex and Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Barry-R-Finegold" + ], + "result": { + "candidates": [ + { + "name": "Barry R. Finegold", + "votes": 73764, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1208, + "blankVotes": 30064, + "totalVotes": 105036 + } + }, + { + "id": 165462, + "year": 2024, + "office": "State Senate", + "districts": "Hampden", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Adam-Gomez" + ], + "result": { + "candidates": [ + { + "name": "Adam Gomez", + "votes": 43211, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1340, + "blankVotes": 9751, + "totalVotes": 54302 + } + }, + { + "id": 165330, + "year": 2024, + "office": "State Senate", + "districts": "Hampden and Hampshire", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-C-Velis" + ], + "result": { + "candidates": [ + { + "name": "John C. Velis", + "votes": 62335, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 831, + "blankVotes": 19074, + "totalVotes": 82240 + } + }, + { + "id": 165345, + "year": 2024, + "office": "State Senate", + "districts": "Hampshire, Franklin and Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jo-Comerford" + ], + "result": { + "candidates": [ + { + "name": "Jo Comerford", + "votes": 67078, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 988, + "blankVotes": 16775, + "totalVotes": 84841 + } + }, + { + "id": 165473, + "year": 2024, + "office": "State Senate", + "districts": "1st Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Edward-J-Kennedy", + "https://electionstats.state.ma.us/candidates/view/Karla-J-Miller" + ], + "result": { + "candidates": [ + { + "name": "Edward J. Kennedy, Jr", + "votes": 40887, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Karla J. Miller", + "votes": 23422, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 147, + "blankVotes": 5757, + "totalVotes": 70213 + } + }, + { + "id": 165452, + "year": 2024, + "office": "State Senate", + "districts": "2nd Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Patricia-D-Jehlen" + ], + "result": { + "candidates": [ + { + "name": "Patricia D. Jehlen", + "votes": 72232, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1183, + "blankVotes": 17914, + "totalVotes": 91329 + } + }, + { + "id": 165391, + "year": 2024, + "office": "State Senate", + "districts": "3rd Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-J-Barrett" + ], + "result": { + "candidates": [ + { + "name": "Michael J. Barrett", + "votes": 65186, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 496, + "blankVotes": 23542, + "totalVotes": 89224 + } + }, + { + "id": 165356, + "year": 2024, + "office": "State Senate", + "districts": "4th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Cindy-F-Friedman" + ], + "result": { + "candidates": [ + { + "name": "Cindy F. Friedman", + "votes": 70460, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1235, + "blankVotes": 29256, + "totalVotes": 100951 + } + }, + { + "id": 165519, + "year": 2024, + "office": "State Senate", + "districts": "5th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jason-M-Lewis" + ], + "result": { + "candidates": [ + { + "name": "Jason M. Lewis", + "votes": 63221, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1043, + "blankVotes": 29088, + "totalVotes": 93352 + } + }, + { + "id": 165369, + "year": 2024, + "office": "State Senate", + "districts": "Middlesex and Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Karen-E-Spilka" + ], + "result": { + "candidates": [ + { + "name": "Karen E. Spilka", + "votes": 68762, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1223, + "blankVotes": 19595, + "totalVotes": 89580 + } + }, + { + "id": 165312, + "year": 2024, + "office": "State Senate", + "districts": "Middlesex and Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/James-B-Eldridge" + ], + "result": { + "candidates": [ + { + "name": "James B. Eldridge", + "votes": 75178, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 927, + "blankVotes": 24174, + "totalVotes": 100279 + } + }, + { + "id": 165431, + "year": 2024, + "office": "State Senate", + "districts": "Middlesex and Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Sal-N-DiDomenico" + ], + "result": { + "candidates": [ + { + "name": "Sal N. DiDomenico", + "votes": 47113, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 736, + "blankVotes": 12594, + "totalVotes": 60443 + } + }, + { + "id": 165305, + "year": 2024, + "office": "State Senate", + "districts": "Norfolk and Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-F-Keenan" + ], + "result": { + "candidates": [ + { + "name": "John F. Keenan", + "votes": 61091, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1325, + "blankVotes": 20582, + "totalVotes": 82998 + } + }, + { + "id": 165432, + "year": 2024, + "office": "State Senate", + "districts": "Norfolk and Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-F-Rush" + ], + "result": { + "candidates": [ + { + "name": "Michael F. Rush", + "votes": 72352, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1289, + "blankVotes": 27513, + "totalVotes": 101154 + } + }, + { + "id": 165436, + "year": 2024, + "office": "State Senate", + "districts": "Plymouth and Barnstable", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Dylan-A-Fernandes", + "https://electionstats.state.ma.us/candidates/view/Mathew-J-Muratore" + ], + "result": { + "candidates": [ + { + "name": "Dylan A. Fernandes", + "votes": 59137, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Mathew J. Muratore", + "votes": 56335, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 107, + "blankVotes": 6412, + "totalVotes": 121991 + } + }, + { + "id": 165430, + "year": 2024, + "office": "State Senate", + "districts": "1st Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Nicholas-P-Collins" + ], + "result": { + "candidates": [ + { + "name": "Nicholas P. Collins", + "votes": 61127, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1437, + "blankVotes": 16251, + "totalVotes": 78815 + } + }, + { + "id": 165433, + "year": 2024, + "office": "State Senate", + "districts": "2nd Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Liz-Miranda" + ], + "result": { + "candidates": [ + { + "name": "Liz Miranda", + "votes": 52072, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 897, + "blankVotes": 9286, + "totalVotes": 62255 + } + }, + { + "id": 165434, + "year": 2024, + "office": "State Senate", + "districts": "3rd Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Lydia-Marie-Edwards", + "https://electionstats.state.ma.us/candidates/view/Jeannamarie-Tamas" + ], + "result": { + "candidates": [ + { + "name": "Lydia Marie Edwards", + "votes": 41690, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Jeannamarie Tamas", + "votes": 17681, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 281, + "blankVotes": 7865, + "totalVotes": 67517 + } + }, + { + "id": 165396, + "year": 2024, + "office": "State Senate", + "districts": "Suffolk and Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/William-N-Brownsberger" + ], + "result": { + "candidates": [ + { + "name": "William N. Brownsberger", + "votes": 56815, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 685, + "blankVotes": 14509, + "totalVotes": 72009 + } + }, + { + "id": 165362, + "year": 2024, + "office": "State Senate", + "districts": "Worcester and Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-J-Cronin", + "https://electionstats.state.ma.us/candidates/view/Nicholas-A-Pirro-III" + ], + "result": { + "candidates": [ + { + "name": "John J. Cronin", + "votes": 50235, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Nicholas A. Pirro, III", + "votes": 34759, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 100, + "blankVotes": 4594, + "totalVotes": 89688 + } + }, + { + "id": 165400, + "year": 2024, + "office": "State Senate", + "districts": "1st Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Robyn-K-Kennedy" + ], + "result": { + "candidates": [ + { + "name": "Robyn K. Kennedy", + "votes": 49613, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1531, + "blankVotes": 13654, + "totalVotes": 64798 + } + }, + { + "id": 165378, + "year": 2024, + "office": "State Senate", + "districts": "2nd Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-O-Moore" + ], + "result": { + "candidates": [ + { + "name": "Michael O. Moore", + "votes": 59686, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1052, + "blankVotes": 16563, + "totalVotes": 77301 + } + }, + { + "id": 160410, + "year": 2023, + "office": "State Senate", + "districts": "Worcester & Hampshire", + "party": "General", + "special": true, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Peter-J-Durant", + "https://electionstats.state.ma.us/candidates/view/Jonathan-D-Zlotnik" + ], + "result": { + "candidates": [ + { + "name": "Peter J. Durant", + "votes": 12646, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Jonathan D. Zlotnik", + "votes": 10546, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 30, + "blankVotes": 90, + "totalVotes": 23312 + } + }, + { + "id": 154365, + "year": 2022, + "office": "State Senate", + "districts": "Berkshire, Hampden, Franklin & Hampshire", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Paul-W-Mark", + "https://electionstats.state.ma.us/candidates/view/Brendan-M-Phair" + ], + "result": { + "candidates": [ + { + "name": "Paul W. Mark", + "votes": 47989, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Brendan M. Phair", + "votes": 14806, + "writeIn": false + } + ], + "otherVotes": 139, + "blankVotes": 6306, + "totalVotes": 69240 + } + }, + { + "id": 154498, + "year": 2022, + "office": "State Senate", + "districts": "First Plymouth & Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Patrick-Michael-OConnor", + "https://electionstats.state.ma.us/candidates/view/Robert-William-Stephens-Jr" + ], + "result": { + "candidates": [ + { + "name": "Patrick Michael O'Connor", + "votes": 48668, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Robert William Stephens, Jr", + "votes": 31609, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 42, + "blankVotes": 2952, + "totalVotes": 83271 + } + }, + { + "id": 154431, + "year": 2022, + "office": "State Senate", + "districts": "Hampden, Hampshire & Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jacob-R-Oliveira", + "https://electionstats.state.ma.us/candidates/view/William-E-Johnson" + ], + "result": { + "candidates": [ + { + "name": "Jacob R. Oliveira", + "votes": 37410, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "William E. Johnson", + "votes": 29027, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 31, + "blankVotes": 1681, + "totalVotes": 68149 + } + }, + { + "id": 154482, + "year": 2022, + "office": "State Senate", + "districts": "Norfolk & Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Cynthia-Stone-Creem" + ], + "result": { + "candidates": [ + { + "name": "Cynthia Stone Creem", + "votes": 55022, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 713, + "blankVotes": 15213, + "totalVotes": 70948 + } + }, + { + "id": 154473, + "year": 2022, + "office": "State Senate", + "districts": "Norfolk, Plymouth & Bristol", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Walter-F-Timilty-Jr", + "https://electionstats.state.ma.us/candidates/view/Brian-R-Muello" + ], + "result": { + "candidates": [ + { + "name": "Walter F. Timilty, Jr.", + "votes": 40311, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Brian R. Muello", + "votes": 20648, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 86, + "blankVotes": 2996, + "totalVotes": 64041 + } + }, + { + "id": 154433, + "year": 2022, + "office": "State Senate", + "districts": "Norfolk, Worcester & Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Rebecca-L-Rausch", + "https://electionstats.state.ma.us/candidates/view/Shawn-C-Dooley" + ], + "result": { + "candidates": [ + { + "name": "Rebecca L. Rausch", + "votes": 41893, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Shawn C. Dooley", + "votes": 34452, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 53, + "blankVotes": 1950, + "totalVotes": 78348 + } + }, + { + "id": 154419, + "year": 2022, + "office": "State Senate", + "districts": "Second Plymouth & Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-D-Brady", + "https://electionstats.state.ma.us/candidates/view/Jim-Gordon" + ], + "result": { + "candidates": [ + { + "name": "Michael D. Brady", + "votes": 29297, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Jim Gordon", + "votes": 16693, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 38, + "blankVotes": 1733, + "totalVotes": 47761 + } + }, + { + "id": 154436, + "year": 2022, + "office": "State Senate", + "districts": "Third Bristol & Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Marc-R-Pacheco", + "https://electionstats.state.ma.us/candidates/view/Maria-S-Collins" + ], + "result": { + "candidates": [ + { + "name": "Marc R. Pacheco", + "votes": 35556, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Maria S. Collins", + "votes": 29937, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 32, + "blankVotes": 2105, + "totalVotes": 67630 + } + }, + { + "id": 154442, + "year": 2022, + "office": "State Senate", + "districts": "Worcester & Hampden", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Ryan-C-Fattman" + ], + "result": { + "candidates": [ + { + "name": "Ryan C. Fattman", + "votes": 53456, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 833, + "blankVotes": 17109, + "totalVotes": 71398 + } + }, + { + "id": 154427, + "year": 2022, + "office": "State Senate", + "districts": "Worcester & Hampshire", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Anne-M-Gobi", + "https://electionstats.state.ma.us/candidates/view/James-Anthony-Amorello" + ], + "result": { + "candidates": [ + { + "name": "Anne M. Gobi", + "votes": 35409, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "James Anthony Amorello", + "votes": 29734, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 15, + "blankVotes": 1580, + "totalVotes": 66738 + } + }, + { + "id": 154413, + "year": 2022, + "office": "State Senate", + "districts": "Bristol and Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Paul-R-Feeney", + "https://electionstats.state.ma.us/candidates/view/Michael-Chaisson", + "https://electionstats.state.ma.us/candidates/view/Laura-L-Saylor" + ], + "result": { + "candidates": [ + { + "name": "Paul R. Feeney", + "votes": 40353, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Michael Chaisson", + "votes": 26221, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Laura L. Saylor", + "votes": 2168, + "writeIn": false, + "party": "Workers Party" + } + ], + "otherVotes": 17, + "blankVotes": 2733, + "totalVotes": 71492 + } + }, + { + "id": 154517, + "year": 2022, + "office": "State Senate", + "districts": "1st Bristol and Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-J-Rodrigues", + "https://electionstats.state.ma.us/candidates/view/Russell-T-Protentis" + ], + "result": { + "candidates": [ + { + "name": "Michael J. Rodrigues", + "votes": 29420, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Russell T. Protentis", + "votes": 21600, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 34, + "blankVotes": 1920, + "totalVotes": 52974 + } + }, + { + "id": 154359, + "year": 2022, + "office": "State Senate", + "districts": "2nd Bristol and Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Mark-C-Montigny" + ], + "result": { + "candidates": [ + { + "name": "Mark C. Montigny", + "votes": 35193, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1018, + "blankVotes": 12524, + "totalVotes": 48735 + } + }, + { + "id": 154391, + "year": 2022, + "office": "State Senate", + "districts": "Cape and Islands", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Julian-Andre-Cyr", + "https://electionstats.state.ma.us/candidates/view/Christopher-Robert-Lauzon" + ], + "result": { + "candidates": [ + { + "name": "Julian Andre Cyr", + "votes": 54714, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Christopher Robert Lauzon", + "votes": 31176, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 32, + "blankVotes": 1722, + "totalVotes": 87644 + } + }, + { + "id": 154530, + "year": 2022, + "office": "State Senate", + "districts": "1st Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Pavel-M-Payano" + ], + "result": { + "candidates": [ + { + "name": "Pavel M. Payano", + "votes": 21591, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1256, + "blankVotes": 8106, + "totalVotes": 30953 + } + }, + { + "id": 154440, + "year": 2022, + "office": "State Senate", + "districts": "2nd Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Joan-B-Lovely", + "https://electionstats.state.ma.us/candidates/view/Damian-M-Anketell" + ], + "result": { + "candidates": [ + { + "name": "Joan B. Lovely", + "votes": 44277, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Damian M. Anketell", + "votes": 21108, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 50, + "blankVotes": 2022, + "totalVotes": 67457 + } + }, + { + "id": 154548, + "year": 2022, + "office": "State Senate", + "districts": "3rd Essex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Brendan-P-Crighton", + "https://electionstats.state.ma.us/candidates/view/Annalisa-Salustri" + ], + "result": { + "candidates": [ + { + "name": "Brendan P. Crighton", + "votes": 34620, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Annalisa Salustri", + "votes": 13910, + "writeIn": false + } + ], + "otherVotes": 205, + "blankVotes": 7443, + "totalVotes": 56178 + } + }, + { + "id": 154471, + "year": 2022, + "office": "State Senate", + "districts": "1st Essex and Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Bruce-E-Tarr", + "https://electionstats.state.ma.us/candidates/view/Terence-William-Cudney" + ], + "result": { + "candidates": [ + { + "name": "Bruce E. Tarr", + "votes": 58838, + "writeIn": false, + "party": "Republican" + }, + { + "name": "Terence William Cudney", + "votes": 23408, + "writeIn": false + } + ], + "otherVotes": 171, + "blankVotes": 7075, + "totalVotes": 89492 + } + }, + { + "id": 154378, + "year": 2022, + "office": "State Senate", + "districts": "2nd Essex and Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Barry-R-Finegold", + "https://electionstats.state.ma.us/candidates/view/SalvatorE-Paul-Defranco" + ], + "result": { + "candidates": [ + { + "name": "Barry R. Finegold", + "votes": 42932, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "SalvatorE Paul Defranco", + "votes": 31926, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 42, + "blankVotes": 1727, + "totalVotes": 76627 + } + }, + { + "id": 154496, + "year": 2022, + "office": "State Senate", + "districts": "Hampden", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Adam-Gomez" + ], + "result": { + "candidates": [ + { + "name": "Adam Gomez", + "votes": 23665, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 845, + "blankVotes": 5790, + "totalVotes": 30300 + } + }, + { + "id": 154370, + "year": 2022, + "office": "State Senate", + "districts": "Hampden and Hampshire", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-C-Velis", + "https://electionstats.state.ma.us/candidates/view/Cecilia-P-Calabrese" + ], + "result": { + "candidates": [ + { + "name": "John C. Velis", + "votes": 37130, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Cecilia P. Calabrese", + "votes": 19388, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 77, + "blankVotes": 1244, + "totalVotes": 57839 + } + }, + { + "id": 154383, + "year": 2022, + "office": "State Senate", + "districts": "Hampshire, Franklin and Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jo-Comerford" + ], + "result": { + "candidates": [ + { + "name": "Jo Comerford", + "votes": 51232, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1280, + "blankVotes": 11039, + "totalVotes": 63551 + } + }, + { + "id": 154507, + "year": 2022, + "office": "State Senate", + "districts": "1st Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Edward-J-Kennedy" + ], + "result": { + "candidates": [ + { + "name": "Edward J. Kennedy, Jr", + "votes": 32003, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 847, + "blankVotes": 12782, + "totalVotes": 45632 + } + }, + { + "id": 154486, + "year": 2022, + "office": "State Senate", + "districts": "2nd Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Patricia-D-Jehlen" + ], + "result": { + "candidates": [ + { + "name": "Patricia D. Jehlen", + "votes": 53866, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 439, + "blankVotes": 12403, + "totalVotes": 66708 + } + }, + { + "id": 154429, + "year": 2022, + "office": "State Senate", + "districts": "3rd Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-J-Barrett" + ], + "result": { + "candidates": [ + { + "name": "Michael J. Barrett", + "votes": 50728, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 672, + "blankVotes": 17403, + "totalVotes": 68803 + } + }, + { + "id": 154396, + "year": 2022, + "office": "State Senate", + "districts": "4th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Cindy-F-Friedman" + ], + "result": { + "candidates": [ + { + "name": "Cindy F. Friedman", + "votes": 54112, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1107, + "blankVotes": 21232, + "totalVotes": 76451 + } + }, + { + "id": 154553, + "year": 2022, + "office": "State Senate", + "districts": "5th Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jason-M-Lewis", + "https://electionstats.state.ma.us/candidates/view/Edward-F-Dombroski-Jr" + ], + "result": { + "candidates": [ + { + "name": "Jason M. Lewis", + "votes": 42130, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Edward F. Dombroski, Jr", + "votes": 24104, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 63, + "blankVotes": 2625, + "totalVotes": 68922 + } + }, + { + "id": 154408, + "year": 2022, + "office": "State Senate", + "districts": "Middlesex and Norfolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Karen-E-Spilka" + ], + "result": { + "candidates": [ + { + "name": "Karen E. Spilka", + "votes": 52484, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 952, + "blankVotes": 14075, + "totalVotes": 67511 + } + }, + { + "id": 154350, + "year": 2022, + "office": "State Senate", + "districts": "Middlesex and Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/James-B-Eldridge", + "https://electionstats.state.ma.us/candidates/view/Anthony-Christakis" + ], + "result": { + "candidates": [ + { + "name": "James B. Eldridge", + "votes": 51574, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Anthony Christakis", + "votes": 21819, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 44, + "blankVotes": 2528, + "totalVotes": 75965 + } + }, + { + "id": 154464, + "year": 2022, + "office": "State Senate", + "districts": "Middlesex and Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Sal-N-DiDomenico" + ], + "result": { + "candidates": [ + { + "name": "Sal N. DiDomenico", + "votes": 33355, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 395, + "blankVotes": 7831, + "totalVotes": 41581 + } + }, + { + "id": 154342, + "year": 2022, + "office": "State Senate", + "districts": "Norfolk and Plymouth", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-F-Keenan", + "https://electionstats.state.ma.us/candidates/view/Gary-M-Innes" + ], + "result": { + "candidates": [ + { + "name": "John F. Keenan", + "votes": 36063, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Gary M. Innes", + "votes": 20586, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 38, + "blankVotes": 2248, + "totalVotes": 58935 + } + }, + { + "id": 154465, + "year": 2022, + "office": "State Senate", + "districts": "Norfolk and Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-F-Rush" + ], + "result": { + "candidates": [ + { + "name": "Michael F. Rush", + "votes": 54915, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1043, + "blankVotes": 19742, + "totalVotes": 75700 + } + }, + { + "id": 154470, + "year": 2022, + "office": "State Senate", + "districts": "Plymouth and Barnstable", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Susan-Lynn-Moran", + "https://electionstats.state.ma.us/candidates/view/Kari-Macrae" + ], + "result": { + "candidates": [ + { + "name": "Susan Lynn Moran", + "votes": 49686, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Kari Macrae", + "votes": 38493, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 39, + "blankVotes": 2832, + "totalVotes": 91050 + } + }, + { + "id": 154463, + "year": 2022, + "office": "State Senate", + "districts": "1st Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Nicholas-P-Collins" + ], + "result": { + "candidates": [ + { + "name": "Nicholas P. Collins", + "votes": 41069, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 929, + "blankVotes": 10482, + "totalVotes": 52480 + } + }, + { + "id": 154466, + "year": 2022, + "office": "State Senate", + "districts": "2nd Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Liz-Miranda" + ], + "result": { + "candidates": [ + { + "name": "Liz Miranda", + "votes": 35207, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 439, + "blankVotes": 5011, + "totalVotes": 40657 + } + }, + { + "id": 154467, + "year": 2022, + "office": "State Senate", + "districts": "3rd Suffolk", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Lydia-Marie-Edwards" + ], + "result": { + "candidates": [ + { + "name": "Lydia Marie Edwards", + "votes": 32396, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 1006, + "blankVotes": 11580, + "totalVotes": 44982 + } + }, + { + "id": 154434, + "year": 2022, + "office": "State Senate", + "districts": "Suffolk and Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/William-N-Brownsberger" + ], + "result": { + "candidates": [ + { + "name": "William N. Brownsberger", + "votes": 42713, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 437, + "blankVotes": 9782, + "totalVotes": 52932 + } + }, + { + "id": 145908, + "year": 2022, + "office": "State Senate", + "districts": "1st Suffolk and Middlesex", + "party": "General", + "special": true, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Lydia-Marie-Edwards" + ], + "result": { + "candidates": [ + { + "name": "Lydia Marie Edwards", + "votes": 1764, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 95, + "blankVotes": 34, + "totalVotes": 1893 + } + }, + { + "id": 154402, + "year": 2022, + "office": "State Senate", + "districts": "Worcester and Middlesex", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-J-Cronin", + "https://electionstats.state.ma.us/candidates/view/Kenneth-B-Hoyt" + ], + "result": { + "candidates": [ + { + "name": "John J. Cronin", + "votes": 36784, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Kenneth B. Hoyt", + "votes": 24238, + "writeIn": false, + "party": "Republican" + } + ], + "otherVotes": 35, + "blankVotes": 2232, + "totalVotes": 63289 + } + }, + { + "id": 154438, + "year": 2022, + "office": "State Senate", + "districts": "1st Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Robyn-K-Kennedy", + "https://electionstats.state.ma.us/candidates/view/Lisa-K-Mair" + ], + "result": { + "candidates": [ + { + "name": "Robyn K. Kennedy", + "votes": 30138, + "writeIn": false, + "party": "Democratic" + }, + { + "name": "Lisa K. Mair", + "votes": 10805, + "writeIn": false + } + ], + "otherVotes": 456, + "blankVotes": 3318, + "totalVotes": 44717 + } + }, + { + "id": 154415, + "year": 2022, + "office": "State Senate", + "districts": "2nd Worcester", + "party": "General", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-O-Moore" + ], + "result": { + "candidates": [ + { + "name": "Michael O. Moore", + "votes": 40946, + "writeIn": false, + "party": "Democratic" + } + ], + "otherVotes": 793, + "blankVotes": 12641, + "totalVotes": 54380 + } + } +] \ No newline at end of file diff --git a/tests/fixtures/electionResults/2024_republican_primaries.html b/tests/fixtures/electionResults/2024_republican_primaries.html new file mode 100644 index 000000000..6e71668e2 --- /dev/null +++ b/tests/fixtures/electionResults/2024_republican_primaries.html @@ -0,0 +1,17208 @@ + + + + + + + + PD43+ » Search Elections + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ +  + + + + +
+ + +
+ + + + + + + + + + + + + + +
+ + + + +
+ + + + + +
+ + +
+
+ + + + + +
+ + +
+
+ + + + + +
 
+
+ + + +
 
+ + +
+
+ + + + + + +
+ + + + + +
+
+ +
+ + + + + + +
+
+
+
 
+
+
+ + + + + + + + +
+
+ + + +
+
+ SHARE THIS DATA: + + + + + + + + + +
+
+ + + + + +
+ + + + + +

To visualize the data, specify a state-wide office or a district.

+
+
+ +
+
+ + +
+
+ +
Please wait...
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
YearOfficeDistrictStageCandidates
2025State Representative3rd BristolSpecial Republican Primary +
+Lawrence Joseph Quintal won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2025State Representative6th EssexSpecial Republican Primary +
+Medley Long, III won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024PresidentStatewideRepublican Primary +
+Donald J. Trump won + (60%) against 6 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManBerkshire, Hampden, Franklin & HampshireRepublican Primary +
+Nicholas A. Boldyga won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManFirst Plymouth & NorfolkRepublican Primary +
+David F. Decoste won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManHampden, Hampshire & WorcesterRepublican Primary +
+Sidney M. Starks won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManNorfolk & MiddlesexRepublican Primary +
+Tom Mountain won + (59%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManNorfolk, Plymouth & BristolRepublican Primary +
+Sean E. Powers won + (55%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManNorfolk, Worcester & MiddlesexRepublican Primary +
+Eric Calton won + (62%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManSecond Plymouth & NorfolkRepublican Primary +
+Geoff Diehl won + (77%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManThird Bristol & PlymouthRepublican Primary +
+Mark Edward Townsend won + (56%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManWorcester & HampdenRepublican Primary +
+Ryan Chamberland won + (59%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManWorcester & HampshireRepublican Primary +
+Michael Paul Fountain won + (66%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManBristol and NorfolkRepublican Primary +
+David B. Cannata won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Man1st Bristol and PlymouthRepublican Primary +
+Gabriel Boomer Amaral won + (72%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Man2nd Bristol and PlymouthRepublican Primary +
+Robert S. McConnell won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManCape and IslandsRepublican Primary +
+William L. Crocker, Jr. won + (59%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Man1st EssexRepublican Primary +
+Paul C. Downing won + (77%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Man2nd EssexRepublican Primary +
+John F. McCarthy, Jr. won + (56%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Man3rd EssexRepublican Primary +
+Donald H. Wong won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Man1st Essex and MiddlesexRepublican Primary +
+Michael J. Scarlata won + (34%) against 2 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Man2nd Essex and MiddlesexRepublican Primary +
+Shaun P. Toohey won + (51%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManHampdenRepublican Primary +
+Thomas S. Balcom, Jr won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManHampden and HampshireRepublican Primary +
+Nathan A. Bech won + (75%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManHampshire, Franklin and WorcesterRepublican Primary +
+Christopher J. Ryan won + (66%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Man1st MiddlesexRepublican Primary +
+Brian K. Genest won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Man2nd MiddlesexRepublican Primary +
+John Joseph Sousa won + (77%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Man3rd MiddlesexRepublican Primary +
+James E. Dixon won + (95%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Man4th MiddlesexRepublican Primary +
+Anthony M. Ventresca won + (63%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Man5th MiddlesexRepublican Primary +
+Robert E. Aufiero won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManMiddlesex and NorfolkRepublican Primary +
+Nicholas Blaize Miceli won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManMiddlesex and WorcesterRepublican Primary +
+Dave H. Lunger won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManMiddlesex and SuffolkRepublican Primary +
+Todd B. Taylor won + (52%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManNorfolk and PlymouthRepublican Primary +
+Alexander S. Hagerty won + (54%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManNorfolk and SuffolkRepublican Primary +
+John T. Hasenjaeger won + (64%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManPlymouth and BarnstableRepublican Primary +
+James R. McMahon, III won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Man1st SuffolkRepublican Primary +
+Timothy J. Smyth, Jr won + (49%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Man2nd SuffolkRepublican Primary +
+Timothy McCarthy (Write-In) +ran against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Man3rd SuffolkRepublican Primary +
+Paul J. Ronukaitus won + (59%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManSuffolk and MiddlesexRepublican Primary +
+Daniel Hickey won + (50%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee ManWorcester and MiddlesexRepublican Primary +
+Mark C. Bodanza won + (52%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Man1st WorcesterRepublican Primary +
+Michael A. Vulcano won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Man2nd WorcesterRepublican Primary +
+Paul K. Frost won + (61%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanBerkshire, Hampden, Franklin & HampshireRepublican Primary +
+Jessica L. Boldyga won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanFirst Plymouth & NorfolkRepublican Primary +
+Janet R. Fogarty won + (53%) against 2 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanHampden, Hampshire & WorcesterRepublican Primary +
+Virginia E. Neill won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanNorfolk & MiddlesexRepublican Primary +
+Susan Huffman won + (55%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanNorfolk, Plymouth & BristolRepublican Primary +
+Sandra M. Wright won + (54%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanNorfolk, Worcester & MiddlesexRepublican Primary +
+Amanda Joan Peterson won + (53%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanSecond Plymouth & NorfolkRepublican Primary +
+Kathyjo Boss won + (66%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanThird Bristol & PlymouthRepublican Primary +
+Shaunna L. O'Connell won + (59%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanWorcester & HampdenRepublican Primary +
+Elizabeth L. S. Groot won + (52%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanWorcester & HampshireRepublican Primary +
+Stephanie R. Mulroy won + (61%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanBristol and NorfolkRepublican Primary +
+Julie A. Hall won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Woman1st Bristol and PlymouthRepublican Primary +
+Kayla Rose Churchill won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Woman2nd Bristol and PlymouthRepublican Primary +
+Jo-Anne Mello Hodgson won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanCape and IslandsRepublican Primary +
+Judith Anessa Crocker won + (56%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Woman1st EssexRepublican Primary +
+Maura L. Ryan-Ciardiello won + (81%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Woman2nd EssexRepublican Primary +
+Jaclyn Corriveau won + (51%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Woman3rd EssexRepublican Primary +
+Amy Carnevale won + (63%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Woman1st Essex and MiddlesexRepublican Primary +
+Lisa-Marie C. Cashman won + (49%) against 3 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Woman2nd Essex and MiddlesexRepublican Primary +
+Jeri Ann Levasseur won + (53%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanHampdenRepublican Primary +
No Candidates
+
2024Party State Committee WomanHampden and HampshireRepublican Primary +
+Linda L. Vacon won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanHampshire, Franklin and WorcesterRepublican Primary +
+Sue O'Sullivan won + (63%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Woman1st MiddlesexRepublican Primary +
+Noreen E. Crowley won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Woman2nd MiddlesexRepublican Primary +
+Patricia Brady Doherty won + (64%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Woman3rd MiddlesexRepublican Primary +
+Doreen A. Deshler won + (50%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Woman4th MiddlesexRepublican Primary +
+Helen Anne Hatch won + (65%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Woman5th MiddlesexRepublican Primary +
+Monica C. Medeiros Solano won + (56%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanMiddlesex and NorfolkRepublican Primary +
+Leanne J. Yarosz-Harris won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanMiddlesex and WorcesterRepublican Primary +
+Caroline Stewart Cunningham won + (88%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanMiddlesex and SuffolkRepublican Primary +
+Regina M. Taylor won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanNorfolk and PlymouthRepublican Primary +
+Beth M. Veneto won + (58%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanNorfolk and SuffolkRepublican Primary +
+Lynne Roberts won + (69%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanPlymouth and BarnstableRepublican Primary +
+Jennifer A. Cunningham won + (57%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Woman1st SuffolkRepublican Primary +
+Elizabeth H. Hinds-Ferrick won + (59%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Woman2nd SuffolkRepublican Primary +
+Brittney Rocchina Ciccone (Write-In) +ran against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Woman3rd SuffolkRepublican Primary +
+Vera E. Carducci won + (48%) against 2 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanSuffolk and MiddlesexRepublican Primary +
+Catherine A. Umina won + (55%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee WomanWorcester and MiddlesexRepublican Primary +
+Kathleen Lynch won + (59%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Woman1st WorcesterRepublican Primary +
+Joanne E. Powell won + (50%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Party State Committee Woman2nd WorcesterRepublican Primary +
+Mindy J. McKenzie won + (64%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024U.S. SenateStatewideRepublican Primary +
+John Deaton won + (65%) against 2 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024U.S. House1st CongressionalRepublican Primary +
No Candidates
+
2024U.S. House2nd CongressionalRepublican Primary +
No Candidates
+
2024U.S. House3rd CongressionalRepublican Primary +
No Candidates
+
2024U.S. House4th CongressionalRepublican Primary +
No Candidates
+
2024U.S. House5th CongressionalRepublican Primary +
No Candidates
+
2024U.S. House6th CongressionalRepublican Primary +
No Candidates
+
2024U.S. House7th CongressionalRepublican Primary +
No Candidates
+
2024U.S. House8th CongressionalRepublican Primary +
+Robert G. Burke won + (46%) against 2 opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024U.S. House9th CongressionalRepublican Primary +
+Dan Sullivan won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Governor's Council 1stRepublican Primary +
No Candidates
+
2024Governor's Council 2ndRepublican Primary +
+Francis T. Crimmins, Jr won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Governor's Council 3rdRepublican Primary +
No Candidates
+
2024Governor's Council 4thRepublican Primary +
No Candidates
+
2024Governor's Council 5thRepublican Primary +
+Anne M. Manning-Martin won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Governor's Council 6thRepublican Primary +
No Candidates
+
2024Governor's Council 7thRepublican Primary +
+Andrew J. Couture won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Governor's Council 8thRepublican Primary +
No Candidates
+
2024State SenateBerkshire, Hampden, Franklin & HampshireRepublican Primary +
+David A. Rosa won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateFirst Plymouth & NorfolkRepublican Primary +
+Patrick A. O'Connor won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateHampden, Hampshire & WorcesterRepublican Primary +
No Candidates
+
2024State SenateNorfolk & MiddlesexRepublican Primary +
No Candidates
+
2024State SenateNorfolk, Plymouth & BristolRepublican Primary +
+Steven David Fruzzetti (Write-In) +ran against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateNorfolk, Worcester & MiddlesexRepublican Primary +
+Dashe M. Videira (Write-In) won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateSecond Plymouth & NorfolkRepublican Primary +
No Candidates
+
2024State SenateThird Bristol & PlymouthRepublican Primary +
+Kelly A. Dooner won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateWorcester & HampdenRepublican Primary +
+Ryan C. Fattman won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateWorcester & HampshireRepublican Primary +
+Peter J. Durant won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateBristol and NorfolkRepublican Primary +
No Candidates
+
2024State Senate1st Bristol and PlymouthRepublican Primary +
No Candidates
+
2024State Senate2nd Bristol and PlymouthRepublican Primary +
No Candidates
+
2024State SenateCape and IslandsRepublican Primary +
+Christopher Robert Lauzon won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate1st EssexRepublican Primary +
No Candidates
+
2024State Senate2nd EssexRepublican Primary +
+Damian Mitchell Anketell won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate3rd EssexRepublican Primary +
No Candidates
+
2024State Senate1st Essex and MiddlesexRepublican Primary +
+Bruce E. Tarr won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate2nd Essex and MiddlesexRepublican Primary +
No Candidates
+
2024State SenateHampdenRepublican Primary +
No Candidates
+
2024State SenateHampden and HampshireRepublican Primary +
No Candidates
+
2024State SenateHampshire, Franklin and WorcesterRepublican Primary +
No Candidates
+
2024State Senate1st MiddlesexRepublican Primary +
+Karla J. Miller won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate2nd MiddlesexRepublican Primary +
No Candidates
+
2024State Senate3rd MiddlesexRepublican Primary +
No Candidates
+
2024State Senate4th MiddlesexRepublican Primary +
No Candidates
+
2024State Senate5th MiddlesexRepublican Primary +
No Candidates
+
2024State SenateMiddlesex and NorfolkRepublican Primary +
No Candidates
+
2024State SenateMiddlesex and WorcesterRepublican Primary +
No Candidates
+
2024State SenateMiddlesex and SuffolkRepublican Primary +
No Candidates
+
2024State SenateNorfolk and PlymouthRepublican Primary +
No Candidates
+
2024State SenateNorfolk and SuffolkRepublican Primary +
No Candidates
+
2024State SenatePlymouth and BarnstableRepublican Primary +
+Mathew J. Muratore won + (50%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate1st SuffolkRepublican Primary +
No Candidates
+
2024State Senate2nd SuffolkRepublican Primary +
No Candidates
+
2024State Senate3rd SuffolkRepublican Primary +
+Jeannamarie Tamas won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State SenateSuffolk and MiddlesexRepublican Primary +
No Candidates
+
2024State SenateWorcester and MiddlesexRepublican Primary +
+Nicholas A. Pirro, III won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Senate1st WorcesterRepublican Primary +
No Candidates
+
2024State Senate2nd WorcesterRepublican Primary +
No Candidates
+
2024State Representative1st BarnstableRepublican Primary +
+Gerald Joseph O'Connell (Write-In) won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative2nd BarnstableRepublican Primary +
+Susanne H. Conley won + (97%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative3rd BarnstableRepublican Primary +
+David T. Vieira won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative4th BarnstableRepublican Primary +
No Candidates
+
2024State Representative5th BarnstableRepublican Primary +
+Steven G. Xiarhos won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State RepresentativeBarnstable, Dukes and NantucketRepublican Primary +
+Erich F. Horgan (Write-In) +ran against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative1st BerkshireRepublican Primary +
No Candidates
+
2024State Representative2nd BerkshireRepublican Primary +
No Candidates
+
2024State Representative3rd BerkshireRepublican Primary +
No Candidates
+
2024State Representative1st BristolRepublican Primary +
+Michael Chaisson won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative2nd BristolRepublican Primary +
No Candidates
+
2024State Representative3rd BristolRepublican Primary +
No Candidates
+
2024State Representative4th BristolRepublican Primary +
+Steven S. Howitt won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative5th BristolRepublican Primary +
+Justin Thurber won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative6th BristolRepublican Primary +
No Candidates
+
2024State Representative7th BristolRepublican Primary +
No Candidates
+
2024State Representative8th BristolRepublican Primary +
+Christopher Thrasher (Write-In) won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative9th BristolRepublican Primary +
No Candidates
+
2024State Representative10th BristolRepublican Primary +
+Joseph M. Pires won + (69%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative11th BristolRepublican Primary +
No Candidates
+
2024State Representative12th BristolRepublican Primary +
+Norman J. Orrall won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative13th BristolRepublican Primary +
No Candidates
+
2024State Representative14th BristolRepublican Primary +
+David Cannata, Jr won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative1st EssexRepublican Primary +
No Candidates
+
2024State Representative2nd EssexRepublican Primary +
+Mark T. Tashjian (Write-In) won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative3rd EssexRepublican Primary +
No Candidates
+
2024State Representative4th EssexRepublican Primary +
No Candidates
+
2024State Representative5th EssexRepublican Primary +
No Candidates
+
2024State Representative6th EssexRepublican Primary +
+Ty Vitale won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative7th EssexRepublican Primary +
No Candidates
+
2024State Representative8th EssexRepublican Primary +
No Candidates
+
2024State Representative9th EssexRepublican Primary +
+Donald H. Wong won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative10th EssexRepublican Primary +
No Candidates
+
2024State Representative11th EssexRepublican Primary +
No Candidates
+
2024State Representative12th EssexRepublican Primary +
No Candidates
+
2024State Representative13th EssexRepublican Primary +
No Candidates
+
2024State Representative14th EssexRepublican Primary +
No Candidates
+
2024State Representative15th EssexRepublican Primary +
No Candidates
+
2024State Representative16th EssexRepublican Primary +
No Candidates
+
2024State Representative17th EssexRepublican Primary +
No Candidates
+
2024State Representative18th EssexRepublican Primary +
No Candidates
+
2024State Representative1st FranklinRepublican Primary +
No Candidates
+
2024State Representative2nd FranklinRepublican Primary +
+Jeffrey L. Raymond won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative1st HampdenRepublican Primary +
+Todd M. Smola won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative2nd HampdenRepublican Primary +
No Candidates
+
2024State Representative3rd HampdenRepublican Primary +
+Nicholas A. Boldyga won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative4th HampdenRepublican Primary +
+Kelly W. Pease won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative5th HampdenRepublican Primary +
No Candidates
+
2024State Representative6th HampdenRepublican Primary +
No Candidates
+
2024State Representative7th HampdenRepublican Primary +
No Candidates
+
2024State Representative8th HampdenRepublican Primary +
No Candidates
+
2024State Representative9th HampdenRepublican Primary +
No Candidates
+
2024State Representative10th HampdenRepublican Primary +
No Candidates
+
2024State Representative11th HampdenRepublican Primary +
No Candidates
+
2024State Representative12th HampdenRepublican Primary +
No Candidates
+
2024State Representative1st HampshireRepublican Primary +
No Candidates
+
2024State Representative2nd HampshireRepublican Primary +
No Candidates
+
2024State Representative3rd HampshireRepublican Primary +
No Candidates
+
2024State Representative1st MiddlesexRepublican Primary +
+Lynne E. Archambault won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative2nd MiddlesexRepublican Primary +
No Candidates
+
2024State Representative3rd MiddlesexRepublican Primary +
No Candidates
+
2024State Representative4th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative5th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative6th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative7th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative8th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative9th MiddlesexRepublican Primary +
+Carly Marie Downs won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative10th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative11th MiddlesexRepublican Primary +
+Vladislav S. Yanovsky won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative12th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative13th MiddlesexRepublican Primary +
+Virginia A Gardner (Write-In) won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative14th MiddlesexRepublican Primary +
+Doreen Ann Deshler (Write-In) +ran against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative15th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative16th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative17th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative18th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative19th MiddlesexRepublican Primary +
+Paul Sarnowski won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative20th MiddlesexRepublican Primary +
+Bradley H. Jones, Jr won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative21st MiddlesexRepublican Primary +
No Candidates
+
2024State Representative22nd MiddlesexRepublican Primary +
+Marc T. Lombardo won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative23rd MiddlesexRepublican Primary +
No Candidates
+
2024State Representative24th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative25th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative26th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative27th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative28th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative29th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative30th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative31st MiddlesexRepublican Primary +
No Candidates
+
2024State Representative32nd MiddlesexRepublican Primary +
No Candidates
+
2024State Representative33rd MiddlesexRepublican Primary +
No Candidates
+
2024State Representative34th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative35th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative36th MiddlesexRepublican Primary +
No Candidates
+
2024State Representative37th MiddlesexRepublican Primary +
+Gary J. Grossi (Write-In) +ran against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative1st NorfolkRepublican Primary +
No Candidates
+
2024State Representative2nd NorfolkRepublican Primary +
+Sharon Marie Cintolo won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative3rd NorfolkRepublican Primary +
No Candidates
+
2024State Representative4th NorfolkRepublican Primary +
No Candidates
+
2024State Representative5th NorfolkRepublican Primary +
No Candidates
+
2024State Representative6th NorfolkRepublican Primary +
No Candidates
+
2024State Representative7th NorfolkRepublican Primary +
No Candidates
+
2024State Representative8th NorfolkRepublican Primary +
No Candidates
+
2024State Representative9th NorfolkRepublican Primary +
+Marcus S. Vaughn won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative10th NorfolkRepublican Primary +
+Charles F. Bailey, III won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative11th NorfolkRepublican Primary +
No Candidates
+
2024State Representative12th NorfolkRepublican Primary +
No Candidates
+
2024State Representative13th NorfolkRepublican Primary +
No Candidates
+
2024State Representative14th NorfolkRepublican Primary +
No Candidates
+
2024State Representative15th NorfolkRepublican Primary +
No Candidates
+
2024State Representative1st PlymouthRepublican Primary +
+Jesse G. Brown won + (52%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative2nd PlymouthRepublican Primary +
+John Robert Gaskey won + (59%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative3rd PlymouthRepublican Primary +
No Candidates
+
2024State Representative4th PlymouthRepublican Primary +
No Candidates
+
2024State Representative5th PlymouthRepublican Primary +
+David F. Decoste won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative6th PlymouthRepublican Primary +
+Kenneth Peter Sweezey won + (52%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative7th PlymouthRepublican Primary +
+Alyson M. Sullivan-Almeida won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative8th PlymouthRepublican Primary +
+Sandra M. Wright won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative9th PlymouthRepublican Primary +
+Lawrence Peter Novak won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative10th PlymouthRepublican Primary +
No Candidates
+
2024State Representative11th PlymouthRepublican Primary +
No Candidates
+
2024State Representative12th PlymouthRepublican Primary +
+Eric J. Meschino won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative1st SuffolkRepublican Primary +
No Candidates
+
2024State Representative2nd SuffolkRepublican Primary +
No Candidates
+
2024State Representative3rd SuffolkRepublican Primary +
No Candidates
+
2024State Representative4th SuffolkRepublican Primary +
No Candidates
+
2024State Representative5th SuffolkRepublican Primary +
No Candidates
+
2024State Representative6th SuffolkRepublican Primary +
No Candidates
+
2024State Representative7th SuffolkRepublican Primary +
No Candidates
+
2024State Representative8th SuffolkRepublican Primary +
No Candidates
+
2024State Representative9th SuffolkRepublican Primary +
+Roy A. Owens won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative10th SuffolkRepublican Primary +
No Candidates
+
2024State Representative11th SuffolkRepublican Primary +
No Candidates
+
2024State Representative12th SuffolkRepublican Primary +
No Candidates
+
2024State Representative13th SuffolkRepublican Primary +
No Candidates
+
2024State Representative14th SuffolkRepublican Primary +
No Candidates
+
2024State Representative15th SuffolkRepublican Primary +
No Candidates
+
2024State Representative16th SuffolkRepublican Primary +
No Candidates
+
2024State Representative17th SuffolkRepublican Primary +
No Candidates
+
2024State Representative18th SuffolkRepublican Primary +
No Candidates
+
2024State Representative19th SuffolkRepublican Primary +
No Candidates
+
2024State Representative1st WorcesterRepublican Primary +
+Kimberly N. Ferguson won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative2nd WorcesterRepublican Primary +
+Bruce K. Chester won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative3rd WorcesterRepublican Primary +
No Candidates
+
2024State Representative4th WorcesterRepublican Primary +
+Salvatore Perla won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative5th WorcesterRepublican Primary +
+Donald R. Berthiaume, Jr won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative6th WorcesterSpecial Republican Primary +
+John J. Marsi, Jr won + (53%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative6th WorcesterRepublican Primary +
+John J. Marsi, Jr won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative7th WorcesterRepublican Primary +
+Paul K. Frost won + (82%) against 1 opponent. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative8th WorcesterRepublican Primary +
+Michael J. Soter won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative9th WorcesterRepublican Primary +
+David Kent Muradian, Jr won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative10th WorcesterRepublican Primary +
No Candidates
+
2024State Representative11th WorcesterRepublican Primary +
+Hannah E. Kane won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative12th WorcesterRepublican Primary +
No Candidates
+
2024State Representative13th WorcesterRepublican Primary +
No Candidates
+
2024State Representative14th WorcesterRepublican Primary +
No Candidates
+
2024State Representative15th WorcesterRepublican Primary +
No Candidates
+
2024State Representative16th WorcesterRepublican Primary +
No Candidates
+
2024State Representative17th WorcesterRepublican Primary +
No Candidates
+
2024State Representative18th WorcesterRepublican Primary +
+Joseph D. Mckenna won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024State Representative19th WorcesterRepublican Primary +
No Candidates
+
2024Clerk of CourtsBarnstable CountyRepublican Primary +
No Candidates
+
2024Clerk of CourtsBerkshire CountyRepublican Primary +
No Candidates
+
2024Clerk of CourtsBristol CountyRepublican Primary +
No Candidates
+
2024Clerk of CourtsDukes CountyRepublican Primary +
No Candidates
+
2024Clerk of CourtsEssex CountyRepublican Primary +
No Candidates
+
2024Clerk of CourtsFranklin CountyRepublican Primary +
No Candidates
+
2024Clerk of CourtsHampden CountyRepublican Primary +
No Candidates
+
2024Clerk of CourtsHampshire CountyRepublican Primary +
No Candidates
+
2024Clerk of CourtsMiddlesex CountyRepublican Primary +
No Candidates
+
2024Clerk of CourtsNantucket CountyRepublican Primary +
No Candidates
+
2024Clerk of CourtsNorfolk CountyRepublican Primary +
No Candidates
+
2024Clerk of CourtsPlymouth CountyRepublican Primary +
No Candidates
+
2024Clerk of CourtsWorcester CountyRepublican Primary +
No Candidates
+
2024Clerk of Superior Court (Civil)Suffolk CountyRepublican Primary +
No Candidates
+
2024Clerk of Superior Court (Criminal)Suffolk CountyRepublican Primary +
No Candidates
+
2024Clerk of Supreme Judicial CourtSuffolk CountyRepublican Primary +
No Candidates
+
2024Register of DeedsBarnstableRepublican Primary +
+John F. Meade won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Register of DeedsBerkshire MiddleRepublican Primary +
No Candidates
+
2024Register of DeedsBerkshire NorthernRepublican Primary +
No Candidates
+
2024Register of DeedsBerkshire SouthernRepublican Primary +
No Candidates
+
2024Register of DeedsBristol NorthernRepublican Primary +
No Candidates
+
2024Register of DeedsBristol SouthernRepublican Primary +
No Candidates
+
2024Register of DeedsDukesRepublican Primary +
No Candidates
+
2024Register of DeedsEssex NorthernRepublican Primary +
No Candidates
+
2024Register of DeedsEssex SouthernRepublican Primary +
+Jonathan Edward Ring won + against no opponents. + Candidates » +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2024Register of DeedsFall RiverRepublican Primary +
No Candidates
+
2024Register of DeedsFranklinRepublican Primary +
No Candidates
+
2024Register of DeedsHampdenRepublican Primary +
No Candidates
+
2024Register of DeedsHampshireRepublican Primary +
No Candidates
+
2024Register of DeedsMiddlesex NorthernRepublican Primary +
No Candidates
+
2024Register of DeedsMiddlesex SouthernRepublican Primary +
No Candidates
+
2024Register of DeedsNantucketRepublican Primary +
No Candidates
+
2024Register of DeedsNorfolkRepublican Primary +
No Candidates
+
2024Register of DeedsPlymouthRepublican Primary +
No Candidates
+
2024Register of DeedsSuffolkRepublican Primary +
No Candidates
+
2024Register of DeedsWorcesterRepublican Primary +
No Candidates
+
2024Register of DeedsWorcester NorthernRepublican Primary +
No Candidates
+
2024Register of ProbateHampshire CountyRepublican Primary +
No Candidates
+
2024Register of ProbateSuffolk CountyRepublican Primary +
No Candidates
+
+
+
+ +
 
+ +
+ + + + + + + +
+ + + + + + + + + + + +
+
+
+ +
+ +
+ +
+
+
+ + + + + + + + + + + + + diff --git a/tests/fixtures/electionResults/2024_republican_primaries.json b/tests/fixtures/electionResults/2024_republican_primaries.json new file mode 100644 index 000000000..68c16dfe8 --- /dev/null +++ b/tests/fixtures/electionResults/2024_republican_primaries.json @@ -0,0 +1,5716 @@ +[ + { + "id": 171341, + "year": 2025, + "office": "State Representative", + "districts": "3rd Bristol", + "party": "Republican", + "special": true, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Lawrence-Joseph-Quintal" + ], + "result": { + "candidates": [ + { + "name": "Lawrence Joseph Quintal", + "votes": 270, + "writeIn": false + } + ], + "otherVotes": 4, + "blankVotes": 2, + "totalVotes": 276 + } + }, + { + "id": 170372, + "year": 2025, + "office": "State Representative", + "districts": "6th Essex", + "party": "Republican", + "special": true, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Medley-Long-III" + ], + "result": { + "candidates": [ + { + "name": "Medley Long, III", + "votes": 244, + "writeIn": false + } + ], + "otherVotes": 0, + "blankVotes": 36, + "totalVotes": 280 + } + }, + { + "id": 160659, + "year": 2024, + "office": "President", + "districts": "Statewide", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Donald-J-Trump", + "https://electionstats.state.ma.us/candidates/view/Nikki-Haley", + "https://electionstats.state.ma.us/candidates/view/Chris-Christie", + "https://electionstats.state.ma.us/candidates/view/Ron-Desantis", + "https://electionstats.state.ma.us/candidates/view/Vivek-Ramaswamy", + "https://electionstats.state.ma.us/candidates/view/Ryan-Binkley", + "https://electionstats.state.ma.us/candidates/view/Asa-Hutchinson" + ], + "result": { + "candidates": [ + { + "name": "Donald J. Trump", + "votes": 343189, + "writeIn": false + }, + { + "name": "Nikki Haley", + "votes": 211440, + "writeIn": false + }, + { + "name": "Chris Christie", + "votes": 5217, + "writeIn": false + }, + { + "name": "Ron Desantis", + "votes": 3981, + "writeIn": false + }, + { + "name": "Vivek Ramaswamy", + "votes": 1738, + "writeIn": false + }, + { + "name": "Ryan Binkley", + "votes": 619, + "writeIn": false + }, + { + "name": "Asa Hutchinson", + "votes": 527, + "writeIn": false + } + ], + "otherVotes": 1674, + "blankVotes": 2148, + "totalVotes": 576250, + "noPreferenceVotes": 5717 + } + }, + { + "id": 160680, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Berkshire, Hampden, Franklin & Hampshire", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Nicholas-A-Boldyga" + ], + "result": { + "candidates": [ + { + "name": "Nicholas A. Boldyga", + "votes": 7066, + "writeIn": false + } + ], + "otherVotes": 76, + "blankVotes": 4394, + "totalVotes": 11536 + } + }, + { + "id": 160866, + "year": 2024, + "office": "Party State Committee Man", + "districts": "First Plymouth & Norfolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/David-F-Decoste" + ], + "result": { + "candidates": [ + { + "name": "David F. Decoste", + "votes": 15546, + "writeIn": false + } + ], + "otherVotes": 145, + "blankVotes": 7488, + "totalVotes": 23179 + } + }, + { + "id": 160758, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Hampden, Hampshire & Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Sidney-M-Starks" + ], + "result": { + "candidates": [ + { + "name": "Sidney M. Starks", + "votes": 9583, + "writeIn": false + } + ], + "otherVotes": 131, + "blankVotes": 6430, + "totalVotes": 16144 + } + }, + { + "id": 160848, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Norfolk & Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Tom-Mountain", + "https://electionstats.state.ma.us/candidates/view/Vladislav-S-Yanovsky" + ], + "result": { + "candidates": [ + { + "name": "Tom Mountain", + "votes": 4203, + "writeIn": false + }, + { + "name": "Vladislav S. Yanovsky", + "votes": 2876, + "writeIn": false + } + ], + "otherVotes": 92, + "blankVotes": 3829, + "totalVotes": 11000 + } + }, + { + "id": 160842, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Norfolk, Plymouth & Bristol", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Sean-E-Powers", + "https://electionstats.state.ma.us/candidates/view/Steven-D-Fruzzetti" + ], + "result": { + "candidates": [ + { + "name": "Sean E. Powers", + "votes": 7062, + "writeIn": false + }, + { + "name": "Steven D. Fruzzetti", + "votes": 5838, + "writeIn": false + } + ], + "otherVotes": 47, + "blankVotes": 3221, + "totalVotes": 16168 + } + }, + { + "id": 160764, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Norfolk, Worcester & Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Eric-Calton", + "https://electionstats.state.ma.us/candidates/view/Andrew-E-Johanson" + ], + "result": { + "candidates": [ + { + "name": "Eric Calton", + "votes": 10216, + "writeIn": false + }, + { + "name": "Andrew E. Johanson", + "votes": 6096, + "writeIn": false + } + ], + "otherVotes": 98, + "blankVotes": 4900, + "totalVotes": 21310 + } + }, + { + "id": 160740, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Second Plymouth & Norfolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Geoff-Diehl", + "https://electionstats.state.ma.us/candidates/view/Lawrence-Peter-Novak" + ], + "result": { + "candidates": [ + { + "name": "Geoff Diehl", + "votes": 7751, + "writeIn": false + }, + { + "name": "Lawrence Peter Novak", + "votes": 2308, + "writeIn": false + } + ], + "otherVotes": 18, + "blankVotes": 1144, + "totalVotes": 11221 + } + }, + { + "id": 160776, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Third Bristol & Plymouth", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Mark-Edward-Townsend", + "https://electionstats.state.ma.us/candidates/view/Mark-R-Swan" + ], + "result": { + "candidates": [ + { + "name": "Mark Edward Townsend", + "votes": 8967, + "writeIn": false + }, + { + "name": "Mark R. Swan", + "votes": 6936, + "writeIn": false + } + ], + "otherVotes": 53, + "blankVotes": 4041, + "totalVotes": 19997 + } + }, + { + "id": 160794, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Worcester & Hampden", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Ryan-Chamberland", + "https://electionstats.state.ma.us/candidates/view/Michael-W-Young" + ], + "result": { + "candidates": [ + { + "name": "Ryan Chamberland", + "votes": 10745, + "writeIn": false + }, + { + "name": "Michael W. Young", + "votes": 7346, + "writeIn": false + } + ], + "otherVotes": 47, + "blankVotes": 3411, + "totalVotes": 21549 + } + }, + { + "id": 160746, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Worcester & Hampshire", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-Paul-Fountain", + "https://electionstats.state.ma.us/candidates/view/Jesse-Michael-Barnaby" + ], + "result": { + "candidates": [ + { + "name": "Michael Paul Fountain", + "votes": 10099, + "writeIn": false + }, + { + "name": "Jesse Michael Barnaby", + "votes": 5218, + "writeIn": false + } + ], + "otherVotes": 93, + "blankVotes": 5301, + "totalVotes": 20711 + } + }, + { + "id": 160728, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Bristol and Norfolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/David-B-Cannata" + ], + "result": { + "candidates": [ + { + "name": "David B. Cannata", + "votes": 10796, + "writeIn": false + } + ], + "otherVotes": 329, + "blankVotes": 6451, + "totalVotes": 17576 + } + }, + { + "id": 160878, + "year": 2024, + "office": "Party State Committee Man", + "districts": "1st Bristol and Plymouth", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Gabriel-Boomer-Amaral", + "https://electionstats.state.ma.us/candidates/view/Angel-Luis-Pantoja-Jr" + ], + "result": { + "candidates": [ + { + "name": "Gabriel Boomer Amaral", + "votes": 7275, + "writeIn": false + }, + { + "name": "Angel Luis Pantoja, Jr", + "votes": 2742, + "writeIn": false + } + ], + "otherVotes": 53, + "blankVotes": 3596, + "totalVotes": 13666 + } + }, + { + "id": 160674, + "year": 2024, + "office": "Party State Committee Man", + "districts": "2nd Bristol and Plymouth", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Robert-S-McConnell" + ], + "result": { + "candidates": [ + { + "name": "Robert S. McConnell", + "votes": 7207, + "writeIn": false + } + ], + "otherVotes": 106, + "blankVotes": 4152, + "totalVotes": 11465 + } + }, + { + "id": 160704, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Cape and Islands", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/William-L-Crocker-Jr", + "https://electionstats.state.ma.us/candidates/view/Michael-Arnold" + ], + "result": { + "candidates": [ + { + "name": "William L. Crocker, Jr.", + "votes": 10960, + "writeIn": false + }, + { + "name": "Michael Arnold", + "votes": 7450, + "writeIn": false + } + ], + "otherVotes": 122, + "blankVotes": 3674, + "totalVotes": 22206 + } + }, + { + "id": 160884, + "year": 2024, + "office": "Party State Committee Man", + "districts": "1st Essex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Paul-C-Downing", + "https://electionstats.state.ma.us/candidates/view/Charles-Hayward-Stearns" + ], + "result": { + "candidates": [ + { + "name": "Paul C. Downing", + "votes": 4032, + "writeIn": false + }, + { + "name": "Charles Hayward Stearns", + "votes": 1152, + "writeIn": false + } + ], + "otherVotes": 20, + "blankVotes": 1407, + "totalVotes": 6611 + } + }, + { + "id": 160788, + "year": 2024, + "office": "Party State Committee Man", + "districts": "2nd Essex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-F-McCarthy-Jr", + "https://electionstats.state.ma.us/candidates/view/Bob-May" + ], + "result": { + "candidates": [ + { + "name": "John F. McCarthy, Jr.", + "votes": 7776, + "writeIn": false + }, + { + "name": "Bob May", + "votes": 6126, + "writeIn": false + } + ], + "otherVotes": 47, + "blankVotes": 3107, + "totalVotes": 17056 + } + }, + { + "id": 160890, + "year": 2024, + "office": "Party State Committee Man", + "districts": "3rd Essex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Donald-H-Wong" + ], + "result": { + "candidates": [ + { + "name": "Donald H. Wong", + "votes": 8967, + "writeIn": false + } + ], + "otherVotes": 91, + "blankVotes": 4247, + "totalVotes": 13305 + } + }, + { + "id": 160836, + "year": 2024, + "office": "Party State Committee Man", + "districts": "1st Essex and Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-J-Scarlata", + "https://electionstats.state.ma.us/candidates/view/Clayton-R-Sova", + "https://electionstats.state.ma.us/candidates/view/Jeffrey-R-Yull" + ], + "result": { + "candidates": [ + { + "name": "Michael J. Scarlata", + "votes": 6409, + "writeIn": false + }, + { + "name": "Clayton R. Sova", + "votes": 6383, + "writeIn": false + }, + { + "name": "Jeffrey R. Yull", + "votes": 5747, + "writeIn": false + } + ], + "otherVotes": 137, + "blankVotes": 6520, + "totalVotes": 25196 + } + }, + { + "id": 160692, + "year": 2024, + "office": "Party State Committee Man", + "districts": "2nd Essex and Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Shaun-P-Toohey", + "https://electionstats.state.ma.us/candidates/view/Joseph-G-Finn" + ], + "result": { + "candidates": [ + { + "name": "Shaun P. Toohey", + "votes": 9237, + "writeIn": false + }, + { + "name": "Joseph G. Finn", + "votes": 8838, + "writeIn": false + } + ], + "otherVotes": 78, + "blankVotes": 4003, + "totalVotes": 22156 + } + }, + { + "id": 160860, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Hampden", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Thomas-S-Balcom-Jr" + ], + "result": { + "candidates": [ + { + "name": "Thomas S. Balcom, Jr", + "votes": 2278, + "writeIn": false + } + ], + "otherVotes": 68, + "blankVotes": 1782, + "totalVotes": 4128 + } + }, + { + "id": 160686, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Hampden and Hampshire", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Nathan-A-Bech", + "https://electionstats.state.ma.us/candidates/view/Richard-A-Berrena" + ], + "result": { + "candidates": [ + { + "name": "Nathan A. Bech", + "votes": 7696, + "writeIn": false + }, + { + "name": "Richard A. Berrena", + "votes": 2514, + "writeIn": false + } + ], + "otherVotes": 95, + "blankVotes": 2698, + "totalVotes": 13003 + } + }, + { + "id": 160698, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Hampshire, Franklin and Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Christopher-J-Ryan", + "https://electionstats.state.ma.us/candidates/view/Jay-Scott-Fleitman" + ], + "result": { + "candidates": [ + { + "name": "Christopher J. Ryan", + "votes": 5499, + "writeIn": false + }, + { + "name": "Jay Scott Fleitman", + "votes": 2777, + "writeIn": false + } + ], + "otherVotes": 71, + "blankVotes": 2278, + "totalVotes": 10625 + } + }, + { + "id": 160872, + "year": 2024, + "office": "Party State Committee Man", + "districts": "1st Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Brian-K-Genest" + ], + "result": { + "candidates": [ + { + "name": "Brian K. Genest", + "votes": 8044, + "writeIn": false + } + ], + "otherVotes": 95, + "blankVotes": 4118, + "totalVotes": 12257 + } + }, + { + "id": 160854, + "year": 2024, + "office": "Party State Committee Man", + "districts": "2nd Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-Joseph-Sousa", + "https://electionstats.state.ma.us/candidates/view/Mark-Alan-Steffen" + ], + "result": { + "candidates": [ + { + "name": "John Joseph Sousa", + "votes": 4184, + "writeIn": false + }, + { + "name": "Mark Alan Steffen", + "votes": 1132, + "writeIn": false + } + ], + "otherVotes": 113, + "blankVotes": 2783, + "totalVotes": 8212 + } + }, + { + "id": 160752, + "year": 2024, + "office": "Party State Committee Man", + "districts": "3rd Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/James-E-Dixon", + "https://electionstats.state.ma.us/candidates/view/Justin-David-Cusano" + ], + "result": { + "candidates": [ + { + "name": "James E. Dixon", + "votes": 8777, + "writeIn": false + }, + { + "name": "Justin David Cusano", + "votes": 252, + "writeIn": true + } + ], + "otherVotes": 211, + "blankVotes": 6054, + "totalVotes": 15294 + } + }, + { + "id": 160710, + "year": 2024, + "office": "Party State Committee Man", + "districts": "4th Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Anthony-M-Ventresca", + "https://electionstats.state.ma.us/candidates/view/Jeffrey-M-Semon" + ], + "result": { + "candidates": [ + { + "name": "Anthony M. Ventresca", + "votes": 8473, + "writeIn": false + }, + { + "name": "Jeffrey M. Semon", + "votes": 5009, + "writeIn": false + } + ], + "otherVotes": 70, + "blankVotes": 4198, + "totalVotes": 17750 + } + }, + { + "id": 160896, + "year": 2024, + "office": "Party State Committee Man", + "districts": "5th Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Robert-E-Aufiero" + ], + "result": { + "candidates": [ + { + "name": "Robert E. Aufiero", + "votes": 8725, + "writeIn": false + } + ], + "otherVotes": 113, + "blankVotes": 7097, + "totalVotes": 15935 + } + }, + { + "id": 160722, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Middlesex and Norfolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Nicholas-Blaize-Miceli" + ], + "result": { + "candidates": [ + { + "name": "Nicholas Blaize Miceli", + "votes": 8087, + "writeIn": false + } + ], + "otherVotes": 272, + "blankVotes": 5393, + "totalVotes": 13752 + } + }, + { + "id": 160668, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Middlesex and Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Dave-H-Lunger" + ], + "result": { + "candidates": [ + { + "name": "Dave H. Lunger", + "votes": 9972, + "writeIn": false + } + ], + "otherVotes": 125, + "blankVotes": 7197, + "totalVotes": 17294 + } + }, + { + "id": 160803, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Middlesex and Suffolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Todd-B-Taylor", + "https://electionstats.state.ma.us/candidates/view/John-David-Olds" + ], + "result": { + "candidates": [ + { + "name": "Todd B. Taylor", + "votes": 1728, + "writeIn": false + }, + { + "name": "John David Olds", + "votes": 1561, + "writeIn": false + } + ], + "otherVotes": 23, + "blankVotes": 1302, + "totalVotes": 4614 + } + }, + { + "id": 160662, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Norfolk and Plymouth", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Alexander-S-Hagerty", + "https://electionstats.state.ma.us/candidates/view/Gary-Malcolm-Innes" + ], + "result": { + "candidates": [ + { + "name": "Alexander S. Hagerty", + "votes": 6702, + "writeIn": false + }, + { + "name": "Gary Malcolm Innes", + "votes": 5528, + "writeIn": false + } + ], + "otherVotes": 95, + "blankVotes": 2885, + "totalVotes": 15210 + } + }, + { + "id": 160806, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Norfolk and Suffolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-T-Hasenjaeger", + "https://electionstats.state.ma.us/candidates/view/Thomas-Lee-Ricketts" + ], + "result": { + "candidates": [ + { + "name": "John T. Hasenjaeger", + "votes": 7570, + "writeIn": false + }, + { + "name": "Thomas Lee Ricketts", + "votes": 4100, + "writeIn": false + } + ], + "otherVotes": 91, + "blankVotes": 4348, + "totalVotes": 16109 + } + }, + { + "id": 160830, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Plymouth and Barnstable", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/James-R-McMahon-III" + ], + "result": { + "candidates": [ + { + "name": "James R. McMahon, III", + "votes": 19167, + "writeIn": false + } + ], + "otherVotes": 119, + "blankVotes": 8028, + "totalVotes": 27314 + } + }, + { + "id": 160800, + "year": 2024, + "office": "Party State Committee Man", + "districts": "1st Suffolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Timothy-J-Smyth-Jr", + "https://electionstats.state.ma.us/candidates/view/Daniel-K-Kelly" + ], + "result": { + "candidates": [ + { + "name": "Timothy J. Smyth, Jr", + "votes": 2124, + "writeIn": false + }, + { + "name": "Daniel K. Kelly", + "votes": 2107, + "writeIn": false + } + ], + "otherVotes": 84, + "blankVotes": 1755, + "totalVotes": 6070 + } + }, + { + "id": 160809, + "year": 2024, + "office": "Party State Committee Man", + "districts": "2nd Suffolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Timothy-McCarthy", + "https://electionstats.state.ma.us/candidates/view/Brendan-Halloran-OConnell" + ], + "result": { + "candidates": [ + { + "name": "Timothy McCarthy", + "votes": 39, + "writeIn": true + }, + { + "name": "Brendan Halloran O'Connell", + "votes": 3, + "writeIn": true + } + ], + "otherVotes": 182, + "blankVotes": 1330, + "totalVotes": 1554 + } + }, + { + "id": 160812, + "year": 2024, + "office": "Party State Committee Man", + "districts": "3rd Suffolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Paul-J-Ronukaitus", + "https://electionstats.state.ma.us/candidates/view/Talen-John-Carvalho" + ], + "result": { + "candidates": [ + { + "name": "Paul J. Ronukaitus", + "votes": 3272, + "writeIn": false + }, + { + "name": "Talen John Carvalho", + "votes": 2227, + "writeIn": false + } + ], + "otherVotes": 77, + "blankVotes": 2326, + "totalVotes": 7902 + } + }, + { + "id": 160770, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Suffolk and Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Daniel-Hickey", + "https://electionstats.state.ma.us/candidates/view/John-T-Umina" + ], + "result": { + "candidates": [ + { + "name": "Daniel Hickey", + "votes": 2188, + "writeIn": false + }, + { + "name": "John T. Umina", + "votes": 2142, + "writeIn": false + } + ], + "otherVotes": 79, + "blankVotes": 2034, + "totalVotes": 6443 + } + }, + { + "id": 160716, + "year": 2024, + "office": "Party State Committee Man", + "districts": "Worcester and Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Mark-C-Bodanza", + "https://electionstats.state.ma.us/candidates/view/Dennis-J-Galvin" + ], + "result": { + "candidates": [ + { + "name": "Mark C. Bodanza", + "votes": 8021, + "writeIn": false + }, + { + "name": "Dennis J. Galvin", + "votes": 7441, + "writeIn": false + } + ], + "otherVotes": 57, + "blankVotes": 2427, + "totalVotes": 17946 + } + }, + { + "id": 160782, + "year": 2024, + "office": "Party State Committee Man", + "districts": "1st Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-A-Vulcano" + ], + "result": { + "candidates": [ + { + "name": "Michael A. Vulcano", + "votes": 6040, + "writeIn": false + } + ], + "otherVotes": 114, + "blankVotes": 3454, + "totalVotes": 9608 + } + }, + { + "id": 160734, + "year": 2024, + "office": "Party State Committee Man", + "districts": "2nd Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Paul-K-Frost", + "https://electionstats.state.ma.us/candidates/view/James-Davidson" + ], + "result": { + "candidates": [ + { + "name": "Paul K. Frost", + "votes": 6716, + "writeIn": false + }, + { + "name": "James Davidson", + "votes": 4204, + "writeIn": false + } + ], + "otherVotes": 53, + "blankVotes": 2106, + "totalVotes": 13079 + } + }, + { + "id": 160683, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Berkshire, Hampden, Franklin & Hampshire", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jessica-L-Boldyga" + ], + "result": { + "candidates": [ + { + "name": "Jessica L. Boldyga", + "votes": 6822, + "writeIn": false + } + ], + "otherVotes": 158, + "blankVotes": 4556, + "totalVotes": 11536 + } + }, + { + "id": 160869, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "First Plymouth & Norfolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Janet-R-Fogarty", + "https://electionstats.state.ma.us/candidates/view/Kristen-G-Arute", + "https://electionstats.state.ma.us/candidates/view/Lynne-Santangelo" + ], + "result": { + "candidates": [ + { + "name": "Janet R. Fogarty", + "votes": 9817, + "writeIn": false + }, + { + "name": "Kristen G. Arute", + "votes": 4340, + "writeIn": false + }, + { + "name": "Lynne Santangelo", + "votes": 4232, + "writeIn": false + } + ], + "otherVotes": 52, + "blankVotes": 4738, + "totalVotes": 23179 + } + }, + { + "id": 160761, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Hampden, Hampshire & Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Virginia-E-Neill" + ], + "result": { + "candidates": [ + { + "name": "Virginia E. Neill", + "votes": 9319, + "writeIn": false + } + ], + "otherVotes": 113, + "blankVotes": 6712, + "totalVotes": 16144 + } + }, + { + "id": 160851, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Norfolk & Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Susan-Huffman", + "https://electionstats.state.ma.us/candidates/view/Rosann-Palermo-Fleischauer" + ], + "result": { + "candidates": [ + { + "name": "Susan Huffman", + "votes": 3848, + "writeIn": false + }, + { + "name": "Rosann Palermo Fleischauer", + "votes": 3037, + "writeIn": false + } + ], + "otherVotes": 85, + "blankVotes": 4030, + "totalVotes": 11000 + } + }, + { + "id": 160845, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Norfolk, Plymouth & Bristol", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Sandra-M-Wright", + "https://electionstats.state.ma.us/candidates/view/Patricia-A-Locke" + ], + "result": { + "candidates": [ + { + "name": "Sandra M. Wright", + "votes": 6648, + "writeIn": false + }, + { + "name": "Patricia A. Locke", + "votes": 5598, + "writeIn": false + } + ], + "otherVotes": 54, + "blankVotes": 3868, + "totalVotes": 16168 + } + }, + { + "id": 160767, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Norfolk, Worcester & Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Amanda-Joan-Peterson", + "https://electionstats.state.ma.us/candidates/view/Maureen-Maloney" + ], + "result": { + "candidates": [ + { + "name": "Amanda Joan Peterson", + "votes": 8570, + "writeIn": false + }, + { + "name": "Maureen Maloney", + "votes": 7382, + "writeIn": false + } + ], + "otherVotes": 106, + "blankVotes": 5252, + "totalVotes": 21310 + } + }, + { + "id": 160743, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Second Plymouth & Norfolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Kathyjo-Boss", + "https://electionstats.state.ma.us/candidates/view/Jeanie-Falcone" + ], + "result": { + "candidates": [ + { + "name": "Kathyjo Boss", + "votes": 6223, + "writeIn": false + }, + { + "name": "Jeanie Falcone", + "votes": 3186, + "writeIn": false + } + ], + "otherVotes": 24, + "blankVotes": 1788, + "totalVotes": 11221 + } + }, + { + "id": 160779, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Third Bristol & Plymouth", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Shaunna-L-OConnell", + "https://electionstats.state.ma.us/candidates/view/Maria-S-Collins" + ], + "result": { + "candidates": [ + { + "name": "Shaunna L. O'Connell", + "votes": 9885, + "writeIn": false + }, + { + "name": "Maria S. Collins", + "votes": 6904, + "writeIn": false + } + ], + "otherVotes": 41, + "blankVotes": 3167, + "totalVotes": 19997 + } + }, + { + "id": 160797, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Worcester & Hampden", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Elizabeth-L-S-Groot", + "https://electionstats.state.ma.us/candidates/view/Janet-E-Garon" + ], + "result": { + "candidates": [ + { + "name": "Elizabeth L. S. Groot", + "votes": 9234, + "writeIn": false + }, + { + "name": "Janet E. Garon", + "votes": 8638, + "writeIn": false + } + ], + "otherVotes": 50, + "blankVotes": 3627, + "totalVotes": 21549 + } + }, + { + "id": 160749, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Worcester & Hampshire", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Stephanie-R-Mulroy", + "https://electionstats.state.ma.us/candidates/view/Rebecca-E-Connors" + ], + "result": { + "candidates": [ + { + "name": "Stephanie R. Mulroy", + "votes": 9878, + "writeIn": false + }, + { + "name": "Rebecca E. Connors", + "votes": 6263, + "writeIn": false + } + ], + "otherVotes": 69, + "blankVotes": 4501, + "totalVotes": 20711 + } + }, + { + "id": 160731, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Bristol and Norfolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Julie-A-Hall" + ], + "result": { + "candidates": [ + { + "name": "Julie A. Hall", + "votes": 11290, + "writeIn": false + } + ], + "otherVotes": 69, + "blankVotes": 6217, + "totalVotes": 17576 + } + }, + { + "id": 160881, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "1st Bristol and Plymouth", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Kayla-Rose-Churchill" + ], + "result": { + "candidates": [ + { + "name": "Kayla Rose Churchill", + "votes": 8186, + "writeIn": false + } + ], + "otherVotes": 109, + "blankVotes": 5371, + "totalVotes": 13666 + } + }, + { + "id": 160677, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "2nd Bristol and Plymouth", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jo-Anne-Mello-Hodgson" + ], + "result": { + "candidates": [ + { + "name": "Jo-Anne Mello Hodgson", + "votes": 7409, + "writeIn": false + } + ], + "otherVotes": 94, + "blankVotes": 3962, + "totalVotes": 11465 + } + }, + { + "id": 160707, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Cape and Islands", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Judith-Anessa-Crocker", + "https://electionstats.state.ma.us/candidates/view/Daralyn-Andrea-Heywood" + ], + "result": { + "candidates": [ + { + "name": "Judith Anessa Crocker", + "votes": 10317, + "writeIn": false + }, + { + "name": "Daralyn Andrea Heywood", + "votes": 7938, + "writeIn": false + } + ], + "otherVotes": 42, + "blankVotes": 3909, + "totalVotes": 22206 + } + }, + { + "id": 160887, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "1st Essex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Maura-L-Ryan-Ciardiello", + "https://electionstats.state.ma.us/candidates/view/Cecilia-G-Buckles" + ], + "result": { + "candidates": [ + { + "name": "Maura L. Ryan-Ciardiello", + "votes": 3048, + "writeIn": false + }, + { + "name": "Cecilia G. Buckles", + "votes": 689, + "writeIn": false + } + ], + "otherVotes": 8, + "blankVotes": 2866, + "totalVotes": 6611 + } + }, + { + "id": 160791, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "2nd Essex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jaclyn-Corriveau", + "https://electionstats.state.ma.us/candidates/view/Sandy-M-Franceschini" + ], + "result": { + "candidates": [ + { + "name": "Jaclyn Corriveau", + "votes": 6734, + "writeIn": false + }, + { + "name": "Sandy M. Franceschini", + "votes": 6541, + "writeIn": false + } + ], + "otherVotes": 52, + "blankVotes": 3729, + "totalVotes": 17056 + } + }, + { + "id": 160893, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "3rd Essex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Amy-Carnevale", + "https://electionstats.state.ma.us/candidates/view/Maria-Pia-Perez" + ], + "result": { + "candidates": [ + { + "name": "Amy Carnevale", + "votes": 6638, + "writeIn": false + }, + { + "name": "Maria Pia Perez", + "votes": 3878, + "writeIn": false + } + ], + "otherVotes": 83, + "blankVotes": 2706, + "totalVotes": 13305 + } + }, + { + "id": 160839, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "1st Essex and Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Lisa-Marie-C-Cashman", + "https://electionstats.state.ma.us/candidates/view/Ashley-Sullivan", + "https://electionstats.state.ma.us/candidates/view/Cynthia-C-Bjorlie", + "https://electionstats.state.ma.us/candidates/view/Nicole-Coles" + ], + "result": { + "candidates": [ + { + "name": "Lisa-Marie C. Cashman", + "votes": 9127, + "writeIn": false + }, + { + "name": "Ashley Sullivan", + "votes": 5344, + "writeIn": false + }, + { + "name": "Cynthia C. Bjorlie", + "votes": 2629, + "writeIn": false + }, + { + "name": "Nicole Coles", + "votes": 1332, + "writeIn": false + } + ], + "otherVotes": 84, + "blankVotes": 6777, + "totalVotes": 25293 + } + }, + { + "id": 160695, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "2nd Essex and Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jeri-Ann-Levasseur", + "https://electionstats.state.ma.us/candidates/view/Tatum-Ryan-Toohey" + ], + "result": { + "candidates": [ + { + "name": "Jeri Ann Levasseur", + "votes": 9123, + "writeIn": false + }, + { + "name": "Tatum Ryan-Toohey", + "votes": 8106, + "writeIn": false + } + ], + "otherVotes": 73, + "blankVotes": 4854, + "totalVotes": 22156 + } + }, + { + "id": 160863, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Hampden", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 160689, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Hampden and Hampshire", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Linda-L-Vacon" + ], + "result": { + "candidates": [ + { + "name": "Linda L. Vacon", + "votes": 8113, + "writeIn": false + } + ], + "otherVotes": 175, + "blankVotes": 4715, + "totalVotes": 13003 + } + }, + { + "id": 160701, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Hampshire, Franklin and Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Sue-OSullivan", + "https://electionstats.state.ma.us/candidates/view/Mary-L-Stuart" + ], + "result": { + "candidates": [ + { + "name": "Sue O'Sullivan", + "votes": 5098, + "writeIn": false + }, + { + "name": "Mary L. Stuart", + "votes": 2906, + "writeIn": false + } + ], + "otherVotes": 53, + "blankVotes": 2568, + "totalVotes": 10625 + } + }, + { + "id": 160875, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "1st Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Noreen-E-Crowley" + ], + "result": { + "candidates": [ + { + "name": "Noreen E. Crowley", + "votes": 8038, + "writeIn": false + } + ], + "otherVotes": 89, + "blankVotes": 4130, + "totalVotes": 12257 + } + }, + { + "id": 160857, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "2nd Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Patricia-Brady-Doherty", + "https://electionstats.state.ma.us/candidates/view/Christine-H-Doherty" + ], + "result": { + "candidates": [ + { + "name": "Patricia Brady Doherty", + "votes": 3572, + "writeIn": false + }, + { + "name": "Christine H. Doherty", + "votes": 1910, + "writeIn": false + } + ], + "otherVotes": 106, + "blankVotes": 2624, + "totalVotes": 8212 + } + }, + { + "id": 160755, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "3rd Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Doreen-A-Deshler", + "https://electionstats.state.ma.us/candidates/view/Ruth-A-Cusano" + ], + "result": { + "candidates": [ + { + "name": "Doreen A. Deshler", + "votes": 5215, + "writeIn": false + }, + { + "name": "Ruth A. Cusano", + "votes": 5161, + "writeIn": false + } + ], + "otherVotes": 81, + "blankVotes": 4837, + "totalVotes": 15294 + } + }, + { + "id": 160713, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "4th Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Helen-Anne-Hatch", + "https://electionstats.state.ma.us/candidates/view/Stirling-D-Smith" + ], + "result": { + "candidates": [ + { + "name": "Helen Anne Hatch", + "votes": 8657, + "writeIn": false + }, + { + "name": "Stirling D. Smith", + "votes": 4480, + "writeIn": false + } + ], + "otherVotes": 81, + "blankVotes": 4532, + "totalVotes": 17750 + } + }, + { + "id": 160899, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "5th Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Monica-C-Medeiros-Solano", + "https://electionstats.state.ma.us/candidates/view/Marcy-L-McCauley" + ], + "result": { + "candidates": [ + { + "name": "Monica C. Medeiros Solano", + "votes": 6626, + "writeIn": false + }, + { + "name": "Marcy L. McCauley", + "votes": 5189, + "writeIn": false + } + ], + "otherVotes": 65, + "blankVotes": 4055, + "totalVotes": 15935 + } + }, + { + "id": 160725, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Middlesex and Norfolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Leanne-J-Yarosz-Harris" + ], + "result": { + "candidates": [ + { + "name": "Leanne J. Yarosz-Harris", + "votes": 7775, + "writeIn": false + } + ], + "otherVotes": 118, + "blankVotes": 5859, + "totalVotes": 13752 + } + }, + { + "id": 160671, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Middlesex and Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Caroline-Stewart-Cunningham", + "https://electionstats.state.ma.us/candidates/view/Dorothy-A-Bisson" + ], + "result": { + "candidates": [ + { + "name": "Caroline Stewart Cunningham", + "votes": 9008, + "writeIn": false + }, + { + "name": "Dorothy A. Bisson", + "votes": 941, + "writeIn": true + } + ], + "otherVotes": 307, + "blankVotes": 7038, + "totalVotes": 17294 + } + }, + { + "id": 160818, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Middlesex and Suffolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Regina-M-Taylor" + ], + "result": { + "candidates": [ + { + "name": "Regina M. Taylor", + "votes": 2564, + "writeIn": false + } + ], + "otherVotes": 63, + "blankVotes": 1987, + "totalVotes": 4614 + } + }, + { + "id": 160665, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Norfolk and Plymouth", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Beth-M-Veneto", + "https://electionstats.state.ma.us/candidates/view/Edith-Hughes" + ], + "result": { + "candidates": [ + { + "name": "Beth M. Veneto", + "votes": 7050, + "writeIn": false + }, + { + "name": "Edith Hughes", + "votes": 4960, + "writeIn": false + } + ], + "otherVotes": 84, + "blankVotes": 3116, + "totalVotes": 15210 + } + }, + { + "id": 160821, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Norfolk and Suffolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Lynne-Roberts", + "https://electionstats.state.ma.us/candidates/view/Kristina-Kaye-Karpovich" + ], + "result": { + "candidates": [ + { + "name": "Lynne Roberts", + "votes": 8087, + "writeIn": false + }, + { + "name": "Kristina Kaye Karpovich", + "votes": 3596, + "writeIn": false + } + ], + "otherVotes": 78, + "blankVotes": 4348, + "totalVotes": 16109 + } + }, + { + "id": 160833, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Plymouth and Barnstable", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jennifer-A-Cunningham", + "https://electionstats.state.ma.us/candidates/view/Alice-Zinkevich" + ], + "result": { + "candidates": [ + { + "name": "Jennifer A. Cunningham", + "votes": 12485, + "writeIn": false + }, + { + "name": "Alice Zinkevich", + "votes": 9107, + "writeIn": false + } + ], + "otherVotes": 171, + "blankVotes": 5551, + "totalVotes": 27314 + } + }, + { + "id": 160815, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "1st Suffolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Elizabeth-H-Hinds-Ferrick", + "https://electionstats.state.ma.us/candidates/view/Lori-Kauffman" + ], + "result": { + "candidates": [ + { + "name": "Elizabeth H. Hinds-Ferrick", + "votes": 2352, + "writeIn": false + }, + { + "name": "Lori Kauffman", + "votes": 1476, + "writeIn": false + } + ], + "otherVotes": 127, + "blankVotes": 2115, + "totalVotes": 6070 + } + }, + { + "id": 160824, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "2nd Suffolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Brittney-Rocchina-Ciccone" + ], + "result": { + "candidates": [ + { + "name": "Brittney Rocchina Ciccone", + "votes": 34, + "writeIn": true + } + ], + "otherVotes": 145, + "blankVotes": 1375, + "totalVotes": 1554 + } + }, + { + "id": 160827, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "3rd Suffolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Vera-E-Carducci", + "https://electionstats.state.ma.us/candidates/view/Jeannamarie-Tamas", + "https://electionstats.state.ma.us/candidates/view/Rachel-Nicole-Miselman" + ], + "result": { + "candidates": [ + { + "name": "Vera E. Carducci", + "votes": 2532, + "writeIn": false + }, + { + "name": "Jeannamarie Tamas", + "votes": 1581, + "writeIn": false + }, + { + "name": "Rachel Nicole Miselman", + "votes": 1124, + "writeIn": false + } + ], + "otherVotes": 78, + "blankVotes": 2587, + "totalVotes": 7902 + } + }, + { + "id": 160773, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Suffolk and Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Catherine-A-Umina", + "https://electionstats.state.ma.us/candidates/view/Eva-M-Webster" + ], + "result": { + "candidates": [ + { + "name": "Catherine A. Umina", + "votes": 2389, + "writeIn": false + }, + { + "name": "Eva M. Webster", + "votes": 1926, + "writeIn": false + } + ], + "otherVotes": 64, + "blankVotes": 2064, + "totalVotes": 6443 + } + }, + { + "id": 160719, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "Worcester and Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Kathleen-Lynch", + "https://electionstats.state.ma.us/candidates/view/Beth-Joyce-Lindstrom" + ], + "result": { + "candidates": [ + { + "name": "Kathleen Lynch", + "votes": 8900, + "writeIn": false + }, + { + "name": "Beth Joyce Lindstrom", + "votes": 6046, + "writeIn": false + } + ], + "otherVotes": 46, + "blankVotes": 2954, + "totalVotes": 17946 + } + }, + { + "id": 160785, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "1st Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Joanne-E-Powell", + "https://electionstats.state.ma.us/candidates/view/Lisa-K-Mair" + ], + "result": { + "candidates": [ + { + "name": "Joanne E. Powell", + "votes": 3722, + "writeIn": false + }, + { + "name": "Lisa K. Mair", + "votes": 3684, + "writeIn": false + } + ], + "otherVotes": 47, + "blankVotes": 2147, + "totalVotes": 9600 + } + }, + { + "id": 160737, + "year": 2024, + "office": "Party State Committee Woman", + "districts": "2nd Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Mindy-J-McKenzie", + "https://electionstats.state.ma.us/candidates/view/Stacie-Norton-Bennett" + ], + "result": { + "candidates": [ + { + "name": "Mindy J. McKenzie", + "votes": 6843, + "writeIn": false + }, + { + "name": "Stacie Norton Bennett", + "votes": 3883, + "writeIn": false + } + ], + "otherVotes": 26, + "blankVotes": 2327, + "totalVotes": 13079 + } + }, + { + "id": 163522, + "year": 2024, + "office": "U.S. Senate", + "districts": "Statewide", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-Deaton", + "https://electionstats.state.ma.us/candidates/view/Robert-J-Antonellis", + "https://electionstats.state.ma.us/candidates/view/Ian-Cain" + ], + "result": { + "candidates": [ + { + "name": "John Deaton", + "votes": 136773, + "writeIn": false + }, + { + "name": "Robert J. Antonellis", + "votes": 54940, + "writeIn": false + }, + { + "name": "Ian Cain", + "votes": 19374, + "writeIn": false + } + ], + "otherVotes": 924, + "blankVotes": 5952, + "totalVotes": 217963 + } + }, + { + "id": 163582, + "year": 2024, + "office": "U.S. House", + "districts": "1st Congressional", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163642, + "year": 2024, + "office": "U.S. House", + "districts": "2nd Congressional", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163537, + "year": 2024, + "office": "U.S. House", + "districts": "3rd Congressional", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163732, + "year": 2024, + "office": "U.S. House", + "districts": "4th Congressional", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163675, + "year": 2024, + "office": "U.S. House", + "districts": "5th Congressional", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163621, + "year": 2024, + "office": "U.S. House", + "districts": "6th Congressional", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163858, + "year": 2024, + "office": "U.S. House", + "districts": "7th Congressional", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163516, + "year": 2024, + "office": "U.S. House", + "districts": "8th Congressional", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Robert-G-Burke", + "https://electionstats.state.ma.us/candidates/view/James-M-Govatsos", + "https://electionstats.state.ma.us/candidates/view/Daniel-P-Kelly" + ], + "result": { + "candidates": [ + { + "name": "Robert G. Burke", + "votes": 10335, + "writeIn": false + }, + { + "name": "James M. Govatsos", + "votes": 6216, + "writeIn": false + }, + { + "name": "Daniel P. Kelly", + "votes": 5761, + "writeIn": false + } + ], + "otherVotes": 127, + "blankVotes": 2750, + "totalVotes": 25189 + } + }, + { + "id": 163561, + "year": 2024, + "office": "U.S. House", + "districts": "9th Congressional", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Dan-Sullivan" + ], + "result": { + "candidates": [ + { + "name": "Dan Sullivan", + "votes": 36888, + "writeIn": false + } + ], + "otherVotes": 549, + "blankVotes": 7706, + "totalVotes": 45143 + } + }, + { + "id": 163552, + "year": 2024, + "office": "Governor's Council", + "districts": "1st", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163714, + "year": 2024, + "office": "Governor's Council", + "districts": "2nd", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Francis-T-Crimmins-Jr" + ], + "result": { + "candidates": [ + { + "name": "Francis T. Crimmins, Jr", + "votes": 22357, + "writeIn": false + } + ], + "otherVotes": 226, + "blankVotes": 7134, + "totalVotes": 29717 + } + }, + { + "id": 163531, + "year": 2024, + "office": "Governor's Council", + "districts": "3rd", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163507, + "year": 2024, + "office": "Governor's Council", + "districts": "4th", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163615, + "year": 2024, + "office": "Governor's Council", + "districts": "5th", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Anne-M-Manning-Martin" + ], + "result": { + "candidates": [ + { + "name": "Anne M. Manning-Martin", + "votes": 21462, + "writeIn": false + } + ], + "otherVotes": 224, + "blankVotes": 6980, + "totalVotes": 28666 + } + }, + { + "id": 163849, + "year": 2024, + "office": "Governor's Council", + "districts": "6th", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163696, + "year": 2024, + "office": "Governor's Council", + "districts": "7th", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Andrew-J-Couture" + ], + "result": { + "candidates": [ + { + "name": "Andrew J. Couture", + "votes": 24763, + "writeIn": false + } + ], + "otherVotes": 169, + "blankVotes": 7779, + "totalVotes": 32711 + } + }, + { + "id": 163576, + "year": 2024, + "office": "Governor's Council", + "districts": "8th", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163588, + "year": 2024, + "office": "State Senate", + "districts": "Berkshire, Hampden, Franklin & Hampshire", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/David-A-Rosa" + ], + "result": { + "candidates": [ + { + "name": "David A. Rosa", + "votes": 3095, + "writeIn": false + } + ], + "otherVotes": 33, + "blankVotes": 868, + "totalVotes": 3996 + } + }, + { + "id": 164011, + "year": 2024, + "office": "State Senate", + "districts": "First Plymouth & Norfolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Patrick-A-OConnor" + ], + "result": { + "candidates": [ + { + "name": "Patrick A. O'Connor", + "votes": 8061, + "writeIn": false + } + ], + "otherVotes": 13, + "blankVotes": 1178, + "totalVotes": 9252 + } + }, + { + "id": 163798, + "year": 2024, + "office": "State Senate", + "districts": "Hampden, Hampshire & Worcester", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163963, + "year": 2024, + "office": "State Senate", + "districts": "Norfolk & Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163936, + "year": 2024, + "office": "State Senate", + "districts": "Norfolk, Plymouth & Bristol", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Steven-David-Fruzzetti" + ], + "result": { + "candidates": [ + { + "name": "Steven David Fruzzetti", + "votes": 101, + "writeIn": true + } + ], + "otherVotes": 565, + "blankVotes": 5679, + "totalVotes": 6345 + } + }, + { + "id": 163804, + "year": 2024, + "office": "State Senate", + "districts": "Norfolk, Worcester & Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Dashe-M-Videira" + ], + "result": { + "candidates": [ + { + "name": "Dashe M. Videira", + "votes": 1066, + "writeIn": true + } + ], + "otherVotes": 465, + "blankVotes": 6084, + "totalVotes": 7615 + } + }, + { + "id": 163762, + "year": 2024, + "office": "State Senate", + "districts": "Second Plymouth & Norfolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163813, + "year": 2024, + "office": "State Senate", + "districts": "Third Bristol & Plymouth", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Kelly-A-Dooner" + ], + "result": { + "candidates": [ + { + "name": "Kelly A. Dooner", + "votes": 7182, + "writeIn": false + } + ], + "otherVotes": 58, + "blankVotes": 1539, + "totalVotes": 8779 + } + }, + { + "id": 163834, + "year": 2024, + "office": "State Senate", + "districts": "Worcester & Hampden", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Ryan-C-Fattman" + ], + "result": { + "candidates": [ + { + "name": "Ryan C. Fattman", + "votes": 8099, + "writeIn": false + } + ], + "otherVotes": 65, + "blankVotes": 864, + "totalVotes": 9028 + } + }, + { + "id": 163783, + "year": 2024, + "office": "State Senate", + "districts": "Worcester & Hampshire", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Peter-J-Durant" + ], + "result": { + "candidates": [ + { + "name": "Peter J. Durant", + "votes": 7488, + "writeIn": false + } + ], + "otherVotes": 29, + "blankVotes": 953, + "totalVotes": 8470 + } + }, + { + "id": 163741, + "year": 2024, + "office": "State Senate", + "districts": "Bristol and Norfolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164071, + "year": 2024, + "office": "State Senate", + "districts": "1st Bristol and Plymouth", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163570, + "year": 2024, + "office": "State Senate", + "districts": "2nd Bristol and Plymouth", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163672, + "year": 2024, + "office": "State Senate", + "districts": "Cape and Islands", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Christopher-Robert-Lauzon" + ], + "result": { + "candidates": [ + { + "name": "Christopher Robert Lauzon", + "votes": 8405, + "writeIn": false + } + ], + "otherVotes": 75, + "blankVotes": 1350, + "totalVotes": 9830 + } + }, + { + "id": 164110, + "year": 2024, + "office": "State Senate", + "districts": "1st Essex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163825, + "year": 2024, + "office": "State Senate", + "districts": "2nd Essex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Damian-Mitchell-Anketell" + ], + "result": { + "candidates": [ + { + "name": "Damian Mitchell Anketell", + "votes": 4649, + "writeIn": false + } + ], + "otherVotes": 25, + "blankVotes": 1335, + "totalVotes": 6009 + } + }, + { + "id": 164161, + "year": 2024, + "office": "State Senate", + "districts": "3rd Essex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163930, + "year": 2024, + "office": "State Senate", + "districts": "1st Essex and Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Bruce-E-Tarr" + ], + "result": { + "candidates": [ + { + "name": "Bruce E. Tarr", + "votes": 7197, + "writeIn": false + } + ], + "otherVotes": 43, + "blankVotes": 983, + "totalVotes": 8223 + } + }, + { + "id": 163630, + "year": 2024, + "office": "State Senate", + "districts": "2nd Essex and Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164005, + "year": 2024, + "office": "State Senate", + "districts": "Hampden", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163603, + "year": 2024, + "office": "State Senate", + "districts": "Hampden and Hampshire", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163648, + "year": 2024, + "office": "State Senate", + "districts": "Hampshire, Franklin and Worcester", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164038, + "year": 2024, + "office": "State Senate", + "districts": "1st Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Karla-J-Miller" + ], + "result": { + "candidates": [ + { + "name": "Karla J. Miller", + "votes": 3827, + "writeIn": false + } + ], + "otherVotes": 38, + "blankVotes": 908, + "totalVotes": 4773 + } + }, + { + "id": 163975, + "year": 2024, + "office": "State Senate", + "districts": "2nd Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163792, + "year": 2024, + "office": "State Senate", + "districts": "3rd Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163684, + "year": 2024, + "office": "State Senate", + "districts": "4th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164176, + "year": 2024, + "office": "State Senate", + "districts": "5th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163720, + "year": 2024, + "office": "State Senate", + "districts": "Middlesex and Norfolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163546, + "year": 2024, + "office": "State Senate", + "districts": "Middlesex and Worcester", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163912, + "year": 2024, + "office": "State Senate", + "districts": "Middlesex and Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163525, + "year": 2024, + "office": "State Senate", + "districts": "Norfolk and Plymouth", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163915, + "year": 2024, + "office": "State Senate", + "districts": "Norfolk and Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163927, + "year": 2024, + "office": "State Senate", + "districts": "Plymouth and Barnstable", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Mathew-J-Muratore", + "https://electionstats.state.ma.us/candidates/view/Kari-Macrae" + ], + "result": { + "candidates": [ + { + "name": "Mathew J. Muratore", + "votes": 7005, + "writeIn": false + }, + { + "name": "Kari Macrae", + "votes": 6966, + "writeIn": false + } + ], + "otherVotes": 15, + "blankVotes": 564, + "totalVotes": 14550 + } + }, + { + "id": 163909, + "year": 2024, + "office": "State Senate", + "districts": "1st Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163918, + "year": 2024, + "office": "State Senate", + "districts": "2nd Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163921, + "year": 2024, + "office": "State Senate", + "districts": "3rd Suffolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jeannamarie-Tamas" + ], + "result": { + "candidates": [ + { + "name": "Jeannamarie Tamas", + "votes": 1817, + "writeIn": false + } + ], + "otherVotes": 30, + "blankVotes": 860, + "totalVotes": 2707 + } + }, + { + "id": 163807, + "year": 2024, + "office": "State Senate", + "districts": "Suffolk and Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163702, + "year": 2024, + "office": "State Senate", + "districts": "Worcester and Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Nicholas-A-Pirro-III" + ], + "result": { + "candidates": [ + { + "name": "Nicholas A. Pirro, III", + "votes": 5747, + "writeIn": false + } + ], + "otherVotes": 41, + "blankVotes": 897, + "totalVotes": 6685 + } + }, + { + "id": 163819, + "year": 2024, + "office": "State Senate", + "districts": "1st Worcester", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163747, + "year": 2024, + "office": "State Senate", + "districts": "2nd Worcester", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163939, + "year": 2024, + "office": "State Representative", + "districts": "1st Barnstable", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Gerald-Joseph-OConnell" + ], + "result": { + "candidates": [ + { + "name": "Gerald Joseph O'Connell", + "votes": 518, + "writeIn": true + } + ], + "otherVotes": 165, + "blankVotes": 2703, + "totalVotes": 3386 + } + }, + { + "id": 163777, + "year": 2024, + "office": "State Representative", + "districts": "2nd Barnstable", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Susanne-H-Conley", + "https://electionstats.state.ma.us/candidates/view/Gerald-Joseph-OConnell" + ], + "result": { + "candidates": [ + { + "name": "Susanne H. Conley", + "votes": 2148, + "writeIn": false + }, + { + "name": "Gerald Joseph O'Connell", + "votes": 0, + "writeIn": true + } + ], + "otherVotes": 56, + "blankVotes": 366, + "totalVotes": 2570 + } + }, + { + "id": 163924, + "year": 2024, + "office": "State Representative", + "districts": "3rd Barnstable", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/David-T-Vieira" + ], + "result": { + "candidates": [ + { + "name": "David T. Vieira", + "votes": 2672, + "writeIn": false + } + ], + "otherVotes": 23, + "blankVotes": 467, + "totalVotes": 3162 + } + }, + { + "id": 163984, + "year": 2024, + "office": "State Representative", + "districts": "4th Barnstable", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163774, + "year": 2024, + "office": "State Representative", + "districts": "5th Barnstable", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Steven-G-Xiarhos" + ], + "result": { + "candidates": [ + { + "name": "Steven G. Xiarhos", + "votes": 3039, + "writeIn": false + } + ], + "otherVotes": 10, + "blankVotes": 253, + "totalVotes": 3302 + } + }, + { + "id": 163669, + "year": 2024, + "office": "State Representative", + "districts": "Barnstable, Dukes and Nantucket", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Erich-F-Horgan" + ], + "result": { + "candidates": [ + { + "name": "Erich F. Horgan", + "votes": 131, + "writeIn": true + } + ], + "otherVotes": 160, + "blankVotes": 996, + "totalVotes": 1287 + } + }, + { + "id": 163585, + "year": 2024, + "office": "State Representative", + "districts": "1st Berkshire", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164224, + "year": 2024, + "office": "State Representative", + "districts": "2nd Berkshire", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163609, + "year": 2024, + "office": "State Representative", + "districts": "3rd Berkshire", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164077, + "year": 2024, + "office": "State Representative", + "districts": "1st Bristol", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-Chaisson" + ], + "result": { + "candidates": [ + { + "name": "Michael Chaisson", + "votes": 1434, + "writeIn": false + } + ], + "otherVotes": 6, + "blankVotes": 354, + "totalVotes": 1794 + } + }, + { + "id": 163738, + "year": 2024, + "office": "State Representative", + "districts": "2nd Bristol", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164053, + "year": 2024, + "office": "State Representative", + "districts": "3rd Bristol", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164215, + "year": 2024, + "office": "State Representative", + "districts": "4th Bristol", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Steven-S-Howitt" + ], + "result": { + "candidates": [ + { + "name": "Steven S. Howitt", + "votes": 1628, + "writeIn": false + } + ], + "otherVotes": 14, + "blankVotes": 279, + "totalVotes": 1921 + } + }, + { + "id": 164026, + "year": 2024, + "office": "State Representative", + "districts": "5th Bristol", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Justin-Thurber" + ], + "result": { + "candidates": [ + { + "name": "Justin Thurber", + "votes": 1415, + "writeIn": false + } + ], + "otherVotes": 16, + "blankVotes": 377, + "totalVotes": 1808 + } + }, + { + "id": 164068, + "year": 2024, + "office": "State Representative", + "districts": "6th Bristol", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164065, + "year": 2024, + "office": "State Representative", + "districts": "7th Bristol", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163564, + "year": 2024, + "office": "State Representative", + "districts": "8th Bristol", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Christopher-Thrasher" + ], + "result": { + "candidates": [ + { + "name": "Christopher Thrasher", + "votes": 213, + "writeIn": true + } + ], + "otherVotes": 58, + "blankVotes": 1787, + "totalVotes": 2058 + } + }, + { + "id": 164020, + "year": 2024, + "office": "State Representative", + "districts": "9th Bristol", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163567, + "year": 2024, + "office": "State Representative", + "districts": "10th Bristol", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Joseph-M-Pires", + "https://electionstats.state.ma.us/candidates/view/Robert-S-McConnell" + ], + "result": { + "candidates": [ + { + "name": "Joseph M. Pires", + "votes": 1894, + "writeIn": false + }, + { + "name": "Robert S. McConnell", + "votes": 854, + "writeIn": false + } + ], + "otherVotes": 6, + "blankVotes": 107, + "totalVotes": 2861 + } + }, + { + "id": 164203, + "year": 2024, + "office": "State Representative", + "districts": "11th Bristol", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163810, + "year": 2024, + "office": "State Representative", + "districts": "12th Bristol", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Norman-J-Orrall" + ], + "result": { + "candidates": [ + { + "name": "Norman J. Orrall", + "votes": 1685, + "writeIn": false + } + ], + "otherVotes": 7, + "blankVotes": 324, + "totalVotes": 2016 + } + }, + { + "id": 164206, + "year": 2024, + "office": "State Representative", + "districts": "13th Bristol", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163735, + "year": 2024, + "office": "State Representative", + "districts": "14th Bristol", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/David-B-Cannata" + ], + "result": { + "candidates": [ + { + "name": "David Cannata, Jr", + "votes": 1229, + "writeIn": false + } + ], + "otherVotes": 8, + "blankVotes": 295, + "totalVotes": 1532 + } + }, + { + "id": 163624, + "year": 2024, + "office": "State Representative", + "districts": "1st Essex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164092, + "year": 2024, + "office": "State Representative", + "districts": "2nd Essex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Mark-T-Tashjian" + ], + "result": { + "candidates": [ + { + "name": "Mark T. Tashjian", + "votes": 394, + "writeIn": true + } + ], + "otherVotes": 122, + "blankVotes": 2126, + "totalVotes": 2642 + } + }, + { + "id": 164107, + "year": 2024, + "office": "State Representative", + "districts": "3rd Essex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164125, + "year": 2024, + "office": "State Representative", + "districts": "4th Essex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164056, + "year": 2024, + "office": "State Representative", + "districts": "5th Essex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163822, + "year": 2024, + "office": "State Representative", + "districts": "6th Essex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Ty-Vitale" + ], + "result": { + "candidates": [ + { + "name": "Ty Vitale", + "votes": 1153, + "writeIn": false + } + ], + "otherVotes": 2, + "blankVotes": 304, + "totalVotes": 1459 + } + }, + { + "id": 164245, + "year": 2024, + "office": "State Representative", + "districts": "7th Essex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164149, + "year": 2024, + "office": "State Representative", + "districts": "8th Essex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164155, + "year": 2024, + "office": "State Representative", + "districts": "9th Essex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Donald-H-Wong" + ], + "result": { + "candidates": [ + { + "name": "Donald H. Wong", + "votes": 1796, + "writeIn": false + } + ], + "otherVotes": 9, + "blankVotes": 305, + "totalVotes": 2110 + } + }, + { + "id": 164158, + "year": 2024, + "office": "State Representative", + "districts": "10th Essex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164152, + "year": 2024, + "office": "State Representative", + "districts": "11th Essex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164221, + "year": 2024, + "office": "State Representative", + "districts": "12th Essex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164017, + "year": 2024, + "office": "State Representative", + "districts": "13th Essex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163627, + "year": 2024, + "office": "State Representative", + "districts": "14th Essex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164104, + "year": 2024, + "office": "State Representative", + "districts": "15th Essex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164128, + "year": 2024, + "office": "State Representative", + "districts": "16th Essex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163657, + "year": 2024, + "office": "State Representative", + "districts": "17th Essex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163654, + "year": 2024, + "office": "State Representative", + "districts": "18th Essex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163711, + "year": 2024, + "office": "State Representative", + "districts": "1st Franklin", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163726, + "year": 2024, + "office": "State Representative", + "districts": "2nd Franklin", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jeffrey-L-Raymond" + ], + "result": { + "candidates": [ + { + "name": "Jeffrey L. Raymond", + "votes": 1180, + "writeIn": false + } + ], + "otherVotes": 9, + "blankVotes": 308, + "totalVotes": 1497 + } + }, + { + "id": 163945, + "year": 2024, + "office": "State Representative", + "districts": "1st Hampden", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Todd-M-Smola" + ], + "result": { + "candidates": [ + { + "name": "Todd M. Smola", + "votes": 2072, + "writeIn": false + } + ], + "otherVotes": 19, + "blankVotes": 303, + "totalVotes": 2394 + } + }, + { + "id": 164044, + "year": 2024, + "office": "State Representative", + "districts": "2nd Hampden", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163600, + "year": 2024, + "office": "State Representative", + "districts": "3rd Hampden", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Nicholas-A-Boldyga" + ], + "result": { + "candidates": [ + { + "name": "Nicholas A. Boldyga", + "votes": 1902, + "writeIn": false + } + ], + "otherVotes": 10, + "blankVotes": 331, + "totalVotes": 2243 + } + }, + { + "id": 164254, + "year": 2024, + "office": "State Representative", + "districts": "4th Hampden", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Kelly-W-Pease" + ], + "result": { + "candidates": [ + { + "name": "Kelly W. Pease", + "votes": 1737, + "writeIn": false + } + ], + "otherVotes": 51, + "blankVotes": 278, + "totalVotes": 2066 + } + }, + { + "id": 164002, + "year": 2024, + "office": "State Representative", + "districts": "5th Hampden", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163597, + "year": 2024, + "office": "State Representative", + "districts": "6th Hampden", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163795, + "year": 2024, + "office": "State Representative", + "districts": "7th Hampden", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163999, + "year": 2024, + "office": "State Representative", + "districts": "8th Hampden", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164260, + "year": 2024, + "office": "State Representative", + "districts": "9th Hampden", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164263, + "year": 2024, + "office": "State Representative", + "districts": "10th Hampden", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164257, + "year": 2024, + "office": "State Representative", + "districts": "11th Hampden", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164047, + "year": 2024, + "office": "State Representative", + "districts": "12th Hampden", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163996, + "year": 2024, + "office": "State Representative", + "districts": "1st Hampshire", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164050, + "year": 2024, + "office": "State Representative", + "districts": "2nd Hampshire", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163645, + "year": 2024, + "office": "State Representative", + "districts": "3rd Hampshire", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163699, + "year": 2024, + "office": "State Representative", + "districts": "1st Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Lynne-E-Archambault" + ], + "result": { + "candidates": [ + { + "name": "Lynne E. Archambault", + "votes": 2190, + "writeIn": false + } + ], + "otherVotes": 21, + "blankVotes": 292, + "totalVotes": 2503 + } + }, + { + "id": 163987, + "year": 2024, + "office": "State Representative", + "districts": "2nd Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163837, + "year": 2024, + "office": "State Representative", + "districts": "3rd Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164080, + "year": 2024, + "office": "State Representative", + "districts": "4th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164200, + "year": 2024, + "office": "State Representative", + "districts": "5th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164086, + "year": 2024, + "office": "State Representative", + "districts": "6th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163717, + "year": 2024, + "office": "State Representative", + "districts": "7th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164119, + "year": 2024, + "office": "State Representative", + "districts": "8th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164272, + "year": 2024, + "office": "State Representative", + "districts": "9th Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Carly-Marie-Downs" + ], + "result": { + "candidates": [ + { + "name": "Carly Marie Downs", + "votes": 685, + "writeIn": false + } + ], + "otherVotes": 17, + "blankVotes": 374, + "totalVotes": 1076 + } + }, + { + "id": 164212, + "year": 2024, + "office": "State Representative", + "districts": "10th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164209, + "year": 2024, + "office": "State Representative", + "districts": "11th Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Vladislav-S-Yanovsky" + ], + "result": { + "candidates": [ + { + "name": "Vladislav S. Yanovsky", + "votes": 450, + "writeIn": false + } + ], + "otherVotes": 7, + "blankVotes": 215, + "totalVotes": 672 + } + }, + { + "id": 163960, + "year": 2024, + "office": "State Representative", + "districts": "12th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164014, + "year": 2024, + "office": "State Representative", + "districts": "13th Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Virginia-A-Gardner" + ], + "result": { + "candidates": [ + { + "name": "Virginia A Gardner", + "votes": 151, + "writeIn": true + } + ], + "otherVotes": 23, + "blankVotes": 1035, + "totalVotes": 1209 + } + }, + { + "id": 163540, + "year": 2024, + "office": "State Representative", + "districts": "14th Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Doreen-Ann-Deshler" + ], + "result": { + "candidates": [ + { + "name": "Doreen Ann Deshler", + "votes": 62, + "writeIn": true + } + ], + "otherVotes": 36, + "blankVotes": 1333, + "totalVotes": 1431 + } + }, + { + "id": 164137, + "year": 2024, + "office": "State Representative", + "districts": "15th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163990, + "year": 2024, + "office": "State Representative", + "districts": "16th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164146, + "year": 2024, + "office": "State Representative", + "districts": "17th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164143, + "year": 2024, + "office": "State Representative", + "districts": "18th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164269, + "year": 2024, + "office": "State Representative", + "districts": "19th Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Paul-Sarnowski" + ], + "result": { + "candidates": [ + { + "name": "Paul Sarnowski", + "votes": 1591, + "writeIn": false + } + ], + "otherVotes": 14, + "blankVotes": 448, + "totalVotes": 2053 + } + }, + { + "id": 164164, + "year": 2024, + "office": "State Representative", + "districts": "20th Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Bradley-H-Jones-Jr" + ], + "result": { + "candidates": [ + { + "name": "Bradley H. Jones, Jr", + "votes": 2019, + "writeIn": false + } + ], + "otherVotes": 4, + "blankVotes": 391, + "totalVotes": 2414 + } + }, + { + "id": 163789, + "year": 2024, + "office": "State Representative", + "districts": "21st Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163831, + "year": 2024, + "office": "State Representative", + "districts": "22nd Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Marc-T-Lombardo" + ], + "result": { + "candidates": [ + { + "name": "Marc T. Lombardo", + "votes": 1657, + "writeIn": false + } + ], + "otherVotes": 25, + "blankVotes": 232, + "totalVotes": 1914 + } + }, + { + "id": 163681, + "year": 2024, + "office": "State Representative", + "districts": "23rd Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163678, + "year": 2024, + "office": "State Representative", + "districts": "24th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163966, + "year": 2024, + "office": "State Representative", + "districts": "25th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163972, + "year": 2024, + "office": "State Representative", + "districts": "26th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164251, + "year": 2024, + "office": "State Representative", + "districts": "27th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164059, + "year": 2024, + "office": "State Representative", + "districts": "28th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163969, + "year": 2024, + "office": "State Representative", + "districts": "29th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164236, + "year": 2024, + "office": "State Representative", + "districts": "30th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164266, + "year": 2024, + "office": "State Representative", + "districts": "31st Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164170, + "year": 2024, + "office": "State Representative", + "districts": "32nd Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164173, + "year": 2024, + "office": "State Representative", + "districts": "33rd Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164188, + "year": 2024, + "office": "State Representative", + "districts": "34th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164167, + "year": 2024, + "office": "State Representative", + "districts": "35th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164035, + "year": 2024, + "office": "State Representative", + "districts": "36th Middlesex", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163543, + "year": 2024, + "office": "State Representative", + "districts": "37th Middlesex", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Gary-J-Grossi" + ], + "result": { + "candidates": [ + { + "name": "Gary J. Grossi", + "votes": 95, + "writeIn": true + } + ], + "otherVotes": 50, + "blankVotes": 1233, + "totalVotes": 1378 + } + }, + { + "id": 164230, + "year": 2024, + "office": "State Representative", + "districts": "1st Norfolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164233, + "year": 2024, + "office": "State Representative", + "districts": "2nd Norfolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Sharon-Marie-Cintolo" + ], + "result": { + "candidates": [ + { + "name": "Sharon Marie Cintolo", + "votes": 1030, + "writeIn": false + } + ], + "otherVotes": 24, + "blankVotes": 602, + "totalVotes": 1656 + } + }, + { + "id": 164113, + "year": 2024, + "office": "State Representative", + "districts": "3rd Norfolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164278, + "year": 2024, + "office": "State Representative", + "districts": "4th Norfolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163933, + "year": 2024, + "office": "State Representative", + "districts": "5th Norfolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163759, + "year": 2024, + "office": "State Representative", + "districts": "6th Norfolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164191, + "year": 2024, + "office": "State Representative", + "districts": "7th Norfolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164179, + "year": 2024, + "office": "State Representative", + "districts": "8th Norfolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164185, + "year": 2024, + "office": "State Representative", + "districts": "9th Norfolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Marcus-S-Vaughn" + ], + "result": { + "candidates": [ + { + "name": "Marcus S. Vaughn", + "votes": 2232, + "writeIn": false + } + ], + "otherVotes": 6, + "blankVotes": 372, + "totalVotes": 2610 + } + }, + { + "id": 164089, + "year": 2024, + "office": "State Representative", + "districts": "10th Norfolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Charles-F-Bailey-III" + ], + "result": { + "candidates": [ + { + "name": "Charles F. Bailey, III", + "votes": 1730, + "writeIn": false + } + ], + "otherVotes": 22, + "blankVotes": 347, + "totalVotes": 2099 + } + }, + { + "id": 164023, + "year": 2024, + "office": "State Representative", + "districts": "11th Norfolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164218, + "year": 2024, + "office": "State Representative", + "districts": "12th Norfolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164032, + "year": 2024, + "office": "State Representative", + "districts": "13th Norfolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164140, + "year": 2024, + "office": "State Representative", + "districts": "14th Norfolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163957, + "year": 2024, + "office": "State Representative", + "districts": "15th Norfolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164227, + "year": 2024, + "office": "State Representative", + "districts": "1st Plymouth", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jesse-G-Brown", + "https://electionstats.state.ma.us/candidates/view/Dee-Wallace-Spencer" + ], + "result": { + "candidates": [ + { + "name": "Jesse G. Brown", + "votes": 2241, + "writeIn": false + }, + { + "name": "Dee Wallace Spencer", + "votes": 2027, + "writeIn": false + } + ], + "otherVotes": 8, + "blankVotes": 167, + "totalVotes": 4443 + } + }, + { + "id": 163978, + "year": 2024, + "office": "State Representative", + "districts": "2nd Plymouth", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-Robert-Gaskey", + "https://electionstats.state.ma.us/candidates/view/Susan-Williams-Gifford" + ], + "result": { + "candidates": [ + { + "name": "John Robert Gaskey", + "votes": 1811, + "writeIn": false + }, + { + "name": "Susan Williams Gifford", + "votes": 1258, + "writeIn": false + } + ], + "otherVotes": 2, + "blankVotes": 50, + "totalVotes": 3121 + } + }, + { + "id": 164008, + "year": 2024, + "office": "State Representative", + "districts": "3rd Plymouth", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164182, + "year": 2024, + "office": "State Representative", + "districts": "4th Plymouth", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164101, + "year": 2024, + "office": "State Representative", + "districts": "5th Plymouth", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/David-F-Decoste" + ], + "result": { + "candidates": [ + { + "name": "David F. Decoste", + "votes": 2266, + "writeIn": false + } + ], + "otherVotes": 13, + "blankVotes": 298, + "totalVotes": 2577 + } + }, + { + "id": 164041, + "year": 2024, + "office": "State Representative", + "districts": "6th Plymouth", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Kenneth-Sweezey", + "https://electionstats.state.ma.us/candidates/view/Jane-L-Cournan" + ], + "result": { + "candidates": [ + { + "name": "Kenneth Peter Sweezey", + "votes": 1846, + "writeIn": false + }, + { + "name": "Jane L. Cournan", + "votes": 1681, + "writeIn": false + } + ], + "otherVotes": 1, + "blankVotes": 92, + "totalVotes": 3620 + } + }, + { + "id": 163519, + "year": 2024, + "office": "State Representative", + "districts": "7th Plymouth", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Alyson-M-Sullivan-Almeida" + ], + "result": { + "candidates": [ + { + "name": "Alyson M. Sullivan-Almeida", + "votes": 1829, + "writeIn": false + } + ], + "otherVotes": 7, + "blankVotes": 319, + "totalVotes": 2155 + } + }, + { + "id": 163942, + "year": 2024, + "office": "State Representative", + "districts": "8th Plymouth", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Sandra-M-Wright" + ], + "result": { + "candidates": [ + { + "name": "Sandra M. Wright", + "votes": 1516, + "writeIn": false + } + ], + "otherVotes": 5, + "blankVotes": 330, + "totalVotes": 1851 + } + }, + { + "id": 163951, + "year": 2024, + "office": "State Representative", + "districts": "9th Plymouth", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Lawrence-Peter-Novak" + ], + "result": { + "candidates": [ + { + "name": "Lawrence Peter Novak", + "votes": 1244, + "writeIn": false + } + ], + "otherVotes": 12, + "blankVotes": 440, + "totalVotes": 1696 + } + }, + { + "id": 163954, + "year": 2024, + "office": "State Representative", + "districts": "10th Plymouth", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163948, + "year": 2024, + "office": "State Representative", + "districts": "11th Plymouth", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164098, + "year": 2024, + "office": "State Representative", + "districts": "12th Plymouth", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Eric-J-Meschino" + ], + "result": { + "candidates": [ + { + "name": "Eric J. Meschino", + "votes": 2553, + "writeIn": false + } + ], + "otherVotes": 7, + "blankVotes": 652, + "totalVotes": 3212 + } + }, + { + "id": 163873, + "year": 2024, + "office": "State Representative", + "districts": "1st Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163885, + "year": 2024, + "office": "State Representative", + "districts": "2nd Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163900, + "year": 2024, + "office": "State Representative", + "districts": "3rd Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163879, + "year": 2024, + "office": "State Representative", + "districts": "4th Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163870, + "year": 2024, + "office": "State Representative", + "districts": "5th Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163894, + "year": 2024, + "office": "State Representative", + "districts": "6th Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163891, + "year": 2024, + "office": "State Representative", + "districts": "7th Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163864, + "year": 2024, + "office": "State Representative", + "districts": "8th Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163882, + "year": 2024, + "office": "State Representative", + "districts": "9th Suffolk", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Roy-A-Owens" + ], + "result": { + "candidates": [ + { + "name": "Roy A. Owens", + "votes": 179, + "writeIn": false + } + ], + "otherVotes": 11, + "blankVotes": 88, + "totalVotes": 278 + } + }, + { + "id": 163897, + "year": 2024, + "office": "State Representative", + "districts": "10th Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163993, + "year": 2024, + "office": "State Representative", + "districts": "11th Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163906, + "year": 2024, + "office": "State Representative", + "districts": "12th Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163903, + "year": 2024, + "office": "State Representative", + "districts": "13th Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163876, + "year": 2024, + "office": "State Representative", + "districts": "14th Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163867, + "year": 2024, + "office": "State Representative", + "districts": "15th Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164242, + "year": 2024, + "office": "State Representative", + "districts": "16th Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163888, + "year": 2024, + "office": "State Representative", + "districts": "17th Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163861, + "year": 2024, + "office": "State Representative", + "districts": "18th Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164239, + "year": 2024, + "office": "State Representative", + "districts": "19th Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164116, + "year": 2024, + "office": "State Representative", + "districts": "1st Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Kimberly-N-Ferguson" + ], + "result": { + "candidates": [ + { + "name": "Kimberly N. Ferguson", + "votes": 2278, + "writeIn": false + } + ], + "otherVotes": 7, + "blankVotes": 336, + "totalVotes": 2621 + } + }, + { + "id": 163693, + "year": 2024, + "office": "State Representative", + "districts": "2nd Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Bruce-K-Chester" + ], + "result": { + "candidates": [ + { + "name": "Bruce K. Chester", + "votes": 1396, + "writeIn": false + } + ], + "otherVotes": 10, + "blankVotes": 216, + "totalVotes": 1622 + } + }, + { + "id": 164074, + "year": 2024, + "office": "State Representative", + "districts": "3rd Worcester", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164134, + "year": 2024, + "office": "State Representative", + "districts": "4th Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Salvatore-Perla" + ], + "result": { + "candidates": [ + { + "name": "Salvatore Perla", + "votes": 1295, + "writeIn": false + } + ], + "otherVotes": 3, + "blankVotes": 248, + "totalVotes": 1546 + } + }, + { + "id": 163780, + "year": 2024, + "office": "State Representative", + "districts": "5th Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Donald-R-Berthiaume-Jr" + ], + "result": { + "candidates": [ + { + "name": "Donald R. Berthiaume, Jr", + "votes": 2444, + "writeIn": false + } + ], + "otherVotes": 8, + "blankVotes": 282, + "totalVotes": 2734 + } + }, + { + "id": 160413, + "year": 2024, + "office": "State Representative", + "districts": "6th Worcester", + "party": "Republican", + "special": true, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-J-Marsi-Jr", + "https://electionstats.state.ma.us/candidates/view/David-S-Adams" + ], + "result": { + "candidates": [ + { + "name": "John J. Marsi, Jr", + "votes": 1189, + "writeIn": false + }, + { + "name": "David S. Adams", + "votes": 1044, + "writeIn": false + } + ], + "otherVotes": 3, + "blankVotes": 7, + "totalVotes": 2243 + } + }, + { + "id": 163981, + "year": 2024, + "office": "State Representative", + "districts": "6th Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-J-Marsi-Jr" + ], + "result": { + "candidates": [ + { + "name": "John J. Marsi, Jr", + "votes": 1711, + "writeIn": false + } + ], + "otherVotes": 15, + "blankVotes": 248, + "totalVotes": 1974 + } + }, + { + "id": 163744, + "year": 2024, + "office": "State Representative", + "districts": "7th Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Paul-K-Frost", + "https://electionstats.state.ma.us/candidates/view/Michelle-L-Frigon" + ], + "result": { + "candidates": [ + { + "name": "Paul K. Frost", + "votes": 2107, + "writeIn": false + }, + { + "name": "Michelle L. Frigon", + "votes": 476, + "writeIn": false + } + ], + "otherVotes": 2, + "blankVotes": 59, + "totalVotes": 2644 + } + }, + { + "id": 163801, + "year": 2024, + "office": "State Representative", + "districts": "8th Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Michael-J-Soter" + ], + "result": { + "candidates": [ + { + "name": "Michael J. Soter", + "votes": 1765, + "writeIn": false + } + ], + "otherVotes": 29, + "blankVotes": 265, + "totalVotes": 2059 + } + }, + { + "id": 164095, + "year": 2024, + "office": "State Representative", + "districts": "9th Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/David-Kent-Muradian-Jr" + ], + "result": { + "candidates": [ + { + "name": "David Kent Muradian, Jr", + "votes": 1597, + "writeIn": false + } + ], + "otherVotes": 8, + "blankVotes": 207, + "totalVotes": 1812 + } + }, + { + "id": 164122, + "year": 2024, + "office": "State Representative", + "districts": "10th Worcester", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164248, + "year": 2024, + "office": "State Representative", + "districts": "11th Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Hannah-E-Kane" + ], + "result": { + "candidates": [ + { + "name": "Hannah E. Kane", + "votes": 1093, + "writeIn": false + } + ], + "otherVotes": 4, + "blankVotes": 165, + "totalVotes": 1262 + } + }, + { + "id": 163816, + "year": 2024, + "office": "State Representative", + "districts": "12th Worcester", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164287, + "year": 2024, + "office": "State Representative", + "districts": "13th Worcester", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164275, + "year": 2024, + "office": "State Representative", + "districts": "14th Worcester", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164281, + "year": 2024, + "office": "State Representative", + "districts": "15th Worcester", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164284, + "year": 2024, + "office": "State Representative", + "districts": "16th Worcester", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164131, + "year": 2024, + "office": "State Representative", + "districts": "17th Worcester", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164029, + "year": 2024, + "office": "State Representative", + "districts": "18th Worcester", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Joseph-D-Mckenna" + ], + "result": { + "candidates": [ + { + "name": "Joseph D. Mckenna", + "votes": 1944, + "writeIn": false + } + ], + "otherVotes": 9, + "blankVotes": 293, + "totalVotes": 2246 + } + }, + { + "id": 164083, + "year": 2024, + "office": "State Representative", + "districts": "19th Worcester", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163765, + "year": 2024, + "office": "Clerk of Courts", + "districts": "Barnstable County", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163573, + "year": 2024, + "office": "Clerk of Courts", + "districts": "Berkshire County", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163549, + "year": 2024, + "office": "Clerk of Courts", + "districts": "Bristol County", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163660, + "year": 2024, + "office": "Clerk of Courts", + "districts": "Dukes County", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163612, + "year": 2024, + "office": "Clerk of Courts", + "districts": "Essex County", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163705, + "year": 2024, + "office": "Clerk of Courts", + "districts": "Franklin County", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163591, + "year": 2024, + "office": "Clerk of Courts", + "districts": "Hampden County", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163633, + "year": 2024, + "office": "Clerk of Courts", + "districts": "Hampshire County", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163528, + "year": 2024, + "office": "Clerk of Courts", + "districts": "Middlesex County", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164194, + "year": 2024, + "office": "Clerk of Courts", + "districts": "Nantucket County", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163750, + "year": 2024, + "office": "Clerk of Courts", + "districts": "Norfolk County", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163504, + "year": 2024, + "office": "Clerk of Courts", + "districts": "Plymouth County", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163687, + "year": 2024, + "office": "Clerk of Courts", + "districts": "Worcester County", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163840, + "year": 2024, + "office": "Clerk of Superior Court (Civil)", + "districts": "Suffolk County", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163843, + "year": 2024, + "office": "Clerk of Superior Court (Criminal)", + "districts": "Suffolk County", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163846, + "year": 2024, + "office": "Clerk of Supreme Judicial Court", + "districts": "Suffolk County", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163771, + "year": 2024, + "office": "Register of Deeds", + "districts": "Barnstable", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/John-F-Meade" + ], + "result": { + "candidates": [ + { + "name": "John F. Meade", + "votes": 12804, + "writeIn": false + } + ], + "otherVotes": 39, + "blankVotes": 2542, + "totalVotes": 15385 + } + }, + { + "id": 163786, + "year": 2024, + "office": "Register of Deeds", + "districts": "Berkshire Middle", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163579, + "year": 2024, + "office": "Register of Deeds", + "districts": "Berkshire Northern", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163606, + "year": 2024, + "office": "Register of Deeds", + "districts": "Berkshire Southern", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163729, + "year": 2024, + "office": "Register of Deeds", + "districts": "Bristol Northern", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163558, + "year": 2024, + "office": "Register of Deeds", + "districts": "Bristol Southern", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163666, + "year": 2024, + "office": "Register of Deeds", + "districts": "Dukes", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163651, + "year": 2024, + "office": "Register of Deeds", + "districts": "Essex Northern", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163618, + "year": 2024, + "office": "Register of Deeds", + "districts": "Essex Southern", + "party": "Republican", + "special": false, + "candidateUrls": [ + "https://electionstats.state.ma.us/candidates/view/Jonathan-Edward-Ring" + ], + "result": { + "candidates": [ + { + "name": "Jonathan Edward Ring", + "votes": 15951, + "writeIn": false + } + ], + "otherVotes": 156, + "blankVotes": 4812, + "totalVotes": 20919 + } + }, + { + "id": 164062, + "year": 2024, + "office": "Register of Deeds", + "districts": "Fall River", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163708, + "year": 2024, + "office": "Register of Deeds", + "districts": "Franklin", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163594, + "year": 2024, + "office": "Register of Deeds", + "districts": "Hampden", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163636, + "year": 2024, + "office": "Register of Deeds", + "districts": "Hampshire", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163828, + "year": 2024, + "office": "Register of Deeds", + "districts": "Middlesex Northern", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163534, + "year": 2024, + "office": "Register of Deeds", + "districts": "Middlesex Southern", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 164197, + "year": 2024, + "office": "Register of Deeds", + "districts": "Nantucket", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163756, + "year": 2024, + "office": "Register of Deeds", + "districts": "Norfolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163513, + "year": 2024, + "office": "Register of Deeds", + "districts": "Plymouth", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163852, + "year": 2024, + "office": "Register of Deeds", + "districts": "Suffolk", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163723, + "year": 2024, + "office": "Register of Deeds", + "districts": "Worcester", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163690, + "year": 2024, + "office": "Register of Deeds", + "districts": "Worcester Northern", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163639, + "year": 2024, + "office": "Register of Probate", + "districts": "Hampshire County", + "candidateUrls": [], + "party": "Republican", + "special": false + }, + { + "id": 163855, + "year": 2024, + "office": "Register of Probate", + "districts": "Suffolk County", + "candidateUrls": [], + "party": "Republican", + "special": false + } +] \ No newline at end of file From 24ec2d01bae1e463b3b3040f20a5b2a73c3410c3 Mon Sep 17 00:00:00 2001 From: "Grace (Iron Yard/CaseNEX/IO Account)" Date: Fri, 24 Jul 2026 20:11:38 -0400 Subject: [PATCH 094/106] Revise Learn and About page copy and fix typos (#2207) - Major copy edits to How Testimony Works, Legislative Process, Writing Effective Testimony, Why Use MAPLE page copy - Fix assorted typos including in the How MAPLE Uses AI and Mission & Goals copy --- public/locales/en/forindividuals.json | 8 ++--- public/locales/en/forlegislators.json | 8 ++--- public/locales/en/fororgs.json | 24 ++++++------- public/locales/en/goalsandmission.json | 4 +-- public/locales/en/learn.json | 47 +++++++++++++------------- public/locales/en/mapleAI.json | 4 +-- 6 files changed, 48 insertions(+), 47 deletions(-) diff --git a/public/locales/en/forindividuals.json b/public/locales/en/forindividuals.json index ea232840f..196dd4223 100644 --- a/public/locales/en/forindividuals.json +++ b/public/locales/en/forindividuals.json @@ -5,21 +5,21 @@ "callToAction": { "header": "Why use MAPLE", "title": "Make your voice count in the laws that shape our future.", - "bodytext": "With a quick search on MAPLE, you can see what bills the state legislature is considering about the topics that matter to you. You can find out what policy changes are proposed, see what other constituents and organizations are saying about them, and-most important-you can submit written testimony directly to our representatives in the State House to make your perspective part of the conversation." + "bodytext": "With a quick search on MAPLE, you can see what bills the state legislature is considering about the topics that matter to you. You can find out what policy changes are proposed, see what other constituents and organizations are saying about them, and you can submit written testimony directly to our representatives in the State House to make your perspective part of the conversation." }, "benefits": { "header": "What we offer", "youMatter": { "title": "Weigh in on the issues that matter to you", - "bodytext": "Before the Massachusetts state legislature can pass a bill, they convene three committees to hear public testimony on the policy changes that bill proposes. Don't miss out on the chance to make your perspective heard; submit your testimony to use your voice to shape public policy for our Commonwealth." + "bodytext": "Whether the proposed policy will touch you personally or you are an expert in the subject matter of the bill, it is critical that our communities’ needs are shaping the policy going through the State House. MAPLE makes it easy to submit your testimony and use your voice to shape public policy for our Commonwealth." }, "multipleSides": { "title": "Hear from multiple sides and perspectives", - "bodytext": "As a non-partisan platform, all stakeholders can use MAPLE to share their perspective on issues and bills. See what others are saying to get more informed about the most important public policy issues of the day." + "bodytext": "As a non-partisan platform, all stakeholders can use MAPLE to share their perspective on issues and bills. MAPLE allows you to see what other people are saying about public policy issues, which can help inform both your own understanding of the bill and where other members of the Commonwealth are coming from." }, "trustedOrgs": { "title": "See what the organizations you trust are saying", - "bodytext": "Organizations use MAPLE to share their priorities and positions with the world. Look for testimony from the groups you belong to and trust on MAPLE to help you learn about the issues that matter to you." + "bodytext": "Organizations use MAPLE to share their priorities and positions. On MAPLE, you can see how groups you trust are testifying on behalf of a bill and learn more about the issues that matter to you." }, "anyLanguage": { "title": "Read and write testimony in any language", diff --git a/public/locales/en/forlegislators.json b/public/locales/en/forlegislators.json index 4e17c7da6..c1d6168c2 100644 --- a/public/locales/en/forlegislators.json +++ b/public/locales/en/forlegislators.json @@ -5,7 +5,7 @@ "callToAction": { "header": "Why use MAPLE", "title": "Gain free, nonpartisan insight into how constituents are thinking about any proposed measure.", - "bodytextOne": "Even before you create your own account, you can search our lightning-fast platform for all testimony submitted by MAPLE users about any bill. Our advanced features let you filter to bills sponsored by your office, bills concerning your city, or bills that you choose to follow.", + "bodytextOne": "Even before you create an account, you can search all testimony that has been submitted by MAPLE users about any bill, past or present. Our advanced features let you filter to bills sponsored by your office, bills concerning your city, or bills that you choose to follow.", "bodytextTwo": "Why trust MAPLE? We are a nonpartisan, nonprofit, volunteer-developed civic technology initiative focused on increasing engagement between the legislature and its constituents. We offer our product as an open source public good; we are committed not to charging our users and we will never sell the data from our platform." }, "benefits": { @@ -15,12 +15,12 @@ "bodytext": "When you visit any bill page on MAPLE, you can see every piece of testimony submitted by MAPLE users along with their district information. You can easily seek out your own constituents' testimony and see how opinions vary across the Commonwealth." }, "seeTestimony": { - "title": "See testimony on all bills before all committees in one place", - "bodytext": "MAPLE makes it easy for users to submit testimony on any bill, before any committee. Anyone can see testimony on a bill as soon as it is submitted." + "title": "See testimony on all bills, before all committees, in one place", + "bodytext": "MAPLE makes it easier for users to submit testimony on any bill, regardless of what committee it’s in. As soon as someone hits 'publish and send,' that testimony is both made available to the public and can be sent individually to the user’s legislators and the chairs of the relevant Committee." }, "simplifyTestimony": { "title": "Simplify the testimony process for your constituents", - "bodytext": "Do your constituents need assistance finding the right committee, the right chairperson, and the right email address to submit their testimony on a bill? Send them to MAPLE! Our platform makes it easy for anyone to direct their testimony to the right committee chairpersons as well as to their own constituents." + "bodytext": "Do your constituents need assistance finding the right committee, the right chairperson, and the right email address to submit their testimony on a bill? Send them to MAPLE! Our platform makes it easy for anyone to direct their testimony to the right committee chairpersons as well as their own legislators." }, "languageAccess": { "title": "Extend access to constituents who speak any language", diff --git a/public/locales/en/fororgs.json b/public/locales/en/fororgs.json index 2e8d19010..c8d672607 100644 --- a/public/locales/en/fororgs.json +++ b/public/locales/en/fororgs.json @@ -4,44 +4,44 @@ "personaLabel": "Organizations", "callToAction": { "header": "Why use MAPLE", - "title": "Get your message out and rally your supporters in civic discourse.", + "title": "Get your message out and engage your members in civic discourse.", "bodytext": "It takes only a couple minutes to sign up for your organization's account on MAPLE, giving you immediate access to publish and share your testimony, designate your priority bills, connect with your members and followers, and research legislation." }, "benefits": { "header": "What we offer", "reach": { "title": "Help your priorities & positions reach a wider audience", - "bodytext": "Your organization's testimony will be highlighted to MAPLE visitors when they look up bills, with easy tools for sharing on social media. Testimony published on MAPLE will be seen by legislators, residents, members of the media, and other stakeholders. The more organizations share their testimony on the platform, the more people will see it. You can even designate bills as your legislative priorities to signal your focus for each session." + "bodytext": "Testimony published on MAPLE reaches a broad range of stakeholders, from legislators to residents to the media and more. When your organization publishes testimony, the bill pages on MAPLE will highlight that testimony so visitors can quickly see where you stand. You can even designate bills as your legislative priorities to signal your focus for each session. The more that organizations share their testimony, the more people will see it. Your organization's testimony will be highlighted to MAPLE visitors when they look up bills, with easy tools for sharing on social media." }, "connect": { - "title": "Connect with your members, and grow your reach", - "bodytext": "MAPLE users can discover your profile when searching for their policy interests and follow your organization to get updates on your priorities and positions. MAPLE helps alert your constituents to the most important bills on issues you care about, and helps you explain why you support or oppose them" + "title": "Connect with your members and grow your reach", + "bodytext": "MAPLE users can discover your profile when searching for their policy interests and follow your organization to get updates on your priorities and positions. MAPLE works to alert your constituents to the most important bills on the issues relevant to your organization while allowing you room to explain your support or opposition." }, "language": { "title": "Extend access to members who speak any language", - "bodytext": "MAPLE uses machine translation to make the legislative process more accessible to non-native English speakers. Both our website content and the testimonies we post can be automatically translated to any language on demand, meaning that non-English speakers can write testimony in the language in which they are most comfortable and legislative offices can click a button to translate it to English. While machine translation is not perfect, it offers a big step forward in language accessibility for constituents who do not have access to expert translation." + "bodytext": "MAPLE uses machine translation to make the legislative process more accessible to non-native English speakers. Both our website content and the testimonies we post can be automatically translated to any language on demand, meaning that non-English speakers can write testimony in the language in which they are most comfortable and legislative offices can click a button to translate it to English. While machine translation is not perfect, it offers a big step forward in language accessibility for constituents who do not have access to expert translation." }, "coordinate": { "title": "Coordinate legislative communication campaigns with ease", "bodytext": "By linking to a bill page on MAPLE, your constituents can submit their own testimony with just a few clicks. MAPLE makes it easy for users of any experience level to draft, format, and submit their testimony to the right legislators for any bill." }, "seeEveryone": { - "title": "See what everyone's saying", - "bodytext": "Even if you don't sign in, you can use MAPLE to browse testimony submitted on any bill. Get a lay of the land and track public opinion at a glance using our endorse / oppose ratios. Read the full testimony of any user to find other stakeholders you may want to connect with or learn from." + "title": "See what the public is saying", + "bodytext": "Even if you don't sign in, you can use MAPLE to browse testimony submitted on any bill. Get a lay of the land and track public opinion at a glance using our endorse / oppose ratios. Read the full testimony of any user and find other stakeholders who you may want to connect with or learn from." }, "curateHistory": { "title": "Curate your testimony history", - "bodytext": "When you submit your testimony through MAPLE, it automatically appears in an indexed & organized listing on your profile page. Use your profile to automatically generate a record of your lobbying efforts for your website, for member communications, or for semi-annual filings. You can also publish previously submitted tesitmony on MAPLE at any time." + "bodytext": "When you submit your testimony through MAPLE, it automatically appears in an indexed & organized listing on your profile page. Use your profile to automatically generate a record of your lobbying efforts for your website, for member communications, or for semi-annual filings. You can also publish previously submitted testimony on MAPLE at any time." }, "legislativeResearch": { "title": "Speed up your legislative research", - "bodytext1": "MAPLE is fast. (Really fast.). Try", + "bodytext1": "Finding bills on MAPLE is really fast. Try", "linkText": "searching for a bill", "bodytext2": "to see just how much MAPLE can speed up your legislative research process; no sign in required!" }, "changeNorms": { "title": "Help change the norms in Massachusetts", - "bodytext": "The Commonwealth can be a leader in legislative transparency and good government. Help shift the norms in Massachusetts by making your testimony available for anyone to read." + "bodytext": "The Commonwealth can be a leader in legislative transparency and good government. As a community, we can help shift the norms in Massachusetts. Making your testimony accessible and centralized supports your members and followers in their own critical civic engagement." }, "signUp": "Ready to sign up for MAPLE?" }, @@ -50,8 +50,8 @@ "title": "Join MAPLE's Coalition to make the 193rd General Court the most transparent in Massachusetts history!", "p1": "Organizations that join MAPLE's Coalition of Lead Organizations (CLO) commit to publishing all of their public testimony submitted to the 193rd General Court through the MAPLE platform. The 193rd General Court is the session that runs from January of 2023 through 2024.", "p2": "In addition to all the benefits outlined above, these organizations will have their profiles featured prominently on the MAPLE website during the two-year legislative session. Any organization can contact MAPLE to be considered for membership in the CLO. Organizations will be prioritized to join the CLO based on the size of the constituency they represent.", - "p3": "Maple is a nonpartisan project and does not take political positions; membership in the CLO does not indicate endorsement of any political position by MAPLE or its parent organization, members, or collaborators.", - "callToAction": "Join the MAPLE coalition-reach out to info@mapletestimony.org" + "p3": "MAPLE is a nonpartisan project and does not take political positions; membership in the CLO does not indicate endorsement of any political position by MAPLE or its parent organization, members, or collaborators.", + "callToAction": "Join the MAPLE coalition — reach out to info@mapletestimony.org" }, "cta": { "headline": "Get started with MAPLE today!", diff --git a/public/locales/en/goalsandmission.json b/public/locales/en/goalsandmission.json index e78755867..4a49b42b5 100644 --- a/public/locales/en/goalsandmission.json +++ b/public/locales/en/goalsandmission.json @@ -6,14 +6,14 @@ "overview": "MAPLE is a free public platform for submitting and reading testimony on Massachusetts legislation. We aim to achieve these goals:", "increase": "Increase access to legislative information and opportunities to contribute to the legislative process.", "engage": "Engage a wider set of stakeholders and perspectives in policy-making.", - "strengthen": "Strengthen the relationships between constituents and legislators.", + "strengthen": "Strengthen the relationships between constituents and legislators.", "encourage": "Encourage common understanding and illuminate consensus." }, "mission": { "title": "Our Mission", "overview": "Make it easy to meaningfully fulfill your civic responsibilities in Massachusetts", "connect": "MAPLE seeks to better connect its constituents to one another, and to our legislators. We hope to create a space for you to meaningfully engage in state government, learn about proposed legislation that impacts our lives in the Commonwealth, and share your expertise and stories. MAPLE aims to meaningfully channel and focus your civic energy towards productive actions for our state and local communities.", - "disclosure": "We believe it's important for everyone in the Commonwealth to have access to the same information about public policy that our legislators consider when writing law. Prior to a ", + "disclosure": "We believe it's important for everyone in the Commonwealth to have access to the same information about public policy that our legislators consider when writing law. Prior to a ", "disclosureLink": "rules change ", "disclosure2": "in June, 2025, the MA legislature (formally known as “The General Court”) had no policy or obligation to disclose the written testimony they received to the public. As a result, it has been difficult to understand what communications and perspectives are informing our legislators' decisions. Often, even members of the legislature have not been able to easily access the public testimony given on a bill.", "callout": "MAPLE seeks to fill this void.", diff --git a/public/locales/en/learn.json b/public/locales/en/learn.json index 347243eb0..439bbdc86 100644 --- a/public/locales/en/learn.json +++ b/public/locales/en/learn.json @@ -40,27 +40,27 @@ { "badge": "who", "headline": "Anyone can submit testimony", - "body": "Legislators value testimony most when it comes from their own constituents. Testimony from Massachusetts residents is typically directed to both the committee responsible for the bill and the legislators representing you in your district." + "body": "Every Massachusetts resident has the right to weigh in on the proposed laws that shape our future. Testimony is typically directed to both the committee responsible for the bill and the legislators representing you in your district." }, { "badge": "what", "headline": "It should be distinctive and relevant", - "body": "Writing your own text and explaining why you are interested in an issue gives legislators valuable, personalized insight into their constituents. Form letters still count, but a personalized, individual letter will always have greater impact." + "body": "Writing your own words and explaining why you are interested in an issue gives legislators valuable, personalized insight into their constituents. Form letters still count, but a personal letter will always have greater impact." }, { "badge": "when", - "headline": "Submit before the hearing date", - "body": "Committees generally accept testimony up until the public hearing date for a bill. You can use Maple's bill pages on this site or this link to identify relevant dates. Submit before the hearing for the greatest impact." + "headline": "Get it in before the hearing date", + "body": "Committees generally accept testimony up until a bill's public hearing date, but earlier is always better. While some committees will accept testimony after this date, submitting prior to the hearing date ensures it will be considered. In addition to written testimony, you can also attend a hearing to testify in person." }, { "badge": "where", - "headline": "Use MAPLE to submit testimony", - "body": "Browse our bill and ballot question pages to find the measures that matter to you. Select one and follow the prompts to compose your testimony. When you are happy with your testimony, MAPLE sends it to the relevant committee personnel." + "headline": "Write to the committee chairs", + "body": "This is what MAPLE was built for! MAPLE not only makes it easy to learn about bills that impact you but it also identifies the correct legislators for you to email and makes your testimony available for all to benefit from. Some committees accept online submissions via malegislature.gov but not all. Emails and letters are always accepted." } ], "why": { - "headline": "Legislators need your input in order to represent you", - "body": "If you don't share your perspective, it may not be taken into account when policymakers make decisions about the laws that govern all our lives. By speaking up, you can make the laws of Massachusetts work better for all of us. MAPLE is designed to make it more accessible.", + "headline": "The key role of testimony is to let your legislators know how you feel about an issue", + "body": "If you don't share your perspective, it can't be taken into account when policymakers make decisions about the laws that govern our lives. By speaking up, you can make the laws of Massachusetts work better for all of us. MAPLE is designed to make this process more accessible.", "mattersHeading": "Why it Matters:", "matters": [ { @@ -84,7 +84,7 @@ "steps": [ { "title": "Testify in writing", - "body": "Written testimony should target bills being considered by the legislature. All Committees should formally accept testimony on each bill in the time between the start of the legislative session and the public hearing of that bill. This is what MAPLE can help with!" + "body": "Written testimony should target bills being considered by the legislature. You can submit testimony by email, letter, or in some cases, a webform on the MA Legislature website. Hearings marked “written testimony only” do not accept webform submissions but emails are always accepted and that's what MAPLE can help with!" }, { "title": "Testify in person", @@ -102,40 +102,41 @@ } ] }, - "tipsLink": "Need help? <0>Read our testimony writing tips", + "tipsLink": "Ready to write? <0>Read our testimony writing tips", "tips": [ { "label": "Be Timely", "body": "Written testimony should target bills being considered by the legislature and should be submitted before the committee hearing date in order to have the most impact." - }, + }, { "label": "Be Original", "body": "Legislators receive a lot of form letters. These communications are important, but almost always an individual and personalized letter will have greater impact." - }, + }, { "label": "Be Informative", - "body": "Introduce yourself. Explain why you are concerned about an issue and why you think one policy choice would be better than another. Whether you're a longtime advocate or first-time testifier, your testimony is important." + "body": "Be sure to introduce yourself, share why you are concerned about an issue, and why you think one policy choice would be better than another. Whether you're a longtime advocate or first-time testifier, your testimony is important." }, + { "label": "Be Direct", "body": "State whether you support or oppose a bill. Be clear and specific about the policies you want to see. You don't need to know specific legal precedents or legislative language to provide valuable input." }, { "label": "Be Respectful", - "body": "No matter how strongly you hold your position, there may be people of good intent who feel differently. Respectful testimony will carry more weight with legislators — especially those you may need to persuade." + "body": "No matter how strongly or sincerely you hold your position, there will be people of good intent who feel differently. We all deserve to have our perspectives considered. Respectful testimony will carry more weight with legislators — especially those you may need to persuade." } ] }, "writingTips": { "breadcrumb": "Writing Effective Testimony", "title": "How to Write Effective Testimony", - "subhead": "You don't need legal training or a policy background. You need a clear position, a reason you care, and the district you live in — this page walks through the rest.", + "subhead": "Submitting testimony doesn’t require a law degree or a policy background — only a clear position, a reason you care, and the district you live in. This page provides some initial guidance to help you get started.", "principles": { - "headline": "Five things to make testimony impactful" + "headline": "Five ways to make testimony more impactful" }, "include": { "headline": "What every letter should include", - "intro": "Make sure a reader can answer these five questions after reading your testimony.", + "intro": "Make sure a reader can understand these five things after reading your testimony.", "items": [ { "title": "Which bill you are writing about", @@ -191,9 +192,9 @@ "num": "01", "title": "A Bill is Introduced", "lead": "Every law starts as an idea. Any Massachusetts resident can petition their legislator to file a bill.", - "body": "Bills can be filed at any time during the two-year session, but most are filed before the session begins — the standard deadline falls in December of the preceding year. Bills filed after that deadline require special approval. They receive a unique identifier — H. for House-originated bills, S. for Senate. The same idea can be filed in both chambers simultaneously. Bills are publicly available on the MA Legislature website and through MAPLE.", + "body": "Nearly all bills filed in the Massachusetts Legislature are filed in the first two weeks of the session. This means that, every other year, the first two to three weeks of January see thousands of bills being filed in the House and Senate. Bills filed after that initial deadline require special committee approval. When a bill is filed, it receives a unique identifier — H. for House-originated bills, S. for Senate. The same idea can be filed in both chambers at the same time. Bills are publicly available on the MA Legislature website and through MAPLE. On MAPLE, you can track the progress of a bill across iterations and even if the bill changes identifiers.", "stat": { - "value": "6,000+", + "value": "7,000+", "label": "bills filed each session", "href": "/bills" } @@ -202,7 +203,7 @@ "num": "02", "title": "Committee Referral", "lead": "Every bill is routed to a Joint Committee based on its subject matter.", - "body": "There are 33 Joint Legislative Committees covering topics from Education to Public Safety. Joint Committees include members from both the House and Senate. This is where the real work of examining a bill begins. Every bill is entitled to a hearing — but most bills are sent to study after that hearing, which ends their progress for that session.", + "body": "There are 33 Joint Legislative Committees in Massachusetts covering different subject areas from Education to Public Safety. Joint Committees include members from both the House and Senate. Every bill is entitled to a hearing where public testimony can be considered. Committee staff review the incoming bills and group related measures onto a single hearing agenda and set a hearing date.", "stat": { "value": "33", "label": "Joint Committees", @@ -213,14 +214,14 @@ "num": "03", "title": "Public Hearing & Testimony", "lead": "This is your moment. Anyone can submit written testimony or speak in person.", - "body": "Committees schedule public hearings where citizens, advocacy groups, experts, and government officials can all testify. Written testimony submitted before the hearing is accepted and reviewed by committee staff. Legislators pay attention to volume — and especially to constituent voices from their own districts.", + "body": "Public hearings are the time when citizens, advocacy groups, experts, and government officials can all testify. Written testimony submitted before the hearing is reviewed by committee staff. Legislators pay special attention to constituent voices from their own districts, but also pay attention to volume.", "callout": "MAPLE makes it easy to find a bill's hearing date and submit testimony directly to the committee chairs via email." }, { "num": "04", "title": "Committee Report", "lead": "The committee decides a bill's fate — advance, amend, or let it die.", - "body": "After reviewing testimony and staff analysis, the committee votes whether to report the bill favorably, unfavorably, or with amendments. A bill reported favorably advances to the full chamber. Most bills are 'sent to study' — a formal committee recommendation that ends the bill's progress for that session. A bill can be refiled in the next session and go through the process again." + "body": "After reviewing testimony and staff analysis, the committee votes whether to report the bill favorably, unfavorably, or with amendments. A bill reported favorably advances to the full chamber. Most bills are 'sent to study', which is a formal committee recommendation that ends the bill's progress for that session. A bill can be refiled in the next session and go through the process again." }, { "num": "05", @@ -232,7 +233,7 @@ "num": "06", "title": "Governor's Action", "lead": "The Governor has 10 days to sign, veto, or return a bill with amendments.", - "body": "A signed bill is 'chaptered' and becomes part of Massachusetts General Laws. A vetoed bill can be overridden by a 2/3 vote in each chamber. If the Governor returns the bill with amendments, the legislature can accept the amendments, override them, or let the bill die. If the Governor takes no action within 10 days and the legislature is still in session, the bill becomes law automatically. If the legislature has adjourned, the same inaction kills the bill — this is called a pocket veto." + "body": "A bill signed by the governor is ‘chaptered’ and becomes part of the Massachusetts General Laws. If the governor vetoes a bill, that veto can be overridden by a 2/3 vote in each chamber. If the governor returns the bill with amendments, the legislature can accept the amendments, override them, or let the bill die. If the governor takes no action within 10 days and the legislature is still in session, the bill becomes law automatically. If the legislature has adjourned, the same inaction kills the bill, which is sometimes referred to as a pocket veto." } ] } diff --git a/public/locales/en/mapleAI.json b/public/locales/en/mapleAI.json index 3bb7b11da..bdb6223ec 100644 --- a/public/locales/en/mapleAI.json +++ b/public/locales/en/mapleAI.json @@ -29,8 +29,8 @@ "desc1Title": "Limitations", "desc1Main": "While we believe AI significantly enhances your experience on MAPLE, it has limitations. AI-generated summaries and tags are based on limited information about the bill in question and existing law and may not always capture every nuance or recent events. Legislative information is also complex and can use archaic or legal language that is difficult for AI models to properly understand and summarize. Transcriptions of oral hearings can be flawed and may lack context not present in the audio recording. Finally, like us humans, AI can make mistakes in both understanding and explaining policies.", "desc2Title": "Risks", - "desc2Main": "If the bill summaries produced by AI models are wrong or misleading, they present a risk of misinforming the public about legislative proposals. We have mitigated risk by testing and iterating on the implementation of our summarization system in collaboration with academic researchers at Boston University’s Spark! program. While we cannot guarantee that the system will never make mistakes, we have found it to be generally accurate and helpful in the summaries it provides. Likewis, we have reviewed the reliability of our AI transcription service and believe that it meets current industry standards in accuracy and reliability, but know that it can have errors in transcription that could introduce confusion for users relying on them to understand the discussion at the hearing.", - "desc3Title": "Data privacy", + "desc2Main": "If the bill summaries produced by AI models are wrong or misleading, they present a risk of misinforming the public about legislative proposals. We have mitigated risk by testing and iterating on the implementation of our summarization system in collaboration with academic researchers at Boston University’s Spark! program. While we cannot guarantee that the system will never make mistakes, we have found it to be generally accurate and helpful in the summaries it provides. Likewise, we have reviewed the reliability of our AI transcription service and believe that it meets current industry standards in accuracy and reliability, but know that it can have errors in transcription that could introduce confusion for users relying on them to understand the discussion at the hearing.", + "desc3Title": "Data Privacy", "desc3Pre": "We only run", "desc3Italic": "publicly available", "desc3Main": "information through AI models and do not expose any private or sensitive data to AI models or third party APIs. Data we do pass through AI models includes bill and law text and oral hearing recordings, all of which is already previously published by the legislature. In the future, we may also use AI to summarize testimony documents that you publish to MAPLE, but only testimony that you have chosen to display publicly. Like other information on public websites, this text is already available to be accessed and processed by any search engine, web crawler, or AI model." From 2919c33858480c0572662b37ffebba73289351b7 Mon Sep 17 00:00:00 2001 From: Sam DeMarrais Date: Tue, 28 Jul 2026 20:27:48 -0400 Subject: [PATCH 095/106] Pass through bucketName (#2208) --- functions/src/events/HearingScraper.ts | 20 +++++++++++++++---- .../backfillHearingTranscription.ts | 6 ++++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/functions/src/events/HearingScraper.ts b/functions/src/events/HearingScraper.ts index 5e65286eb..7e19f8bac 100644 --- a/functions/src/events/HearingScraper.ts +++ b/functions/src/events/HearingScraper.ts @@ -138,9 +138,11 @@ export class HearingPostProcessor { | FirebaseFirestore.DocumentSnapshot, { limit, + bucketName, refetch = false }: { limit?: number + bucketName?: string refetch?: boolean } ): Promise { @@ -185,7 +187,9 @@ export class HearingPostProcessor { videos = data.videos } - let nextVideos = await this.submitNextTranscription(eventId, videos) + let nextVideos = await this.submitNextTranscription(eventId, videos, { + bucketName + }) count += 1 if (!nextVideos) return false while (nextVideos) { @@ -196,7 +200,9 @@ export class HearingPostProcessor { if (limit && count >= limit) { return true } - nextVideos = await this.submitNextTranscription(eventId, nextVideos) + nextVideos = await this.submitNextTranscription(eventId, nextVideos, { + bucketName + }) count += 1 } return true @@ -204,7 +210,12 @@ export class HearingPostProcessor { async submitNextTranscription( eventId: number, - videos: Video[] + videos: Video[], + { + bucketName + }: { + bucketName?: string + } = {} ): Promise { const nextTranscription = videos.findIndex(item => !item.transcriptionId) if (nextTranscription < 0) { @@ -212,7 +223,8 @@ export class HearingPostProcessor { } const result = await assemblyAI().submitTranscription({ EventId: eventId, - videoUrl: videos[nextTranscription].url + videoUrl: videos[nextTranscription].url, + bucketName: bucketName }) if (result.status === ("error" as const)) { functions.logger.error(`Error during ${result.type}: ${result.error}`) diff --git a/scripts/firebase-admin/backfillHearingTranscription.ts b/scripts/firebase-admin/backfillHearingTranscription.ts index c14bc7d5e..fc004a2b8 100644 --- a/scripts/firebase-admin/backfillHearingTranscription.ts +++ b/scripts/firebase-admin/backfillHearingTranscription.ts @@ -24,7 +24,8 @@ export const script: Script = async ({ db, args }) => { try { const hearingProcessor = new HearingPostProcessor() const changed = await hearingProcessor.addVideosToHearing(doc, { - refetch: refetchVideos + refetch: refetchVideos, + bucketName: bucketName }) if (changed) { console.log(`Transcriptions submitted for hearing ${eventId}`) @@ -52,7 +53,8 @@ export const script: Script = async ({ db, args }) => { try { const hearingProcessor = new HearingPostProcessor() const changed = await hearingProcessor.addVideosToHearing(doc, { - refetch: refetchVideos + refetch: refetchVideos, + bucketName: bucketName }) if (changed) { From f5c8005ae1e0a12f4512c5202fe6fc6ed15156c7 Mon Sep 17 00:00:00 2001 From: Janice Lichtman Date: Tue, 28 Jul 2026 20:36:49 -0400 Subject: [PATCH 096/106] Finance scrapper (#2209) * added committee lifecycle reports to the exclusion list to prevent double-counting with bank reports * langauage clarification for handling un-itemized totals * Added election cycle start balance to firestore * removed non-contribution receipts from total received on finance tab --- .../TabComponents/FinanceTab.tsx | 9 ---- components/db/membersFinance.ts | 5 -- functions/src/ocpf/scrapeOcpfFinance.ts | 54 +++++++++++++++---- functions/src/ocpf/types.ts | 12 +++-- public/locales/en/legislators.json | 1 - 5 files changed, 52 insertions(+), 29 deletions(-) diff --git a/components/LegislatorProfile/TabComponents/FinanceTab.tsx b/components/LegislatorProfile/TabComponents/FinanceTab.tsx index a88f97112..18a5ce105 100644 --- a/components/LegislatorProfile/TabComponents/FinanceTab.tsx +++ b/components/LegislatorProfile/TabComponents/FinanceTab.tsx @@ -57,7 +57,6 @@ export function FinanceTab({ finance }: { finance?: MembersFinance }) { .sort((a, b) => b.value - a.value) const total = categories.reduce((sum, c) => sum + c.value, 0) - const nonContribution = finance.otherReceipts?.nonContribution?.amount ?? 0 const processingFees = finance.breakdown?.processingFees?.amount ?? 0 const smallDonorTotal = @@ -272,14 +271,6 @@ export function FinanceTab({ finance }: { finance?: MembersFinance }) {
)} - {nonContribution > 0 && ( -

- {t("finance.otherReceipts", { - amount: formatCurrency(nonContribution) - })} -

- )} - {processingFees > 0 && (

{t("finance.processingFees", { diff --git a/components/db/membersFinance.ts b/components/db/membersFinance.ts index 7b01ae19a..b2196d37e 100644 --- a/components/db/membersFinance.ts +++ b/components/db/membersFinance.ts @@ -38,10 +38,6 @@ export interface MembersFinanceInKind { unitemized: { amount: number } } -export interface MembersFinanceOtherReceipts { - nonContribution: FinanceBreakdownEntry -} - export interface MembersFinanceYearData { totalRaised: number totalSpent: number @@ -65,7 +61,6 @@ export interface MembersFinance { breakdown: MembersFinanceBreakdown candidateFunds: MembersFinanceCandidateFunds inKind: MembersFinanceInKind - otherReceipts: MembersFinanceOtherReceipts years: Record } diff --git a/functions/src/ocpf/scrapeOcpfFinance.ts b/functions/src/ocpf/scrapeOcpfFinance.ts index d4a18409a..bb39e1edc 100644 --- a/functions/src/ocpf/scrapeOcpfFinance.ts +++ b/functions/src/ocpf/scrapeOcpfFinance.ts @@ -13,7 +13,6 @@ import { MembersFinanceBreakdown, MembersFinanceCandidateFunds, MembersFinanceInKind, - MembersFinanceOtherReceipts, MembersFinanceYearData } from "./types" @@ -37,6 +36,18 @@ const YEAR_END_REPORT_TYPE_IDS = new Set([ 113 // Year-End Report (Municipal) ]) +// Committee lifecycle reports: like Year-End reports, these roll up a date +// range already covered by periodic Bank Reports (verified empirically — +// CPF 16576's Dissolution Report exactly matched the sum of two Bank Reports +// covering the same period, $1,583.64). Kept separate from +// YEAR_END_REPORT_TYPE_IDS because their date range doesn't align to a +// calendar year, so they shouldn't feed the yearEndCheck reconciliation. +const LIFECYCLE_REPORT_TYPE_IDS = new Set([ + 12, // Dissolution Report + 14, // Transition-Out Report + 15 // Transition-In Report +]) + // ── Accumulator types ───────────────────────────────────────────────────────── interface MutableBreakdownEntry { @@ -50,6 +61,8 @@ interface MemberAccumulator { totalSpent: number cashOnHand: number cashOnHandEndDateMs: number // End_Date (as ms) of the most recent Bank Report (type 70) seen + startBalance: number + startBalanceStartDateMs: number // Start_Date (as ms) of the earliest Bank Report (type 70) seen depositEndDateMs: number // End_Date (as ms) of the most recent Deposit Report (type 60) seen contributorCount: number breakdown: { @@ -70,9 +83,6 @@ interface MemberAccumulator { union: MutableBreakdownEntry unitemized: { amount: number } } - otherReceipts: { - nonContribution: MutableBreakdownEntry - } years: Record< string, { @@ -117,6 +127,8 @@ function newAccumulator(cpfId: number): MemberAccumulator { totalSpent: 0, cashOnHand: 0, cashOnHandEndDateMs: 0, + startBalance: 0, + startBalanceStartDateMs: Infinity, depositEndDateMs: 0, contributorCount: 0, breakdown: { @@ -134,7 +146,6 @@ function newAccumulator(cpfId: number): MemberAccumulator { union: emptyEntry(), unitemized: { amount: 0 } }, - otherReceipts: { nonContribution: emptyEntry() }, years: Object.fromEntries(YEARS.map(y => [y, yearInit()])), yearEndCheck: Object.fromEntries(YEARS.map(y => [y, null])) } @@ -280,6 +291,7 @@ export const scrapeOcpfFinance = functions totalRaised: acc.totalRaised, totalSpent: acc.totalSpent, cashOnHand: acc.cashOnHand, + startBalance: acc.startBalance, contributorCount: acc.contributorCount, lastUpdated: now, bankDataAsOf: Timestamp.fromMillis(acc.cashOnHandEndDateMs), @@ -287,7 +299,6 @@ export const scrapeOcpfFinance = functions breakdown: acc.breakdown as MembersFinanceBreakdown, candidateFunds: acc.candidateFunds as MembersFinanceCandidateFunds, inKind: acc.inKind as MembersFinanceInKind, - otherReceipts: acc.otherReceipts as MembersFinanceOtherReceipts, years: Object.fromEntries( Object.entries(acc.years).map(([y, yd]) => [ y, @@ -329,7 +340,9 @@ const REPORT_COLUMN_ALIASES: Record = { receiptsTotal: ["receipts_total"], receiptsUnitemizedTotal: ["receipts_unitemized_total"], expendituresTotal: ["expenditures_total"], + startBalance: ["start_balance"], endBalance: ["end_balance"], + startDate: ["start_date"], endDate: ["end_date"] } @@ -401,6 +414,7 @@ async function parseReports( const receiptsUnitemized = parseFloat(col(cols, idx.receiptsUnitemizedTotal)) || 0 const expendituresTotal = parseFloat(col(cols, idx.expendituresTotal)) || 0 + const startBalance = parseFloat(col(cols, idx.startBalance)) || 0 const endBalance = parseFloat(col(cols, idx.endBalance)) || 0 let acc = accumulators.get(memberCode) @@ -419,6 +433,13 @@ async function parseReports( continue } + if (LIFECYCLE_REPORT_TYPE_IDS.has(reportTypeId)) { + // Dissolution/Transition rollup — same double-counting risk as Year-End, + // but not tied to a calendar year, so skipped without feeding yearEndCheck. + matched++ + continue + } + // Credit Card (type 80) and Reimbursement (type 90) reports are supplemental: // they itemize spending that the depository bank already captured as bank // transactions in the monthly Bank Report's Expenditures_Total. Verified @@ -460,6 +481,14 @@ async function parseReports( acc.cashOnHand = endBalance acc.cashOnHandEndDateMs = endDateMs } + // Start_Balance from the Bank Report with the earliest Start_Date, i.e. cash + // on hand at the start of the election cycle. + const startDate = col(cols, idx.startDate) + const startDateMs = startDate ? new Date(startDate).getTime() : Infinity + if (reportTypeId === 70 && startDateMs < acc.startBalanceStartDateMs) { + acc.startBalance = startBalance + acc.startBalanceStartDateMs = startDateMs + } // Deposit Reports are filed more frequently than Bank Reports, so this // date is normally later — tracked to show readers why the Contributions // Breakdown (sourced from Deposit Reports) and Total Raised (sourced from @@ -565,8 +594,15 @@ function accumulateItem( case 203: // Union/Association Contribution addTo(acc.breakdown.union, yb?.union) break - case 204: // Non-contribution receipt - addTo(acc.otherReceipts.nonContribution) + case 204: // Non-contribution receipt (refunds, misc.) — not real fundraising. + // Bank Report Receipts_Total (summed into totalRaised in parseReports) + // includes this cash since it did hit the bank, but OCPF's own public + // "Receipts" figure nets it out. Subtract here, once identified, to + // match that definition. Verified empirically against ocpf.us: for + // CPF 16883 (Rausch), totalRaised minus this exact amount ($101.39) + // matched the site's displayed 2026 YTD Receipts to the penny. + acc.totalRaised -= amount + if (acc.years[year]) acc.years[year].totalRaised -= amount break case 206: // Candidate Loan case 331: // Out-of-pocket expense (as loan) @@ -590,7 +626,7 @@ function accumulateItem( case 319: // Payment-processor fee (see breakdown.processingFees doc comment) addTo(acc.breakdown.processingFees, yb?.processingFees) break - // 205 (Bank Interest) and 220 (Aggregated un-itemized) come from reports.txt, not items + // 205 (Bank Interest) and 220 (Aggregated un-itemized) totals sourced from reports.txt, not items default: break } diff --git a/functions/src/ocpf/types.ts b/functions/src/ocpf/types.ts index 5ae323d1d..89b5b1b1f 100644 --- a/functions/src/ocpf/types.ts +++ b/functions/src/ocpf/types.ts @@ -62,10 +62,6 @@ export interface MembersFinanceInKind { unitemized: { amount: number } // type 420 } -export interface MembersFinanceOtherReceipts { - nonContribution: FinanceBreakdownEntry // type 204 -} - export interface MembersFinanceYearData { totalRaised: number totalSpent: number @@ -76,9 +72,16 @@ export interface MembersFinanceYearData { // Firestore: /generalCourts/{court}/membersFinance/{memberCode} export interface MembersFinance { ocpfCpfId: number + // Net receipts: Bank Report Receipts_Total, minus non-contribution + // receipts (type 204 — refunds/misc from Deposit Reports). Matches OCPF's own public + // "Receipts" definition. totalRaised: number totalSpent: number cashOnHand: number + // Start_Balance of the earliest Bank Report (type 70) in the tracked window, + // i.e. cash on hand at the start of the current election cycle. + // TODO: Surface this on the Finance tab. + startBalance: number contributorCount: number // count of type-201 rows (row = one itemized contribution) lastUpdated: FirebaseFirestore.Timestamp // End_Date of the most recent Bank Report (type 70) — the basis for totalRaised/cashOnHand. @@ -90,6 +93,5 @@ export interface MembersFinance { breakdown: MembersFinanceBreakdown candidateFunds: MembersFinanceCandidateFunds inKind: MembersFinanceInKind - otherReceipts: MembersFinanceOtherReceipts years: Record } diff --git a/public/locales/en/legislators.json b/public/locales/en/legislators.json index 0714b478b..c422d3e01 100644 --- a/public/locales/en/legislators.json +++ b/public/locales/en/legislators.json @@ -20,7 +20,6 @@ "electionCycleHeading": "{{year}} Election Cycle", "noContributions": "No itemized contributions recorded yet.", "noData": "Finance data not yet available for this legislator.", - "otherReceipts": "Other non-contribution receipts: {{amount}} (refunds and miscellaneous receipts — not included above)", "processingFees": "Category amounts above are shown before {{amount}} in payment-processing fees deducted, which is why they total more than \"Total Raised\" above.", "source": "Source: MA OCPF · ocpf.us", "sourceWithDate": "Source: MA OCPF · ocpf.us\nIncludes contributions through {{date}}", From c46d5c15196cdbc9f91baf677d6600e0319e6f26 Mon Sep 17 00:00:00 2001 From: Sam DeMarrais Date: Tue, 28 Jul 2026 22:11:25 -0400 Subject: [PATCH 097/106] Election bugfix --- functions/src/legislators/scrapeElections.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/functions/src/legislators/scrapeElections.ts b/functions/src/legislators/scrapeElections.ts index fcbe7dbdf..fe7cb22e9 100644 --- a/functions/src/legislators/scrapeElections.ts +++ b/functions/src/legislators/scrapeElections.ts @@ -79,7 +79,7 @@ async function fetchElectionData( if (error.message.includes("Could not parse CSS stylesheet")) { return } - console.error(error) + logger.error(error) }) const dom = new JSDOM(text, { virtualConsole }) @@ -102,7 +102,7 @@ async function fetchElectionData( headers.reverse() cells.reverse() headers.forEach((header, i) => { - const text = cells[i].textContent?.replace(/,/g, "").trim() + const text = cells[i]?.textContent?.replace(/,/g, "").trim() if (text && /^\d+$/.test(text)) { values.set(header, parseInt(text)) } From c571307ac48a3a8d3a1aab0ebb0f65d262ad39d1 Mon Sep 17 00:00:00 2001 From: Eric Pastor <38719351+ericpastorm@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:38:51 +0200 Subject: [PATCH 098/106] Link legislator tabs to URL hashes --- .../LegislatorProfile/LegislatorTabs.test.tsx | 141 ++++++++++++++++++ .../LegislatorProfile/LegislatorTabs.tsx | 70 ++++++--- 2 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 components/LegislatorProfile/LegislatorTabs.test.tsx diff --git a/components/LegislatorProfile/LegislatorTabs.test.tsx b/components/LegislatorProfile/LegislatorTabs.test.tsx new file mode 100644 index 000000000..39cb90de6 --- /dev/null +++ b/components/LegislatorProfile/LegislatorTabs.test.tsx @@ -0,0 +1,141 @@ +import "@testing-library/jest-dom" +import { fireEvent, render, screen } from "@testing-library/react" + +import { LegislatorTabs } from "./LegislatorTabs" + +const push = jest.fn().mockResolvedValue(true) +let mockRouter = { + asPath: "/legislators/194/ABC1", + isReady: true, + push +} + +jest.mock("next/router", () => ({ + useRouter: () => mockRouter +})) + +jest.mock("next-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => + ({ + "tabs.priorities": "Priorities", + "tabs.bills": "Bills", + "tabs.elections": "Elections", + "tabs.finance": "Campaign Finance", + "tabs.district": "District", + "tabs.testimony": "Testimony", + "tabs.votes": "Votes" + }[key] ?? key) + }) +})) + +jest.mock("./TabComponents/PrioritiesTab", () => ({ + PrioritiesTab: () => null +})) +jest.mock("./TabComponents/BillsTab", () => ({ + BillsTab: () => null +})) +jest.mock("./TabComponents/ElectionsTab", () => ({ + ElectionsTab: () => null +})) +jest.mock("./TabComponents/FinanceTab", () => ({ + FinanceTab: () => null +})) +jest.mock("./TabComponents/DistrictTab", () => ({ + DistrictTab: () => null +})) +jest.mock("./TabComponents/TestimonyTab", () => ({ + TestimonyTab: () => null +})) +jest.mock("./TabComponents/VotesTab", () => ({ + VotesTab: () => null +})) + +const renderTabs = () => + render() + +describe("LegislatorTabs", () => { + beforeEach(() => { + push.mockClear() + mockRouter = { + asPath: "/legislators/194/ABC1", + isReady: true, + push + } + }) + + it("selects the tab from the URL hash after the router is ready", () => { + mockRouter = { + ...mockRouter, + asPath: "/legislators/194/ABC1#elections", + isReady: false + } + const { rerender } = renderTabs() + + expect(screen.getByRole("tab", { name: "Priorities" })).toHaveAttribute( + "aria-selected", + "true" + ) + + mockRouter = { ...mockRouter, isReady: true } + rerender( + + ) + + expect(screen.getByRole("tab", { name: "Elections" })).toHaveAttribute( + "aria-selected", + "true" + ) + }) + + it.each([ + "/legislators/194/ABC1", + "/legislators/194/ABC1#not-a-legislator-tab" + ])("falls back to priorities for %s", asPath => { + mockRouter = { ...mockRouter, asPath } + renderTabs() + + expect(screen.getByRole("tab", { name: "Priorities" })).toHaveAttribute( + "aria-selected", + "true" + ) + }) + + it("adds the selected tab to the URL hash", () => { + renderTabs() + + fireEvent.click(screen.getByRole("tab", { name: "Campaign Finance" })) + + expect( + screen.getByRole("tab", { name: "Campaign Finance" }) + ).toHaveAttribute("aria-selected", "true") + expect(push).toHaveBeenCalledWith( + "/legislators/194/ABC1#finance", + undefined, + { shallow: true, scroll: false } + ) + }) + + it("updates the selected tab when browser history changes the hash", () => { + mockRouter = { + ...mockRouter, + asPath: "/legislators/194/ABC1#elections" + } + const { rerender } = renderTabs() + + expect(screen.getByRole("tab", { name: "Elections" })).toHaveAttribute( + "aria-selected", + "true" + ) + + mockRouter = { ...mockRouter, asPath: "/legislators/194/ABC1#bills" } + rerender( + + ) + + expect(screen.getByRole("tab", { name: "Bills" })).toHaveAttribute( + "aria-selected", + "true" + ) + }) +}) diff --git a/components/LegislatorProfile/LegislatorTabs.tsx b/components/LegislatorProfile/LegislatorTabs.tsx index f0d91c148..2d1c96f90 100644 --- a/components/LegislatorProfile/LegislatorTabs.tsx +++ b/components/LegislatorProfile/LegislatorTabs.tsx @@ -1,4 +1,6 @@ import { useTranslation } from "next-i18next" +import { useRouter } from "next/router" +import { useEffect, useState } from "react" import { TabPane } from "react-bootstrap" import TabContainer from "react-bootstrap/TabContainer" import styled from "styled-components" @@ -21,16 +23,24 @@ import { } from "components/EditProfilePage/StyledEditProfileComponents" import { MembersFinance } from "components/db/membersFinance" -const tabCategory = [ - "priorities", - "bills", - "elections", - "finance", - "district", - "testimony", - "votes" -] -type TabCategories = (typeof tabCategory)[number] +const tabCategories = { + priorities: "priorities", + bills: "bills", + elections: "elections", + finance: "finance", + district: "district", + testimony: "testimony", + votes: "votes" +} as const +type TabCategory = (typeof tabCategories)[keyof typeof tabCategories] + +const isTabCategory = (value?: string | null): value is TabCategory => + Object.values(tabCategories).some(category => category === value) + +const tabCategoryFromPath = (path: string): TabCategory => { + const hash = path.split("#", 2)[1] + return isTabCategory(hash) ? hash : tabCategories.priorities +} const TabNavLink = styled(Nav.Link).attrs(props => ({ className: `rounded-top m-0 p-0 ${props.className}` @@ -69,59 +79,79 @@ export function LegislatorTabs({ districtLoading, legislatorId, name, - tabCategory, finance }: { district?: District | undefined districtLoading?: boolean legislatorId: string name: string - tabCategory?: TabCategories finance?: MembersFinance }) { + const router = useRouter() const { t } = useTranslation("legislators") + const [activeTab, setActiveTab] = useState( + tabCategories.priorities + ) + + useEffect(() => { + if (router.isReady) setActiveTab(tabCategoryFromPath(router.asPath)) + }, [router.asPath, router.isReady]) + + const handleTabSelect = (nextTab: string | null) => { + if (!isTabCategory(nextTab)) return + + setActiveTab(nextTab) + + const [path, currentHash] = router.asPath.split("#", 2) + if (currentHash === nextTab) return + + void router.push(`${path}#${nextTab}`, undefined, { + shallow: true, + scroll: false + }) + } const tabs = [ { title: t("tabs.priorities"), - eventKey: "priorities", + eventKey: tabCategories.priorities, content: }, { title: t("tabs.bills"), - eventKey: "bills", + eventKey: tabCategories.bills, content: }, { title: t("tabs.elections"), - eventKey: "elections", + eventKey: tabCategories.elections, content: }, { title: t("tabs.finance"), - eventKey: "finance", + eventKey: tabCategories.finance, content: }, { title: t("tabs.district"), - eventKey: "district", + eventKey: tabCategories.district, content: }, { title: t("tabs.testimony"), - eventKey: "testimony", + eventKey: tabCategories.testimony, content: }, { title: t("tabs.votes"), - eventKey: "votes", + eventKey: tabCategories.votes, content: } ] return ( - + {tabs.map((t, i) => ( From 6e3215b71b3ae5b873b9b0a23129ce79edd9f535 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:18:52 +0000 Subject: [PATCH 099/106] update README.md --- README.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 4c2752e29..4a143c6d9 100644 --- a/README.md +++ b/README.md @@ -187,94 +187,95 @@ Thanks to all our contributors! Darrel Herbst
Darrel Herbst

💻 Dev1nxavier
Dev1nxavier

💻 + Eric
Eric

💻 Gerlin
Gerlin

💻 Grace (Iron Yard/CaseNEX/IO Account)
Grace (Iron Yard/CaseNEX/IO Account)

💻 🎨 Grace Zhang
Grace Zhang

💻 Huanfeng
Huanfeng

💻 - Ikko Eltociear Ashimine
Ikko Eltociear Ashimine

💻 + Ikko Eltociear Ashimine
Ikko Eltociear Ashimine

💻 J.I. Cruz
J.I. Cruz

💻 Janice Lichtman
Janice Lichtman

💻 Jasmine Rose
Jasmine Rose

💻 Jeff Korenstein
Jeff Korenstein

💻 KY233466
KY233466

💻 Kep Kaeppeler
Kep Kaeppeler

💻 - Kimin Kim
Kimin Kim

💻 👀 + Kimin Kim
Kimin Kim

💻 👀 Kittipong Deevee
Kittipong Deevee

💻 Laura Umana
Laura Umana

💻 Leopoldo Lening Celaya
Leopoldo Lening Celaya

💻 Lexa Michaelides
Lexa Michaelides

💻 Luke Rucker
Luke Rucker

💻 Marcos Banchik
Marcos Banchik

💻 - Mark Trepanier-Cajigas
Mark Trepanier-Cajigas

💻 + Mark Trepanier-Cajigas
Mark Trepanier-Cajigas

💻 Matthew Zagaja
Matthew Zagaja

💻 Mephistic
Mephistic

💻 👀 Mike Yavorsky
Mike Yavorsky

🎨 Miles Baird
Miles Baird

💻 Minqi Chai
Minqi Chai

📓 Nansamba Ssensalo
Nansamba Ssensalo

💻 - Nathan Sanders
Nathan Sanders

💻 💼 🔍 + Nathan Sanders
Nathan Sanders

💻 💼 🔍 Navdeep
Navdeep

💻 Noah Burd
Noah Burd

💻 Oliver Herman
Oliver Herman

💻 Peter Schiller
Peter Schiller

💻 Richard Kwon
Richard Kwon

💻 Ridho Suhendar
Ridho Suhendar

💻 - Riley Grant
Riley Grant

💻 + Riley Grant
Riley Grant

💻 RobertMrowiec
RobertMrowiec

💻 👀 Rodrigo Passos
Rodrigo Passos

💻 👀 Sam DeMarrais
Sam DeMarrais

💻 Samuel BUHAN
Samuel BUHAN

💻 📓 Scott Solmonson
Scott Solmonson

💻 Sean Moss
Sean Moss

💻 - Simran Kaur
Simran Kaur

💻 + Simran Kaur
Simran Kaur

💻 Soundar Murugan
Soundar Murugan

💻 Stacey Ali
Stacey Ali

💻 Tim Blais
Tim Blais

💻 Tom Magnusson
Tom Magnusson

💻 Ujwal Kumar
Ujwal Kumar

💻 VenkateshDevarakonda0706
VenkateshDevarakonda0706

💻 - Veronica Adler
Veronica Adler

💻 + Veronica Adler
Veronica Adler

💻 YuhaoT
YuhaoT

💻 ananyasinghz
ananyasinghz

💻 arutfield
arutfield

💻 bancona
bancona

💻 d.ondrich
d.ondrich

💻 djtanner
djtanner

💻 - ekambains
ekambains

💻 + ekambains
ekambains

💻 iWumboUWumbo2
iWumboUWumbo2

💻 ishana-goyal
ishana-goyal

💻 jamesvas5307
jamesvas5307

🎨 🧑‍🏫 📓 jellyyams
jellyyams

💻 jkinzer85
jkinzer85

📓 mertbagt
mertbagt

💻 - mmailloux22
mmailloux22

💻 + mmailloux22
mmailloux22

💻 mvictor55
mvictor55

💼 📆 🔍 💻 pranay
pranay

💻 ren0nie0
ren0nie0

🔬 roed
roed

💻 sammymyi
sammymyi

🎨 📓 sashamaryl
sashamaryl

💻 🧑‍🏫 🔬 - shannonh800
shannonh800

💻 + shannonh800
shannonh800

💻 violet
violet

💻 yasminekarni
yasminekarni

💻 From 6cfe6b14ca71449654caea1e66805c7afce87174 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:18:53 +0000 Subject: [PATCH 100/106] update .all-contributorsrc --- .all-contributorsrc | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index a4f032588..2159af06e 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -867,6 +867,15 @@ "code", "design" ] + }, + { + "login": "ericpastorm", + "name": "Eric", + "avatar_url": "https://avatars.githubusercontent.com/u/38719351?v=4", + "profile": "https://github.com/ericpastorm", + "contributions": [ + "code" + ] } ], "commitType": "docs" From 1e037fbac45507b96e850765f6ca7f6d51893f46 Mon Sep 17 00:00:00 2001 From: Sam DeMarrais Date: Tue, 18 Aug 2026 19:46:30 -0400 Subject: [PATCH 101/106] Fix vote modal (#2217) * Combine votes modal * Create vote dropdown --- components/hearing/HearingSidebar.tsx | 136 +++++++++++++++++++------- components/hearing/hearing.ts | 69 +++++++++++++ 2 files changed, 170 insertions(+), 35 deletions(-) diff --git a/components/hearing/HearingSidebar.tsx b/components/hearing/HearingSidebar.tsx index 6bedeb78e..7978b5a13 100644 --- a/components/hearing/HearingSidebar.tsx +++ b/components/hearing/HearingSidebar.tsx @@ -4,12 +4,18 @@ import Link from "next/link" import { useCallback, useEffect, useState } from "react" import type { ModalProps } from "react-bootstrap" import styled from "styled-components" -import { Col, Image, Modal, Row } from "../bootstrap" +import { Col, Image, Modal, Row, Dropdown } from "../bootstrap" import { firestore } from "../firebase" import * as links from "../links" import { billSiteURL, Internal } from "../links" import { LabeledIcon } from "../shared" -import { Paragraph, TranscriptData, formatVTTTimestamp } from "./hearing" +import { + CommitteeRecommendation, + CommitteeVote, + CommitteeVoteRecord, + LegislativeMemberSummary, + TranscriptData +} from "./hearing" type Bill = { BillNumber: string @@ -365,9 +371,9 @@ function AgendaBill({ const { t } = useTranslation(["common", "hearing"]) const BillNumber = element.BillNumber const CourtNumber = element.GeneralCourtNumber - const [committeeRecommendations, setCommitteeRecommendations] = useState( - [] - ) + const [committeeActions, setCommitteeActions] = useState< + CommitteeRecommendation[] + >([]) const [settingsModal, setSettingsModal] = useState<"show" | null>(null) const close = () => setSettingsModal(null) @@ -378,21 +384,36 @@ function AgendaBill({ ) const docData = bill.data() - setCommitteeRecommendations(docData?.content.CommitteeRecommendations) + // All recommendations for this bill + let committeeRecommendations: CommitteeRecommendation[] = + docData?.content.CommitteeRecommendations + for (const action of committeeRecommendations) { + action.Votes = action.Votes?.filter( + vote => + vote.Vote && + vote.Vote.length && + vote.Vote.some( + vote => + vote.Adverse?.length || + vote.Favorable?.length || + vote.NoVoteRecorded?.length || + vote.ReserveRight?.length + ) + ) + } + // Recommendations for this bill from the relevant committee (regardless of whether they are in this hearing specifically) + committeeRecommendations = committeeRecommendations.filter( + action => + action.Committee?.CommitteeCode === committeeCode && + action.Votes?.length + ) + setCommitteeActions(committeeRecommendations) }, [BillNumber, CourtNumber]) useEffect(() => { BillNumber && CourtNumber ? hearingBill() : null }, [BillNumber, hearingBill, CourtNumber]) - let committeeActions = [] - - committeeRecommendations - ? (committeeActions = committeeRecommendations.filter( - (action: any) => action.Committee.CommitteeCode === committeeCode - )) - : null - return ( <>

@@ -401,7 +422,7 @@ function AgendaBill({ {BillNumber} {element.Title} - {committeeActions[0]?.Votes[0]?.Question ? ( + {committeeActions.length > 0 ? (
- setSettingsModal(null)} - show={settingsModal === "show"} - /> + {committeeActions.length > 0 ? ( + setSettingsModal(null)} + show={settingsModal === "show"} + /> + ) : null} ) } type Props = Pick & { BillNumber: string - committeeActions: any + committeeActions: CommitteeRecommendation[] CourtNumber: number generalCourtNumber: string | null onSettingsModalClose: () => void @@ -446,6 +469,25 @@ function VotesModal({ show }: Props) { const { t } = useTranslation(["common", "editProfile", "hearing"]) + const votes: CommitteeVote[] = [] + const [selectedVote, setSelectedVote] = useState(0) + for (const action of committeeActions) { + for (const vote of action.Votes!) { + // Merge different vote arrays into [0] + const record = vote.Vote!.reduce((acc, vote) => { + for (const [key, values] of Object.entries(vote) as [ + keyof CommitteeVoteRecord, + LegislativeMemberSummary[] + ][]) { + acc[key] ??= [] + acc[key]!.push(...values) + } + return acc + }, {}) + vote.Vote = [record] + votes.push(vote) + } + } return ( @@ -463,16 +505,40 @@ function VotesModal({ /> -
- {committeeActions[0]?.Votes[0]?.Question} +
+ {votes.length > 1 ? ( + + + {votes[selectedVote].Question} + + + + {votes.map((vote, n) => ( + setSelectedVote(n)} + > + {vote.Question} + + ))} + + + ) : ( + votes[selectedVote].Question + )}
{t("yes", { ns: "hearing" })} ( - {committeeActions[0]?.Votes[0]?.Vote[0]?.Favorable.length}) + {votes[selectedVote]?.Vote![0].Favorable?.length ?? 0})
{generalCourtNumber && - committeeActions[0]?.Votes[0]?.Vote[0]?.Favorable.map( + (votes[selectedVote]?.Vote![0].Favorable ?? []).map( (element: any, index: number) => ( {t("no", { ns: "hearing" })} ( - {committeeActions[0]?.Votes[0]?.Vote[0]?.Adverse.length}) + {votes[selectedVote].Vote![0].Adverse?.length ?? 0})
{generalCourtNumber && - committeeActions[0]?.Votes[0]?.Vote[0]?.Adverse.map( + (votes[selectedVote]?.Vote![0].Adverse ?? []).map( (element: any, index: number) => ( {t("no_vote", { ns: "hearing" })} ( - {committeeActions[0]?.Votes[0]?.Vote[0]?.NoVoteRecorded.length}) + {votes[selectedVote]?.Vote![0].NoVoteRecorded?.length ?? 0})
{generalCourtNumber && - committeeActions[0]?.Votes[0]?.Vote[0]?.NoVoteRecorded.map( + (votes[selectedVote]?.Vote![0].NoVoteRecorded ?? []).map( (element: any, index: number) => ( {t("reserve_right", { ns: "hearing" })} ( - {committeeActions[0]?.Votes[0]?.Vote[0]?.ReserveRight.length}) + {votes[selectedVote]?.Vote![0].ReserveRight?.length ?? 0}) {generalCourtNumber && - committeeActions[0]?.Votes[0]?.Vote[0]?.ReserveRight.map( + (votes[selectedVote]?.Vote![0].ReserveRight ?? []).map( (element: any, index: number) => ( Date: Thu, 20 Aug 2026 19:47:37 -0400 Subject: [PATCH 102/106] fixage and prettier --- components/shared/FollowButton.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/shared/FollowButton.tsx b/components/shared/FollowButton.tsx index ec8e035d3..7a09057f5 100644 --- a/components/shared/FollowButton.tsx +++ b/components/shared/FollowButton.tsx @@ -120,7 +120,7 @@ export const BaseFollowButton = ({ const checkmark = isFollowing ? ( ) : null - const handleClick = (event: React.FormEvent) => { + const handleClick = (event: any) => { event.preventDefault() if (!uid) { const currentPath = router.asPath @@ -134,7 +134,7 @@ export const BaseFollowButton = ({ <> {!hide && (
- From cea5e07f918c623fbc44007ae33eded4a7684f95 Mon Sep 17 00:00:00 2001 From: mertbagt Date: Thu, 20 Aug 2026 19:53:55 -0400 Subject: [PATCH 103/106] moar prettier --- components/testimony/TestimonyDetailPage/PolicyActions.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/components/testimony/TestimonyDetailPage/PolicyActions.tsx b/components/testimony/TestimonyDetailPage/PolicyActions.tsx index ea506f02f..c0733e8e6 100644 --- a/components/testimony/TestimonyDetailPage/PolicyActions.tsx +++ b/components/testimony/TestimonyDetailPage/PolicyActions.tsx @@ -71,7 +71,6 @@ export const PolicyActions: FC> = ({ ? ballotQuestionTopicName(ballotQuestionTopic.court, ballotQuestionTopic.id) : billTopicName(bill.court, bill.id) - const { followStatus, setFollowStatus } = useContext(FollowContext) const [canEditBallotQuestionTestimony, setCanEditBallotQuestionTestimony] = useState(!ballotQuestionId) From e373cecfc0e50354b7254e368aed8d2752037016 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:00:48 +0000 Subject: [PATCH 104/106] update README.md --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4a143c6d9..736d88d17 100644 --- a/README.md +++ b/README.md @@ -242,39 +242,40 @@ Thanks to all our contributors! Simran Kaur
Simran Kaur

💻 Soundar Murugan
Soundar Murugan

💻 Stacey Ali
Stacey Ali

💻 + Stephen Larson
Stephen Larson

💻 Tim Blais
Tim Blais

💻 Tom Magnusson
Tom Magnusson

💻 Ujwal Kumar
Ujwal Kumar

💻 - VenkateshDevarakonda0706
VenkateshDevarakonda0706

💻 + VenkateshDevarakonda0706
VenkateshDevarakonda0706

💻 Veronica Adler
Veronica Adler

💻 YuhaoT
YuhaoT

💻 ananyasinghz
ananyasinghz

💻 arutfield
arutfield

💻 bancona
bancona

💻 d.ondrich
d.ondrich

💻 - djtanner
djtanner

💻 + djtanner
djtanner

💻 ekambains
ekambains

💻 iWumboUWumbo2
iWumboUWumbo2

💻 ishana-goyal
ishana-goyal

💻 jamesvas5307
jamesvas5307

🎨 🧑‍🏫 📓 jellyyams
jellyyams

💻 jkinzer85
jkinzer85

📓 - mertbagt
mertbagt

💻 + mertbagt
mertbagt

💻 mmailloux22
mmailloux22

💻 mvictor55
mvictor55

💼 📆 🔍 💻 pranay
pranay

💻 ren0nie0
ren0nie0

🔬 roed
roed

💻 sammymyi
sammymyi

🎨 📓 - sashamaryl
sashamaryl

💻 🧑‍🏫 🔬 + sashamaryl
sashamaryl

💻 🧑‍🏫 🔬 shannonh800
shannonh800

💻 violet
violet

💻 yasminekarni
yasminekarni

💻 From 2a9df5afb6ea74d34bd40aff175a8cd90a216a10 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:00:49 +0000 Subject: [PATCH 105/106] update .all-contributorsrc --- .all-contributorsrc | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 2159af06e..15eb7194e 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -876,6 +876,15 @@ "contributions": [ "code" ] + }, + { + "login": "slarson", + "name": "Stephen Larson", + "avatar_url": "https://avatars.githubusercontent.com/u/1037756?v=4", + "profile": "http://twitter.com/slars0n", + "contributions": [ + "code" + ] } ], "commitType": "docs" From 1ced6c5d966428822a7bb4838642a2417f654b8f Mon Sep 17 00:00:00 2001 From: Nathan Date: Sun, 23 Aug 2026 18:31:11 -0400 Subject: [PATCH 106/106] fix(lobbying): weekly-scraper cursor exceeds Firestore's 1MB document limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live weekly cursor (scrapers/lobbying) stored the entire processed-URL history and per-registrant summary cache as two fields on a single Firestore document. Firestore documents have a hard 1MB size limit; once run against the full corpus, this document grew past it partway through, and every write after that point failed silently — meaning every registrant processed for the rest of that run (and any future run building on that cursor) was silently skipped rather than captured. Moved to subcollections — scrapers/lobbying/processedUrls/{hash} and scrapers/lobbying/summaryCache/{hash}, one small doc per URL — mirroring the pattern the backfill cursor (scrapers/lobbyingBackfill/processedUrls) already used elsewhere in this same file. Point lookups replace the in-memory set/dict that used to get loaded and saved as one blob on every write. Tested with a small in-memory Firestore fake (tests/test_scrape.py) real enough to simulate write-then-read-back across calls, including a regression test asserting the parent cursor doc never grows beyond a handful of small fields. Confirmed this test fails against the pre-fix code by checking it out and rerunning the suite, then passes again with the fix restored. Also validated live against a project with existing data. Co-Authored-By: Claude Sonnet 4.6 --- lobbying-scraper/scrape.py | 96 +++++++++---- lobbying-scraper/tests/test_scrape.py | 198 ++++++++++++++++++++++++++ lobbying-scraper/writer.py | 2 + 3 files changed, 266 insertions(+), 30 deletions(-) create mode 100644 lobbying-scraper/tests/test_scrape.py diff --git a/lobbying-scraper/scrape.py b/lobbying-scraper/scrape.py index 657c59603..470732c66 100644 --- a/lobbying-scraper/scrape.py +++ b/lobbying-scraper/scrape.py @@ -38,43 +38,79 @@ from writer import ( BACKFILL_DOC, BACKFILL_URLS_COLLECTION, + PROCESSED_URLS_COLLECTION, SCRAPER_DOC, + SUMMARY_CACHE_COLLECTION, write_filings, write_registrant, ) # ── Cursor helpers ──────────────────────────────────────────────────────────── +# +# Weekly-mode cursor state lives in subcollections under SCRAPER_DOC, one small +# doc per URL, mirroring the backfill cursor below. An earlier version stored +# the entire processed-URL history and summary cache as two fields on a single +# document; that doc grew past Firestore's 1MB limit once run against the full +# corpus, silently failing (and thus skipping) every registrant processed +# after the limit was hit. Per-URL docs have no such ceiling. -def _load_live_cursor(db: firestore.Client) -> tuple[set[str], dict[str, list[str]]]: - """Return (processedDiscUrls, summaryDiscCache) from the live scraper doc.""" - doc = db.document(SCRAPER_DOC).get() - data = doc.to_dict() or {} +def _url_hash(url: str) -> str: + return hashlib.sha256(url.encode()).hexdigest()[:40] + + +def _is_processed(db: firestore.Client, disc_url: str) -> bool: + h = _url_hash(disc_url) return ( - set(data.get("processedDiscUrls", [])), - data.get("summaryDiscCache", {}), + db.document(SCRAPER_DOC) + .collection(PROCESSED_URLS_COLLECTION) + .document(h) + .get() + .exists + ) + + +def _mark_processed(db: firestore.Client, disc_url: str) -> None: + h = _url_hash(disc_url) + db.document(SCRAPER_DOC).collection(PROCESSED_URLS_COLLECTION).document(h).set( + {"url": disc_url, "processedAt": datetime.now(tz=timezone.utc).isoformat()} ) -def _save_live_cursor( - db: firestore.Client, - processed: set[str], - cache: dict[str, list[str]], +def _get_cached_disc_urls(db: firestore.Client, summary_url: str) -> list[str] | None: + """Cached disclosure URLs for a registrant's summary page, or None if unseen. + + Only consulted for prior years — the current year is always refetched live + since its disclosures can still change. + """ + h = _url_hash(summary_url) + doc = db.document(SCRAPER_DOC).collection(SUMMARY_CACHE_COLLECTION).document(h).get() + if not doc.exists: + return None + return doc.to_dict().get("discUrls", []) + + +def _cache_disc_urls( + db: firestore.Client, summary_url: str, disc_urls: list[str] ) -> None: - db.document(SCRAPER_DOC).set( - {"processedDiscUrls": list(processed), "summaryDiscCache": cache}, - merge=True, + h = _url_hash(summary_url) + db.document(SCRAPER_DOC).collection(SUMMARY_CACHE_COLLECTION).document(h).set( + { + "summaryUrl": summary_url, + "discUrls": disc_urls, + "cachedAt": datetime.now(tz=timezone.utc).isoformat(), + } ) def _is_backfill_processed(db: firestore.Client, disc_url: str) -> bool: - h = hashlib.sha256(disc_url.encode()).hexdigest()[:40] + h = _url_hash(disc_url) return db.document(BACKFILL_DOC).collection(BACKFILL_URLS_COLLECTION).document(h).get().exists def _mark_backfill_processed(db: firestore.Client, disc_url: str) -> None: - h = hashlib.sha256(disc_url.encode()).hexdigest()[:40] + h = _url_hash(disc_url) db.document(BACKFILL_DOC).collection(BACKFILL_URLS_COLLECTION).document(h).set( {"url": disc_url, "processedAt": datetime.now(tz=timezone.utc).isoformat()} ) @@ -117,7 +153,7 @@ def run_weekly( ) -> int: """Incremental weekly check. Returns number of new disclosures processed.""" current_year = datetime.now(tz=timezone.utc).year - processed, cache = _load_live_cursor(db) if db is not None else (set(), {}) + use_cursor = db is not None and not dry_run session = make_session() new_count = 0 @@ -136,33 +172,33 @@ def run_weekly( print(f" {len(summary_urls)} registrants on portal") for summary_url in summary_urls: - # Use cached disc URLs for prior years; always re-check current year - disc_urls = cache.get(summary_url) - if disc_urls is None or year == current_year: + # Prior years: trust the cache if we have one. Current year: + # always refetch live, since its disclosures can still change. + disc_urls = None + if year != current_year and use_cursor: + disc_urls = _get_cached_disc_urls(db, summary_url) + + if disc_urls is None: try: meta = fetch_disclosure_meta(session, summary_url) disc_urls = meta.disclosure_urls - cache[summary_url] = disc_urls - if not dry_run: - _save_live_cursor(db, processed, cache) + if use_cursor: + _cache_disc_urls(db, summary_url, disc_urls) except Exception as e: print(f" failed to fetch summary {summary_url}: {e}", file=sys.stderr) continue - new_disc_urls = [u for u in disc_urls if u not in processed] - if not new_disc_urls: - continue - - for disc_url in new_disc_urls: + for disc_url in disc_urls: + if use_cursor and _is_processed(db, disc_url): + continue try: comp_n, filing_n = process_disclosure( db, session, summary_url, disc_url, year, dry_run=dry_run ) - processed.add(disc_url) new_count += 1 print(f" processed: {comp_n} clients, {filing_n} filings") - if not dry_run: - _save_live_cursor(db, processed, cache) + if use_cursor: + _mark_processed(db, disc_url) except Exception as e: print(f" failed to process {disc_url}: {e}", file=sys.stderr) diff --git a/lobbying-scraper/tests/test_scrape.py b/lobbying-scraper/tests/test_scrape.py new file mode 100644 index 000000000..e2de50768 --- /dev/null +++ b/lobbying-scraper/tests/test_scrape.py @@ -0,0 +1,198 @@ +"""Unit tests for the weekly-mode cursor logic in scrape.py. + +Uses a tiny in-memory fake standing in for firestore.Client — real enough to +exercise document/subcollection reads and writes statefully across calls +(unlike a plain MagicMock, which can't easily simulate "write now, read back +later"), without needing a live database or emulator. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from portal import DisclosureDetail, DisclosureMeta +import scrape + + +# ── Fake Firestore ──────────────────────────────────────────────────────────── + + +class _FakeSnapshot: + def __init__(self, data): + self._data = data + + @property + def exists(self): + return self._data is not None + + def to_dict(self): + return self._data + + +class _FakeDocRef: + def __init__(self, store, path): + self._store = store + self._path = path + + def get(self): + return _FakeSnapshot(self._store.get(self._path)) + + def set(self, data, merge=False): + if merge and self._path in self._store: + self._store[self._path] = {**self._store[self._path], **data} + else: + self._store[self._path] = dict(data) + + def collection(self, name): + return _FakeCollectionRef(self._store, f"{self._path}/{name}") + + +class _FakeCollectionRef: + def __init__(self, store, path): + self._store = store + self._path = path + + def document(self, doc_id): + return _FakeDocRef(self._store, f"{self._path}/{doc_id}") + + +class FakeFirestore: + """Minimal stand-in for firestore.Client: document()/collection() only.""" + + def __init__(self): + self.store: dict[str, dict] = {} + + def document(self, path): + return _FakeDocRef(self.store, path) + + def collection(self, name): + return _FakeCollectionRef(self.store, name) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _meta(summary_url: str, disc_urls: list[str]) -> DisclosureMeta: + return DisclosureMeta( + entity_name=f"Entity for {summary_url}", + year=2024, + reg_type="Employer", + disclosure_urls=disc_urls, + ) + + +# ── run_weekly: subcollection cursor sanity checks ─────────────────────────── + + +def test_weekly_skips_already_processed_disclosure(): + db = FakeFirestore() + summary_url = "https://x/summary/a" + disc_url = "https://x/disc/1" + meta_by_url = {summary_url: _meta(summary_url, [disc_url])} + + def run(year): + with patch("scrape.make_session", return_value=None), patch( + "scrape.fetch_summary_links", + side_effect=lambda session, y: [summary_url], + ), patch( + "scrape.fetch_disclosure_meta", + side_effect=lambda session, url: meta_by_url[url], + ), patch( + "scrape.fetch_disclosure_detail", return_value=DisclosureDetail() + ), patch( + "scrape.write_registrant", return_value=None + ), patch( + "scrape.write_filings", return_value=0 + ): + return scrape.run_weekly(db, years=[year]) + + # Use a prior (non-current) year so caching also gets exercised below. + n1 = run(2020) + n2 = run(2020) + + assert n1 == 1 + assert n2 == 0 + + +def test_weekly_caches_prior_year_but_not_current_year(): + db = FakeFirestore() + current_year = scrape.datetime.now(tz=scrape.timezone.utc).year + prior_year = current_year - 1 + summary_url = "https://x/summary/a" + + with patch("scrape.make_session", return_value=None), patch( + "scrape.fetch_summary_links", + side_effect=lambda session, y: [summary_url], + ), patch( + "scrape.fetch_disclosure_meta", + side_effect=lambda session, url: _meta(url, []), + ) as fetch_meta: + # Prior year twice: second call should hit the cache, not refetch. + scrape.run_weekly(db, years=[prior_year]) + scrape.run_weekly(db, years=[prior_year]) + assert fetch_meta.call_count == 1 + + # Current year twice: must always refetch live. + fetch_meta.reset_mock() + scrape.run_weekly(db, years=[current_year]) + scrape.run_weekly(db, years=[current_year]) + assert fetch_meta.call_count == 2 + + +def test_weekly_cursor_doc_never_exceeds_a_few_small_fields(): + """Regression guard for the original 1MB-doc bug: the parent scraper doc + itself must stay tiny — all real state lives in subcollection docs.""" + db = FakeFirestore() + summary_url = "https://x/summary/a" + disc_url = "https://x/disc/1" + meta_by_url = {summary_url: _meta(summary_url, [disc_url])} + + with patch("scrape.make_session", return_value=None), patch( + "scrape.fetch_summary_links", + side_effect=lambda session, y: [summary_url], + ), patch( + "scrape.fetch_disclosure_meta", + side_effect=lambda session, url: meta_by_url[url], + ), patch( + "scrape.fetch_disclosure_detail", return_value=DisclosureDetail() + ), patch( + "scrape.write_registrant", return_value=None + ), patch( + "scrape.write_filings", return_value=0 + ): + scrape.run_weekly(db, years=[2020]) + + parent = db.store.get(scrape.SCRAPER_DOC) + assert parent is None or "processedDiscUrls" not in parent + assert parent is None or "summaryDiscCache" not in parent + + # The actual state must be in per-URL subcollection docs. + subcollection_paths = [ + p for p in db.store if p.startswith(scrape.SCRAPER_DOC + "/") + ] + assert len(subcollection_paths) >= 2 # one processedUrls doc, one summaryCache doc + + +def test_weekly_dry_run_never_touches_firestore(): + db = FakeFirestore() + summary_url = "https://x/summary/a" + disc_url = "https://x/disc/1" + meta_by_url = {summary_url: _meta(summary_url, [disc_url])} + + with patch("scrape.make_session", return_value=None), patch( + "scrape.fetch_summary_links", + side_effect=lambda session, y: [summary_url], + ), patch( + "scrape.fetch_disclosure_meta", + side_effect=lambda session, url: meta_by_url[url], + ), patch( + "scrape.fetch_disclosure_detail", return_value=DisclosureDetail() + ): + n = scrape.run_weekly(None, years=[2024], dry_run=True) + + assert n == 1 + assert db.store == {} diff --git a/lobbying-scraper/writer.py b/lobbying-scraper/writer.py index fefd81aaf..c6d4958f8 100644 --- a/lobbying-scraper/writer.py +++ b/lobbying-scraper/writer.py @@ -23,6 +23,8 @@ REGISTRANTS_COLLECTION = "lobbyingRegistrants" FILINGS_COLLECTION = "lobbyingFilings" SCRAPER_DOC = "scrapers/lobbying" +PROCESSED_URLS_COLLECTION = "processedUrls" +SUMMARY_CACHE_COLLECTION = "summaryCache" BACKFILL_DOC = "scrapers/lobbyingBackfill" BACKFILL_URLS_COLLECTION = "processedUrls"