diff --git a/.all-contributorsrc b/.all-contributorsrc index 96beac0c1..15eb7194e 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -758,6 +758,133 @@ "contributions": [ "code" ] + }, + { + "login": "Smoss", + "name": "Sean Moss", + "avatar_url": "https://avatars.githubusercontent.com/u/2860018?v=4", + "profile": "https://github.com/Smoss", + "contributions": [ + "code" + ] + }, + { + "login": "J-C-L", + "name": "Janice Lichtman", + "avatar_url": "https://avatars.githubusercontent.com/u/9040395?v=4", + "profile": "https://github.com/J-C-L", + "contributions": [ + "code" + ] + }, + { + "login": "c-tonneslan", + "name": "Charlie Tonneslan", + "avatar_url": "https://avatars.githubusercontent.com/u/281950697?v=4", + "profile": "https://c-tonneslan-portfolio.vercel.app/", + "contributions": [ + "code" + ] + }, + { + "login": "VenkateshDevarakonda0706", + "name": "VenkateshDevarakonda0706", + "avatar_url": "https://avatars.githubusercontent.com/u/188581366?v=4", + "profile": "https://github.com/VenkateshDevarakonda0706", + "contributions": [ + "code" + ] + }, + { + "login": "ACoullard", + "name": "Andre Coullard", + "avatar_url": "https://avatars.githubusercontent.com/u/119697079?v=4", + "profile": "https://github.com/ACoullard", + "contributions": [ + "code" + ] + }, + { + "login": "LexaMichaelides", + "name": "Lexa Michaelides", + "avatar_url": "https://avatars.githubusercontent.com/u/32111123?v=4", + "profile": "http://lexa.fyi", + "contributions": [ + "code" + ] + }, + { + "login": "fastfadingviolets", + "name": "violet", + "avatar_url": "https://avatars.githubusercontent.com/u/158512193?v=4", + "profile": "https://github.com/fastfadingviolets", + "contributions": [ + "code" + ] + }, + { + "login": "AstorDG", + "name": "Astor Meredith-Goujon", + "avatar_url": "https://avatars.githubusercontent.com/u/42044772?v=4", + "profile": "https://github.com/AstorDG", + "contributions": [ + "code" + ] + }, + { + "login": "n1b11", + "name": "Noah Burd", + "avatar_url": "https://avatars.githubusercontent.com/u/183017602?v=4", + "profile": "https://github.com/n1b11", + "contributions": [ + "code" + ] + }, + { + "login": "nssensalo", + "name": "Nansamba Ssensalo", + "avatar_url": "https://avatars.githubusercontent.com/u/85596471?v=4", + "profile": "https://github.com/nssensalo", + "contributions": [ + "code" + ] + }, + { + "login": "jasrose", + "name": "Jasmine Rose", + "avatar_url": "https://avatars.githubusercontent.com/u/72074190?v=4", + "profile": "https://github.com/jasrose", + "contributions": [ + "code" + ] + }, + { + "login": "k-g-k", + "name": "Grace (Iron Yard/CaseNEX/IO Account)", + "avatar_url": "https://avatars.githubusercontent.com/u/10489124?v=4", + "profile": "https://github.com/k-g-k", + "contributions": [ + "code", + "design" + ] + }, + { + "login": "ericpastorm", + "name": "Eric", + "avatar_url": "https://avatars.githubusercontent.com/u/38719351?v=4", + "profile": "https://github.com/ericpastorm", + "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" diff --git a/.gitignore b/.gitignore index e2da7cc59..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 @@ -92,5 +95,10 @@ cert.txt # local MCP server config (contains auth tokens) .mcp.json mcp-server/create-agent-key.ts -.gcloudignore + +# Claude CLAUDE.md + +#gcloud +.gcloudignore + 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/.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..3ba9628f1 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,132 @@ +# 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/.tool-versions b/.tool-versions deleted file mode 100644 index 1e04d796d..000000000 --- a/.tool-versions +++ /dev/null @@ -1 +0,0 @@ -nodejs 18.17.0 diff --git a/README.md b/README.md index 3b79cbae5..736d88d17 100644 --- a/README.md +++ b/README.md @@ -162,33 +162,42 @@ Thanks to all our contributors! Aaron
Aaron

πŸ’» Alex Ball
Alex Ball

πŸ’» πŸ‘€ πŸ§‘β€πŸ« Allan Zheng
Allan Zheng

πŸ’» + Andre Coullard
Andre Coullard

πŸ’» Anna Steele
Anna Steele

πŸ’Ό πŸ“† Anthony
Anthony

πŸ’» - Asia K
Asia K

πŸ’» + Asia K
Asia K

πŸ’» + Astor Meredith-Goujon
Astor Meredith-Goujon

πŸ’» Austin Houck
Austin Houck

πŸ’» Barry Moore
Barry Moore

πŸ’» Boaz
Boaz

πŸ’» Boyu Han
Boyu Han

πŸ’» Brian Rutledge
Brian Rutledge

πŸ’» - Byron Kent Hinebaugh
Byron Kent Hinebaugh

πŸ’» - Celena T.
Celena T.

πŸ’» + Byron Kent Hinebaugh
Byron Kent Hinebaugh

πŸ’» + Celena T.
Celena T.

πŸ’» Chaoyi Jiang
Chaoyi Jiang

πŸ’» + Charlie Tonneslan
Charlie Tonneslan

πŸ’» Colin MacDonald
Colin MacDonald

πŸ’» Colton Almaraz
Colton Almaraz

πŸ’» Dakota
Dakota

πŸ’» + + 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

πŸ’» J.I. Cruz
J.I. Cruz

πŸ’» + Janice Lichtman
Janice Lichtman

πŸ’» + Jasmine Rose
Jasmine Rose

πŸ’» Jeff Korenstein
Jeff Korenstein

πŸ’» KY233466
KY233466

πŸ’» Kep Kaeppeler
Kep Kaeppeler

πŸ’» @@ -198,68 +207,77 @@ Thanks to all our contributors! 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

πŸ““ - Nathan Sanders
Nathan Sanders

πŸ’» πŸ’Ό πŸ” - Navdeep
Navdeep

πŸ’» + Nansamba Ssensalo
Nansamba Ssensalo

πŸ’» + 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

πŸ’» 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

πŸ’» 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

πŸ’» Veronica Adler
Veronica Adler

πŸ’» YuhaoT
YuhaoT

πŸ’» ananyasinghz
ananyasinghz

πŸ’» arutfield
arutfield

πŸ’» bancona
bancona

πŸ’» + d.ondrich
d.ondrich

πŸ’» - d.ondrich
d.ondrich

πŸ’» djtanner
djtanner

πŸ’» ekambains
ekambains

πŸ’» iWumboUWumbo2
iWumboUWumbo2

πŸ’» ishana-goyal
ishana-goyal

πŸ’» jamesvas5307
jamesvas5307

🎨 πŸ§‘β€πŸ« πŸ““ jellyyams
jellyyams

πŸ’» + jkinzer85
jkinzer85

πŸ““ - jkinzer85
jkinzer85

πŸ““ mertbagt
mertbagt

πŸ’» mmailloux22
mmailloux22

πŸ’» mvictor55
mvictor55

πŸ’Ό πŸ“† πŸ” πŸ’» pranay
pranay

πŸ’» ren0nie0
ren0nie0

πŸ”¬ roed
roed

πŸ’» + sammymyi
sammymyi

🎨 πŸ““ - sammymyi
sammymyi

🎨 πŸ““ sashamaryl
sashamaryl

πŸ’» πŸ§‘β€πŸ« πŸ”¬ shannonh800
shannonh800

πŸ’» + violet
violet

πŸ’» yasminekarni
yasminekarni

πŸ’» diff --git a/components/AboutPagesCard/AboutPagesCard.tsx b/components/AboutPagesCard/AboutPagesCard.tsx index 6fbf18b41..ef6c74903 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,19 +23,25 @@ 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 }) => { return ( - + {title} - {children} + {children} ) } diff --git a/components/AdditionalResources.tsx b/components/AdditionalResources.tsx index 1e6170ad8..144f99051 100644 --- a/components/AdditionalResources.tsx +++ b/components/AdditionalResources.tsx @@ -1,48 +1,112 @@ -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); + 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/EditProfilePage/PersonalInfoTab.tsx b/components/EditProfilePage/PersonalInfoTab.tsx index b6d70364c..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 ) { @@ -197,30 +197,16 @@ export function PersonalInfoTab({ label={t("socialLinks.instagram")} defaultValue={social?.instagram} className="col-sm-12 col-md-6 mb-1" - iconSrc="./instagram.svg" + iconSrc="/instagram.svg" {...register("instagram")} /> - - )} diff --git a/components/Faq/FaqCard.tsx b/components/Faq/FaqCard.tsx index 9c9331099..4487b57e7 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,151 @@ 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; + + > h2 { + font-family: var(--maple-font-heading); + font-weight: 700; + color: var(--bs-blue); + font-size: 1.125rem; + margin: 0; + } + + > h2 > 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; + font: inherit; + color: inherit; + text-align: left; + + &:focus-visible { + outline: 2px solid var(--bs-blue); + outline-offset: -2px; + } + } - @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" && ( -
+ + {/* The heading wraps the button (WAI-ARIA accordion pattern) so the + category is exposed as a real h2 -- a heading nested inside the button + would be flattened into the button's label and lost. */} +

+ +

+
+
+
+ {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..2bd065f75 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. + + 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); + + /* .divider-wrap { + padding: 0 2rem; + } + + .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; + } + } - useEffect(() => {}, [open]) + @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; + } - let supportLink = null + .answer a { + color: var(--bs-blue); + text-decoration: underline; + } - { - question == "How can I support MAPLE?" - ? (supportLink = ( - - {" this page"} - - )) - : null + @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. */} + {/*
+
+
*/} +
+ {/* Kept mounted (hidden when collapsed) so the button's aria-controls + always references an element that exists in the DOM. */} + + ) } 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/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/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..da93fd4db 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; + + h3 { + 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..495c24a39 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,10 @@ type TabCounts = { const NewsFeed = ({ type, newsItems }: NewsFeedProps) => { return ( -
+
{newsItems .filter(item => item.type === type || type === null) .map((item, index) => ( @@ -33,7 +39,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 +52,15 @@ export const InTheNews = () => { : null return ( - -

- {t("title")} -

-
+ + + +
{loading ? (
@@ -72,42 +82,30 @@ export const InTheNews = () => { -
- -
+
-
- -
+
-
- -
+
) : ( - - -
- -
- -
+ )}
- + ) } const TabGroup = ({ counts }: { counts: TabCounts }) => { const { t } = useTranslation("inTheNews") return ( - + {newsTypePlurals .filter(val => counts[val]) .map(val => ( @@ -115,13 +113,13 @@ const TabGroup = ({ counts }: { counts: TabCounts }) => {
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/components/learn/AiTools/AiTools.tsx b/components/learn/AiTools/AiTools.tsx index 5c8d6fe6e..d4701c72f 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); @@ -59,207 +60,225 @@ 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") + // 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 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")}” - -
-
- -
+ + + + + {/* What is it */} + + + + {t("section1.title")} + + {t("section1.desc1")} + + + {t("section1.desc2Pre")}{" "} + + + {t("section1.desc2LinkText")} + + + {t("section1.desc2Post")} + + + + - {/* 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("section4.step2item2Bold")}{" "} - {t("section4.step2item2Tag")} {t("section4.step2item2Pre")}{" "} - - {t("section4.step2item2LinkText")} - {" "} - {t("section4.step2item2Post")} +
    • + {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("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 +289,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..436d87b5c --- /dev/null +++ b/components/learn/LearnBreadcrumb.tsx @@ -0,0 +1,79 @@ +import { useTranslation } from "next-i18next" +import styled from "styled-components" +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; + } + + /* Reads like the other crumbs, but not a link. */ + .disabled { + color: var(--maple-text-body); + } + + .current { + color: var(--bs-blue); + font-weight: 700; + } +` + +/** + * "{eyebrow} > {section}" trail shown at the top of a Learn or About sub-page. + * The eyebrow defaults to "Learn"; pass one (e.g. "About") to reuse the trail on + * other sections. + */ +export const LearnBreadcrumb = ({ + section, + eyebrow +}: { + section: string + eyebrow?: 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..31a1c9efd --- /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.25rem 2rem 3.5rem; + + @media (max-width: 36rem) { + padding: 1.75rem 1rem 2rem; + } +` + +/** + * 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..722ce611d --- /dev/null +++ b/components/learn/Process/LegislativeProcess.tsx @@ -0,0 +1,997 @@ +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 + +/** 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}` + +/* ── Sticky journey rail ─────────────────────────────────────── */ + +const RailWrapper = styled.div` + position: sticky; + /* 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; + } +` + +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; + } + /* 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); + } + } +` + +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 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; + + /* 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"). */ + 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; + } + } + + @media print { + .chevron { + display: none; + } + button { + cursor: auto; + } + } +` + +const Panel = styled.div` + /* 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; + } + + .clip { + min-height: 0; + overflow: hidden; + } + + .inner { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem 1.75rem 1.75rem 5.5rem; + } + + .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) { + .inner { + padding: 1rem; + } + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + &[data-open="true"] { + 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 }) => { + if (!stat.href) return {stat.label} + + if (stat.href.startsWith("/")) + return ( + + {stat.label} + + ) + + return ( + + {stat.label} + + + ) +} + +const AccordionRow = ({ + index, + chapter, + color, + isOpen, + isActive, + onToggle +}: { + index: number + chapter: Chapter + color: string + isOpen: boolean + isActive: boolean + onToggle: () => void +}) => ( + + + + + + +
    +
    +

    {chapter.body}

    + + {chapter.stat && ( +
    + + {chapter.stat.value} + + +
    + )} + + {chapter.callout && ( +
    + +

    {chapter.callout}

    +
    + )} +
    +
    +
    +
    +) + +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[] + + // 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) + + // 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) + const headerRef = useRef(null) + // Marks the rail's resting position, so an observer can tell when it pins. + const lockSentinelRef = useRef(null) + + // Set when a rail node is clicked, so we scroll to that chapter. + const pendingScrollRef = useRef(null) + const [scrollNonce, setScrollNonce] = useState(0) + // While a programmatic scroll is running, the reader is not driving, so the + // rail should not chase the intermediate positions. + const suppressUntilRef = useRef(0) + + // Always start this page at the top on load. The browser otherwise restores + // the previous scroll position on refresh, and the rail's highlight -- which + // defaults to the first stage -- then snaps forward to match, animating every + // completed tint and the active fill in at once: a visible flash across the + // nodes. This page has no anchor/deep-link state worth restoring, so we reset + // instead. A layout effect resets before paint, so there is no visible jump, + // and switching restoration to "manual" keeps later refreshes from restoring. + useLayoutEffect(() => { + if (!("scrollRestoration" in window.history)) return + const previous = window.history.scrollRestoration + window.history.scrollRestoration = "manual" + window.scrollTo(0, 0) + return () => { + window.history.scrollRestoration = previous + } + }, []) + + // 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. + // + // 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 + const nav = document.querySelector(".main-navbar") + if (!scope || !nav) return + if (getComputedStyle(nav).position !== "sticky") return + + let frame = 0 + const publishNavOffset = () => { + const bottom = nav.getBoundingClientRect().bottom + scope.style.setProperty( + "--maple-navbar-height", + `${Math.max(0, bottom)}px` + ) + } + const schedule = () => { + if (frame) return + frame = requestAnimationFrame(() => { + frame = 0 + publishNavOffset() + }) + } + + publishNavOffset() + window.addEventListener("scroll", schedule, { passive: true }) + window.addEventListener("resize", schedule) + + const observer = new ResizeObserver(schedule) + 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() + }, []) + + // 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 + + 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 + + const ADVANCE_AT = railBottom + 120 + + const tops = Array.from( + document.querySelectorAll('[id^="learn-process-stage-"]') + ).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 + }) + } + + const schedule = () => { + if (frame) return + frame = requestAnimationFrame(evaluate) + } + + const attach = () => { + evaluate() + window.addEventListener("scroll", schedule, { passive: true }) + window.addEventListener("resize", schedule) + } + const detach = () => { + if (frame) { + cancelAnimationFrame(frame) + frame = 0 + } + window.removeEventListener("scroll", schedule) + window.removeEventListener("resize", schedule) + } + + // Desktop tracks from the start: it is smooth there, and lighting the rail + // early is wanted. + if (!window.matchMedia("(max-width: 48rem)").matches) { + attach() + return detach + } + + // Mobile keeps the initial scroll entirely free of scroll handlers. While + // the rail travels up to its pinned spot -- which is also when the site + // navbar is scrolling off and the mobile URL bar is collapsing, the jankiest + // moment -- a per-frame getBoundingClientRect would force a layout read on + // top of that churn. So we attach nothing until the rail locks. A sentinel + // at the rail's resting position tells us the moment it pins (it leaves the + // top of the viewport); only then do we start the scroll-driven highlight, + // and we stop again if the reader scrolls back up past it. Nothing needs + // highlighting before lock anyway -- the whole rail is on screen. + // + // The sentinel only re-enters the viewport at the TOP of the section (past + // the bottom it stays above the viewport, so it never intersects), so an + // unlock always means the reader is back at the start. We reset the + // highlight to the first stage there rather than leaving it wherever the + // last frame put it. A fast fling to the top can outrun the per-frame + // retreat loop and strand the highlight on a later stage -- the observer + // fires reliably regardless of scroll speed, so it is the dependable place + // to snap it back. + const sentinel = lockSentinelRef.current + if (!sentinel) return + let attached = false + const observer = new IntersectionObserver( + ([entry]) => { + const locked = !entry.isIntersecting + if (locked && !attached) { + attached = true + attach() + } else if (!locked && attached) { + attached = false + detach() + setActiveIdx(0) + } + }, + { threshold: 0 } + ) + observer.observe(sentinel) + return () => { + observer.disconnect() + detach() + } + }, []) + + // 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 row = document.getElementById(rowId(target)) + const rail = railRef.current + if (!row || !rail) return + + const reduceMotion = prefersReducedMotion() + + // 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) + + // 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 + // visible as the highlight moves. + useEffect(() => { + const track = trackRef.current + if (!track || track.scrollWidth <= track.clientWidth) return + + const node = track.children[activeIdx] as HTMLElement | undefined + if (!node) return + + track.scrollTo({ + left: node.offsetLeft - (track.clientWidth - node.offsetWidth) / 2, + 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) + }, []) + + // 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 + }) + }, []) + + return ( + // "medium" rather than "narrow": the h1 wraps at 48rem. + +
    + + + + + + + + + ) +} + +export default LegislativeProcess diff --git a/components/learn/Testimony/AboutTestimony.tsx b/components/learn/Testimony/AboutTestimony.tsx new file mode 100644 index 000000000..13930f265 --- /dev/null +++ b/components/learn/Testimony/AboutTestimony.tsx @@ -0,0 +1,342 @@ +import { Trans, useTranslation } from "next-i18next" +import styled from "styled-components" +import { Internal } from "../../links" +import LearnBreadcrumb from "../LearnBreadcrumb" +import LearnHeader from "../LearnHeader" +import LearnLayout from "../LearnLayout" +import { + HowBlob, + WhatBlob, + WhenBlob, + WhereBlob, + WhoBlob, + WhyBlob +} from "../icons/Blobs" +import { WHY_ICONS } from "../icons/WhyItMattersIcons" + +/** + * Anchors for two of the Learn pages that were folded into this one. The old + * slugs are kept verbatim as ids so /learn/ can redirect straight to the + * section that replaced it (see the redirects in next.config.js) and so links + * from outside the site keep their meaning. + * + * writing-effective-testimony is not here: it is a page again, not a section. + */ +export const ANCHORS = { + role: "role-of-testimony", + communicating: "communicating-with-legislators" +} as const + +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 +} + +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; + + /* The badge is a flex item beside the headline: let it shrink and a long + headline squeezes it below its drawn size. */ + > svg { + flex-shrink: 0; + } + } + + 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; + } + + /* Without this the icon is a shrinkable flex item: on a narrow screen the + text squeezes it well below its 40px and it reads as a different size in + every row. */ + > svg { + flex-shrink: 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; + } +` + +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; +` + +/* Holds the numbered steps, so they can be inset from the panel without moving + the HOW heading or the intro line above them. */ +const Steps = styled.div` + padding: 0.5rem 1.5rem 0; + + @media (max-width: 36rem) { + padding: 0.5rem 0 0; + } +` + +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 Panel = styled.div` + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + overflow: hidden; + + /* Deep-link targets: stop short of the sticky navbar rather than under it. */ + &#${ANCHORS.role}, &#${ANCHORS.communicating} { + scroll-margin-top: calc(var(--maple-navbar-height) + 1rem); + } + + .head { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1.5rem 1.5rem 1rem; + + /* The blob is a flex item beside the headline: let it shrink and a long + headline squeezes it below its drawn size. */ + > svg { + flex-shrink: 0; + } + } + + 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; + } +` + +const ReadyToStart = styled.p` + font-weight: 700; + color: #2f3031; + font-size: 1.125rem; + text-align: center; + margin: 1.5rem 0; +` + +export const AboutTestimony = () => { + const { t } = useTranslation("learn") + + 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[] + + return ( + + + + + {/* WHO / WHAT / WHEN / WHERE */} +
    + {cards.map(card => { + const Badge = BADGES[card.badge] + return ( +
    + +
    + {Badge && } +

    {card.headline}

    +
    +

    {card.body}

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

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

    +
    +
    +

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

    +

    + {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}

    +
    +
    + ) + })} +
    +
    + + {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} + +

    + )} + + {/* The tips used to sit here as a collapsible panel; they are + their own page now. */} + {i === 0 && ( +

    + + ]} + /> +

    + )} +
    +
    + ))} +
    +
    +
    +
    + ) +} + +export default AboutTestimony diff --git a/components/learn/Testimony/WritingTips.tsx b/components/learn/Testimony/WritingTips.tsx new file mode 100644 index 000000000..a3798e121 --- /dev/null +++ b/components/learn/Testimony/WritingTips.tsx @@ -0,0 +1,295 @@ +import { useTranslation } from "next-i18next" +import styled from "styled-components" +import { Internal } from "../../links" +import LearnBreadcrumb from "../LearnBreadcrumb" +import LearnHeader from "../LearnHeader" +import LearnLayout from "../LearnLayout" +import { CheckIcon } from "../icons" +import { NAVY, STAGE_COLORS, alpha } from "../palette" + +/* Near-black for headings, a shade deeper than the page's body ink. */ +const INK = "#0f1828" + +type Tip = { label: string; body: string } +type Item = { title: string; body: string } + +/* The About Testimony page is built from blob-headed panels; this one borrows + the process page's display numerals and coloured spines, so the two pages do + not read as the same page. */ + +const Card = styled.section` + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + overflow: hidden; + margin-bottom: 1rem; +` + +/* No fill: the heading sits on the card itself, divided from the list by a rule. + Any tint here either vanished into the page background or competed with the + coloured numerals below. */ +const Hero = styled.div` + padding: 2rem 2rem 0; + + /* The rule is drawn inside the padding rather than as a border on the box, so + the card's gutter shows on both ends of it instead of it running edge to + edge. Navy, but held well back: at full strength a 1px line across the card + reads as a table rule rather than a divider. */ + &::after { + content: ""; + display: block; + height: 1px; + margin-top: 1.5rem; + background: ${alpha(NAVY, 16)}; + } + + h2 { + font-family: var(--maple-font-heading); + font-weight: 700; + font-size: 1.5rem; + line-height: 1.25; + color: ${alpha(INK, 85)}; + margin-bottom: 0.75rem; + } + + p { + color: var(--maple-text-body); + font-size: 1.0625rem; + line-height: 1.65; + margin-bottom: 0; + } + + @media (max-width: 36rem) { + padding: 1.25rem 1.25rem 0; + } +` + +const Ticks = styled.ul` + list-style: none; + margin: 0; + padding: 2rem; + + li { + display: flex; + align-items: flex-start; + gap: 0.875rem; + } + + li + li { + margin-top: 1.25rem; + } + + .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; + background-color: ${alpha(NAVY, 9)}; + color: ${NAVY}; + } + + .title { + font-weight: 700; + color: var(--maple-text-strong); + line-height: 1.4; + margin: 0 0 0.25rem; + } + + .text { + color: var(--maple-text-muted); + line-height: 1.6; + margin: 0; + } + + @media (max-width: 36rem) { + padding: 1.5rem 1.25rem; + } +` + +/* The five principles: a display numeral and a coloured spine, cycling the + Learn section's stage colours the way the process chapters do. */ +const Principle = styled.li<{ $color: string }>` + display: flex; + align-items: stretch; + gap: 1.25rem; + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + padding: 1.5rem 1.75rem; + + .num { + font-family: var(--maple-font-heading); + font-weight: 900; + font-size: 2.25rem; + line-height: 1; + flex-shrink: 0; + width: 2.5rem; + color: ${p => p.$color}; + } + + .spine { + width: 4px; + align-self: stretch; + border-radius: var(--maple-radius-pill); + flex-shrink: 0; + background: ${p => alpha(p.$color, 25)}; + } + + .label { + font-family: var(--maple-font-heading); + font-weight: 700; + color: var(--maple-text-strong); + font-size: 1.125rem; + line-height: 1.35; + margin: 0 0 0.375rem; + } + + .text { + color: var(--maple-text-muted); + line-height: 1.6; + margin: 0; + } + + @media (max-width: 36rem) { + gap: 1rem; + padding: 1.25rem; + + .num { + font-size: 1.75rem; + width: 1.75rem; + } + } +` + +const PrincipleList = styled.ul` + list-style: none; + padding: 0; + margin: 0 0 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +` + +/* Sets the gap between the checklist card above and the principles below it. */ +const SectionTitle = styled.h2` + font-family: var(--maple-font-heading); + font-weight: 700; + color: ${alpha(INK, 85)}; + font-size: 1.375rem; + margin: 4rem 0 1rem; +` + +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; + text-align: center; + + h2 { + font-family: var(--maple-font-heading); + font-weight: 900; + color: ${NAVY}; + font-size: 1.5rem; + margin-bottom: 0.5rem; + } + + p { + color: var(--maple-text-body); + line-height: 1.65; + max-width: 34rem; + margin: 0 auto 1.5rem; + } + + /* A navy pill, at MAPLE's button height (standard vertical padding). */ + a { + display: inline-block; + padding: var(--maple-space-sm) var(--maple-space-xl); + border-radius: var(--maple-radius-pill); + background: ${NAVY}; + color: var(--maple-text-inverse); + font-weight: 700; + text-decoration: none; + + &:hover { + background: var(--maple-brand-primary-strong); + color: var(--maple-text-inverse); + } + } +` + +export const WritingTips = () => { + const { t } = useTranslation("learn") + + const tips = t("testimony.tips", { returnObjects: true }) as Tip[] + const include = t("writingTips.include.items", { + returnObjects: true + }) as Item[] + + return ( + + + + + {/* What every letter should include: a hero over a tick list. */} + + +

    {t("writingTips.include.headline")}

    +

    {t("writingTips.include.intro")}

    +
    + + {include.map(item => ( +
  • + +
    +

    {item.title}

    +

    {item.body}

    +
    +
  • + ))} + + + + {/* The five principles, as numbered chapters. */} + {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. + + + + ))} + + + +

    {t("writingTips.cta.headline")}

    +

    {t("writingTips.cta.body")}

    + {t("writingTips.cta.button")} +
    + + ) +} + +export default WritingTips diff --git a/components/learn/WhyUseMaple/WhyUseMaple.tsx b/components/learn/WhyUseMaple/WhyUseMaple.tsx new file mode 100644 index 000000000..c03a531cc --- /dev/null +++ b/components/learn/WhyUseMaple/WhyUseMaple.tsx @@ -0,0 +1,644 @@ +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" +import LearnLayout from "../LearnLayout" +import { + CheckIcon, + ChevronDownIcon, + MegaphoneIcon, + ScaleIcon, + UsersIcon +} from "../icons" +import { GREEN, NAVY, ORANGE } from "../palette" + +/** + * "Who Uses MAPLE": a row of persona tabs selecting one persona's panel at a + * time β€” a hero (name, tagline, why) above its checklist of benefits. + * + * Most copy is drawn from the existing persona namespaces: taglines are + * `callToAction.title`, the "why" text is `callToAction.bodytext`, and the + * feature bullets are the `benefits.*.title` values. `personaLabel` is the one + * key added for this component. 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[] + /** + * 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[] = [ + { + slug: "for-individuals", + ns: "forindividuals", + color: NAVY, + Icon: UsersIcon, + benefits: [ + "youMatter", + "multipleSides", + "trustedOrgs", + "anyLanguage", + "stayInformed" + ], + body: ["bodytext"], + cta: { kind: "signup" } + }, + { + slug: "for-orgs", + ns: "fororgs", + color: GREEN, + Icon: MegaphoneIcon, + benefits: [ + "reach", + "connect", + "language", + "coordinate", + "seeEveryone", + "curateHistory", + "legislativeResearch", + "changeNorms" + ], + body: ["bodytext"], + cta: { kind: "signup" } + }, + { + slug: "for-legislators", + ns: "forlegislators", + color: ORANGE, + Icon: ScaleIcon, + benefits: [ + "constituents", + "seeTestimony", + "simplifyTestimony", + "languageAccess", + "advancedStatistics" + ], + body: ["bodytextOne", "bodytextTwo"], + cta: { kind: "browse", href: "/testimony", signup: true } + } +] + +const PersonaGrid = styled.div` + display: flex; + gap: 0.75rem; + margin-bottom: 1.25rem; +` + +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; + 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; + + .glyph { + 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")}; + } + + /* 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 { + background-color: ${p => p.$color}; + + .glyph, + .label { + color: #fff; + } + } + + &:focus-visible { + outline: 2px solid var(--bs-blue); + 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; + } + } +` + +const Detail = styled.div` + background: var(--maple-surface-base); + border-radius: var(--maple-radius-xl); + box-shadow: var(--maple-shadow-sm); + overflow: hidden; +` + +/* No fill: the hero sits on the card itself, running straight into the checklist + below without a heading or a rule between them. */ +const Hero = styled.div<{ $color: string }>` + padding: 2rem 2rem 1rem; + + /* Names the persona whose panel this is, so the tagline below it reads as a + statement about them. + + A slate grey tinted toward the persona's colour, rather than the colour + itself: orange never clears 4.5:1 on white even at full opacity (2.42:1), so + a translucent persona colour here would be unreadable. Tinting a dark grey + keeps the ink dark enough to read while still carrying the hue -- orange + lands at 5.51:1 this way. + + Nunito's heaviest loaded cut is 700, so there is no real weight above this; + the hairline stroke thickens it a little without the smearing that the + browser's synthetic 900 produced. */ + .persona-label { + font-size: 0.8125rem; + font-weight: 700; + -webkit-text-stroke: 0.3px currentColor; + letter-spacing: 0.08em; + text-transform: uppercase; + color: color-mix( + in srgb, + ${p => p.$color} 30%, + var(--maple-color-gray-subtle-text) + ); + margin-bottom: 0.375rem; + } + + .tagline { + font-family: var(--maple-font-heading); + font-weight: 700; + font-size: 1.5rem; + line-height: 1.25; + /* The persona's colour is carried by the tab, the rule and the ticks; the + headline stays ink. */ + color: var(--maple-text-strong); + margin-bottom: 0.75rem; + } + + .why { + color: var(--maple-text-body); + font-size: 1.0625rem; + line-height: 1.65; + margin-bottom: 0; + } + + .why + .why { + margin-top: 0.75rem; + } + + @media (max-width: 36rem) { + padding: 1.25rem 1.25rem 1rem; + } +` + +const Offer = styled.div` + padding: 0.75rem 2rem 2.5rem; + + ul { + /* 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; + } + + li { + border: 1px solid var(--maple-surface-border); + border-radius: var(--maple-radius-lg); + overflow: hidden; + } + + .benefit-toggle { + width: 100%; + display: flex; + align-items: center; + 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 { + flex-shrink: 0; + width: 1.5rem; + height: 1.5rem; + border-radius: var(--maple-radius-pill); + display: flex; + align-items: center; + justify-content: center; + } + + .feature-title { + flex: 1; + min-width: 0; + font-weight: 700; + color: var(--maple-text-strong); + line-height: 1; + } + + .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) { + padding: 1.25rem; + } +` + +/** + * 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} + + ) +} + +/* 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 +}: { + slug: string + onSelect: (slug: string) => void +}) => { + const { t } = useTranslation([ + "learn", + "forindividuals", + "fororgs", + "forlegislators" + ]) + + 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 ( + + + + + + {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} + $active={isActive} + > + + ) + })} + + + + +

    {t(`${active.ns}:personaLabel`)}

    +

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

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

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

    + ))} +
    + + +
      + {active.benefits.map(key => { + const open = openBenefit === key + return ( +
    • + +
      +
      +

      + +

      +
      +
      +
    • + ) + })} +
    +
    +
    + + +

    {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`)} + +

    + + + ) +} + +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..29dec09c8 --- /dev/null +++ b/components/learn/icons/WhyItMattersIcons.tsx @@ -0,0 +1,129 @@ +// The three "Why it Matters" icons on /learn/testimony. +// 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. Drawn to a common ~26px optical size inside +// the 40x40 box so the three read as one set. + +const size = { width: 40, height: 40 } +const a11y = { "aria-hidden": true, focusable: "false" } as const + +const PARCHMENT = "#EAE4D8" +const NAVY = "#1B3A5C" +const OCHRE = "#C8873A" + +// The speaking head's mouth, and how far the outer speech strokes swing off it. +// The base stroke below is drawn at the mouth's y so the fan stays radial: move +// one and you must move the other. +const MOUTH = "25.5 20" +const SPEECH_FAN = 35 + +// How far the whole speaking head is nudged right, toward the axis the bulb and +// bullseye sit on. Capped by the fan, which would otherwise reach the box edge. +const VOICE_NUDGE = 1.5 + +/** + * A speaking head, after the emoji: a profile facing right, with the sound + * radiating from the mouth. Mirrored from the emoji's left-facing head so it + * speaks toward the heading beside it rather than away from it. + */ +export const IconVoice = () => ( + + {/* The speech fan hangs off the right, which drags the bounding box with it + and leaves the head sitting left of the axis the bulb and bullseye share. + Nudge the whole icon right to close most of that gap -- head and fan move + together, so the fan stays radial about the mouth. */} + + {/* Profile silhouette, clockwise from the back of the skull: crown, brow, + nose, the open mouth, then the jaw. Drawn in Figma -- the head's upward + tilt is baked into these coordinates rather than applied as a rotation, + so the face, the speech fan and the viewBox share one coordinate system. */} + + + {/* Speech, as in the emoji: one stroke, drawn three times and rotated about + the mouth. Widen or narrow the fan by changing SPEECH_FAN; lengthen it by + moving the 35.5 in the path. */} + {[-SPEECH_FAN, 0, SPEECH_FAN].map(angle => ( + + ))} + + +) + +/** + * A lightbulb: you give legislators real-world insight. The drawing runs wider + * than the shared box, so it is scaled about its own centre into it. The stroke + * is divided by that scale, so it still renders at a true 1.5 like the others. + */ +const BULB_SCALE = 0.8448 +const BULB_STROKE = 1.5 / BULB_SCALE + +export const IconInsight = () => ( + + + + + + + + +) + +/** + * A bullseye: you can recommend specific changes. Radii are scaled rather than + * transformed, so the stroke stays a true 1.5. A solid disc carries more visual + * weight than the outlined bulb and head, so it is drawn a little inside the + * shared box rather than filling it. + */ +export const IconTarget = () => ( + + + + + +) + +export const WHY_ICONS = [IconVoice, IconInsight, IconTarget] 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/legislator/SidebarComponents/Biography.tsx b/components/legislator/SidebarComponents/Biography.tsx deleted file mode 100644 index 90cf3f5e0..000000000 --- a/components/legislator/SidebarComponents/Biography.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function Biography() { - return
    - Biography
    -} 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/SidebarComponents/OtherTestimony.tsx b/components/legislator/SidebarComponents/OtherTestimony.tsx deleted file mode 100644 index 8b3940014..000000000 --- a/components/legislator/SidebarComponents/OtherTestimony.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function OtherTestimony() { - return
    - Other Testimony
    -} diff --git a/components/legislator/SidebarComponents/UpcomingHearings.tsx b/components/legislator/SidebarComponents/UpcomingHearings.tsx deleted file mode 100644 index 69e68cb05..000000000 --- a/components/legislator/SidebarComponents/UpcomingHearings.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function UpcomingHearings() { - return
    - Upcoming Hearings
    -} 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/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/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/components/shared/CommonComponents.tsx b/components/shared/CommonComponents.tsx index c8e7539ce..f682a7c58 100644 --- a/components/shared/CommonComponents.tsx +++ b/components/shared/CommonComponents.tsx @@ -62,7 +62,14 @@ export const FeatureCalloutButton = styled.button` font-size: 12px; ` -export const NameContainer = styled.div` +// A person's name / sub-heading. Exposed as an h3 for screen-reader heading +// navigation via role/aria-level (rather than a native

    ) 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; diff --git a/components/shared/FollowButton.tsx b/components/shared/FollowButton.tsx index f6d5c236b..7a09057f5 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, useMemo, useState } from "react" import { Button } from "react-bootstrap" import { useAuth } from "../auth" @@ -86,8 +87,12 @@ export const BaseFollowButton = ({ confirmUnfollow?: boolean displayName?: string }) => { - const { t } = useTranslation("common") - const uid = useAuth().user?.uid + const { t } = useTranslation(["profile"]) + + const { user } = useAuth() + const uid = user?.uid + const router = useRouter() + const { followStatus, setFollowStatus } = useContext(FollowContext) const [modalAction, setModalAction] = useState<"follow" | "unfollow" | null>( null @@ -111,15 +116,25 @@ export const BaseFollowButton = ({ } const isFollowing = followStatus[topicName] - const onClick = isFollowing - ? () => (confirmUnfollow ? setModalAction("unfollow") : UnfollowClick()) - : () => (confirmFollow ? setModalAction("follow") : FollowClick()) + const text = isFollowing ? t("button.following") : t("button.follow") + const checkmark = isFollowing ? ( + + ) : null + const handleClick = (event: any) => { + event.preventDefault() + if (!uid) { + const currentPath = router.asPath + router.push(`/login?redirect=${encodeURIComponent(currentPath)}`) + return + } + isFollowing ? UnfollowClick() : FollowClick() + } return ( <> {!hide && (
    - diff --git a/components/testimony/TestimonyDetailPage/PolicyActions.tsx b/components/testimony/TestimonyDetailPage/PolicyActions.tsx index b3636a961..c0733e8e6 100644 --- a/components/testimony/TestimonyDetailPage/PolicyActions.tsx +++ b/components/testimony/TestimonyDetailPage/PolicyActions.tsx @@ -6,6 +6,7 @@ import { formUrl } from "components/publish" import { FC, ReactElement, useContext, useEffect, useState } from "react" import { useCurrentTestimonyDetails } from "./testimonyDetailSlice" import { useTranslation } from "next-i18next" +import { useRouter } from "next/router" import { useAuth } from "components/auth" import { ballotQuestionTopicName, @@ -56,6 +57,9 @@ export const PolicyActions: FC> = ({ const { user } = useAuth() const uid = user?.uid + + const router = useRouter() + const ballotQuestionId = revision.ballotQuestionId ?? undefined const ballotQuestionTopic = ballotQuestionId ? { court: bill.court, id: ballotQuestionId } @@ -139,6 +143,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() } 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/docs/lobbying-disclosure-ingestion.md b/docs/lobbying-disclosure-ingestion.md new file mode 100644 index 000000000..99c9652a7 --- /dev/null +++ b/docs/lobbying-disclosure-ingestion.md @@ -0,0 +1,1038 @@ +# 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 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 +per-filing detail pages do not expose which individual lobbyists within a firm +worked on which bill. + +```typescript +interface LobbyingRegistrant { + 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 + 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 +} +``` + +**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 SHA-256 hash (first 40 hex chars) of the logical key +`{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 +} +``` + +**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 +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 + +### 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`: + - `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` + - 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. + +### Portal HTML format eras + +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. + +--- + +## New Files + +``` +functions/src/lobbying/ + 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 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 +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. 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 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 \ + --task-timeout=30m \ + --max-retries=0 + +# 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//jobs/maple-lobbying-scraper:run" \ + --http-method=POST \ + --oauth-service-account-email=@.iam.gserviceaccount.com \ + --location=us-central1 +``` + +## Historical Backfill + +Runs `scrape.py --mode backfill` directly. Resumable β€” the subcollection +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_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 (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, 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 +``` + +--- + +## 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. + +--- + +## 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 | 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 + +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 tests: parser, normalization, bill construction + +Run the pytest suite against all 4 HTML format eras using committed fixture +pages: + +```bash +cd lobbying-scraper +python -m pytest tests/ -v +``` + +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 2 β€” Live portal fetch: dry run + +Verify the portal is reachable and the parser returns valid data without writing +to Firestore: + +```bash +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 +``` + +Expected: 3 registrants fetched, compensation and bill rows printed, no +Firestore writes. + +### Step 3 β€” Firestore write: single year, small limit + +Write a small batch to the dev project and verify results: + +```bash +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: + +- `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 skips already-processed URLs (output shows 0 new disclosures) + +### 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: + +``` +/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 5 β€” Create GCS archive bucket (once per project) + +The archive bucket name is derived from `GOOGLE_CLOUD_PROJECT`. Create it once +with the Archive storage class (written once, read rarely): + +```bash +gsutil mb -p digital-testimony-dev -l us-central1 \ + -c archive gs://digital-testimony-dev-lobbying-archive +``` + +Repeat for prod when running the prod backfill: + +```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_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 +resumption. + +### Step 8 β€” Deploy and verify Cloud Run scraper + +Build and push the container image, then update the Cloud Run job: + +```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 update maple-lobbying-scraper \ + --image=$IMAGE \ + --project=digital-testimony-dev \ + --region=us-central1 +``` + +Trigger a manual run via the Cloud Run console or: + +```bash +gcloud run jobs execute maple-lobbying-scraper \ + --project=digital-testimony-dev \ + --region=us-central1 +``` + +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. + +--- + +## 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 | `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 | +| 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 + +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/firestore.indexes.json b/firestore.indexes.json index 83cb3fa6d..e78707bc2 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,118 @@ } } ] + }, + { + "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" + } + ] + }, + { + "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 a95586279..df5659ac5 100644 --- a/firestore.rules +++ b/firestore.rules @@ -103,6 +103,18 @@ 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 /lobbyingMeta/{id} { + allow read: if true; + allow write: if false; + } match /transcriptions/{tid} { // public, read-only allow read: if true 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/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..a62a91a00 --- /dev/null +++ b/functions/src/districts/parseSecDistricts.test.ts @@ -0,0 +1,180 @@ +import { + displayDistrictName, + districtId, + normalizeDistrictName +} from "./normalize" +import { parseSecDistricts } from "./parseSecDistricts" + +const sourceUrl = "https://example.test/districts" + +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", () => { + 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 the expected number of Senate and House districts", () => { + expect( + parseSecDistricts(buildSenateSnapshotHtml(40), { + branch: "Senate", + sourceUrl + }) + ).toHaveLength(40) + expect( + parseSecDistricts(buildHouseSnapshotHtml(160), { + branch: "House", + sourceUrl + }) + ).toHaveLength(160) + }) + + 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..52c945a25 --- /dev/null +++ b/functions/src/districts/parseSecDistricts.ts @@ -0,0 +1,108 @@ +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*β–²?\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" && + / 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(stripIgnoredTags(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/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..7e19f8bac --- /dev/null +++ b/functions/src/events/HearingScraper.ts @@ -0,0 +1,352 @@ +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, + bucketName, + refetch = false + }: { + limit?: number + bucketName?: string + 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, { + bucketName + }) + 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, { + bucketName + }) + count += 1 + } + return true + } + + async submitNextTranscription( + eventId: number, + videos: Video[], + { + bucketName + }: { + bucketName?: string + } = {} + ): Promise { + const nextTranscription = videos.findIndex(item => !item.transcriptionId) + if (nextTranscription < 0) { + return null + } + const result = await assemblyAI().submitTranscription({ + EventId: eventId, + videoUrl: videos[nextTranscription].url, + bucketName: bucketName + }) + 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 4101b41d1..bec2028b8 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: Union(String, Null) +}) + 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/functions/src/index.ts b/functions/src/index.ts index 796a30871..e11b30569 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -17,6 +17,7 @@ export { } from "./committees" export { scrapeHearings, + scrapeVideos, scrapeSessions, scrapeSpecialEvents, scrapeSingleHearing, @@ -57,10 +58,12 @@ export { unfollowUser, getFollowers } from "./subscriptions" +export { scrapeElections } from "./legislators" export { transcription } from "./webhooks" export { matchOcpfMembers } from "./ocpf/matchOcpfMembers" +export { scrapeOcpfFinance } from "./ocpf/scrapeOcpfFinance" export * from "./triggerPubsubFunction" 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..fe7cb22e9 --- /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 + } + logger.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/functions/src/lobbying/index.ts b/functions/src/lobbying/index.ts new file mode 100644 index 000000000..6d039ae51 --- /dev/null +++ b/functions/src/lobbying/index.ts @@ -0,0 +1,2 @@ +export * from "./types" +export { normalizeEntityName } from "./normalize" diff --git a/functions/src/lobbying/normalize.ts b/functions/src/lobbying/normalize.ts new file mode 100644 index 000000000..a7beb338f --- /dev/null +++ b/functions/src/lobbying/normalize.ts @@ -0,0 +1,72 @@ +/** + * 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. + * + * 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/types.ts b/functions/src/lobbying/types.ts new file mode 100644 index 000000000..7181fd68d --- /dev/null +++ b/functions/src/lobbying/types.ts @@ -0,0 +1,102 @@ +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), + legacyTotalCompensation: Null.Or(Number), + 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/functions/src/ocpf/scrapeOcpfFinance.ts b/functions/src/ocpf/scrapeOcpfFinance.ts new file mode 100644 index 000000000..bb39e1edc --- /dev/null +++ b/functions/src/ocpf/scrapeOcpfFinance.ts @@ -0,0 +1,633 @@ +// 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, + 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) +]) + +// 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 { + 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 + 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: { + 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 } + } + 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, + startBalance: 0, + startBalanceStartDateMs: Infinity, + 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 } + }, + 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, + startBalance: acc.startBalance, + 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, + 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"], + startBalance: ["start_balance"], + endBalance: ["end_balance"], + startDate: ["start_date"], + 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 startBalance = parseFloat(col(cols, idx.startBalance)) || 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 + } + + 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 + // 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 + } + // 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 + // 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 (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) + 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) totals sourced from reports.txt, not items + default: + break + } +} diff --git a/functions/src/ocpf/types.ts b/functions/src/ocpf/types.ts index ae61e2ce9..89b5b1b1f 100644 --- a/functions/src/ocpf/types.ts +++ b/functions/src/ocpf/types.ts @@ -38,4 +38,60 @@ 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 MembersFinanceYearData { + totalRaised: number + totalSpent: number + breakdown: MembersFinanceBreakdown + finalized: boolean +} + +// 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. + 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 + years: Record } 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/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..4b2da65b5 --- /dev/null +++ b/lobbying-scraper/Dockerfile @@ -0,0 +1,16 @@ +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 + +# ENTRYPOINT is the fixed executable; CMD provides default args that --args overrides. +ENTRYPOINT ["python3", "scrape.py"] +CMD ["--mode", "weekly"] diff --git a/lobbying-scraper/archive.py b/lobbying-scraper/archive.py new file mode 100644 index 000000000..ede5d6656 --- /dev/null +++ b/lobbying-scraper/archive.py @@ -0,0 +1,60 @@ +"""GCS raw-HTML archive for the MA SoS lobbying scraper. + +Write-only cold storage: every fetched Summary/CompleteDisclosure page is +saved as gs://{bucket}/raw_html/{sha1(url)}.html with the original URL stored +as object metadata. This enables offline reparsing when parser logic changes +without re-scraping the portal (which is rate-limited and Imperva-protected). + +Enabled by setting ARCHIVE_RAW=1 in the environment. When disabled (default), +save_page() is a no-op so local runs and tests work without GCS credentials. + +The bucket is named {GOOGLE_CLOUD_PROJECT}-lobbying-archive and should be +created with the Archive storage class (written once, read rarely). +""" + +from __future__ import annotations + +import hashlib +import os + +_ENABLED = os.environ.get("ARCHIVE_RAW", "").lower() in ("1", "true", "yes") +_bucket_name: str | None = None +_client = None # google.cloud.storage.Client, lazily initialized + + +def _gcs(): + global _client + if _client is None: + from google.cloud import storage # noqa: PLC0415 + _client = storage.Client() + return _client + + +def _get_bucket_name() -> 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/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..cf871328e --- /dev/null +++ b/lobbying-scraper/portal.py @@ -0,0 +1,515 @@ +"""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 + +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 + +import hashlib +import re +import time +from dataclasses import dataclass, field +from typing import Optional + +import requests +from bs4 import BeautifulSoup, Tag + +import archive + +# ── 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 = 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-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 +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) + legacy_total_compensation: Optional[float] = None + + +# ── 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) + except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: + if attempt == _MAX_RETRIES - 1: + raise + 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: + 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) + except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: + if attempt == _MAX_RETRIES - 1: + raise + 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 ─────────────────────────────────────────────────────────── + + +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 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 "" + + 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 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: + return float(cleaned) + except ValueError: + return None + + +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 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 / 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")] + if len(cells) >= 2: + compensation.append(Compensation( + client_name=cells[0], + amount=_parse_amount(cells[1]), + )) + + # 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 ""), + ): + 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")] + if len(cells) < 4: + continue + 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, + 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=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 (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 []) + ] + + 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 + + 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 + # 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 + 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=client_name, + chamber=chamber, + raw_bill_number=number, + bill_id=bill_id, + 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: + return DisclosureDetail( + compensation=compensation, + bills=bills, + legacy_total_compensation=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..3ea536de5 --- /dev/null +++ b/lobbying-scraper/reparse_archive.py @@ -0,0 +1,148 @@ +"""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 via GCS object metadata (reparse-processed=true) 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 +from google.cloud.storage import Blob + +import archive +from portal import DisclosureMeta, parse_disclosure_detail +from writer import REGISTRANTS_COLLECTION, write_filings + +_PROCESSED_META_KEY = "reparse-processed" + + +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(blob: Blob) -> bool: + return (blob.metadata or {}).get(_PROCESSED_META_KEY) == "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: + gcs = storage.Client() + bucket_name = archive._get_bucket_name() + bucket = gcs.bucket(bucket_name) + + db = 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 _is_processed(blob): + skipped += 1 + continue + + 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, meta.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: + write_filings(db, meta, detail) + _mark_processed(blob) + + 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 new file mode 100644 index 000000000..d92c075e4 --- /dev/null +++ b/lobbying-scraper/requirements.txt @@ -0,0 +1,4 @@ +requests>=2.28 +beautifulsoup4>=4.12 +google-cloud-firestore>=2.14 +google-cloud-storage>=2.10 diff --git a/lobbying-scraper/scrape.py b/lobbying-scraper/scrape.py new file mode 100644 index 000000000..470732c66 --- /dev/null +++ b/lobbying-scraper/scrape.py @@ -0,0 +1,307 @@ +"""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 os +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, + 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 _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 ( + 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 _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: + 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 = _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 = _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()} + ) + + +# ── 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 + use_cursor = db is not None and not dry_run + + 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: + # 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 + 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 + + 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 + ) + new_count += 1 + print(f" processed: {comp_n} clients, {filing_n} filings") + if use_cursor: + _mark_processed(db, disc_url) + 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)) + + 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) + 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/tests/__init__.py b/lobbying-scraper/tests/__init__.py new file mode 100644 index 000000000..e69de29bb 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 000000000..4311a97da Binary files /dev/null and b/lobbying-scraper/tests/fixtures/2007e_disc.html.gz differ 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 000000000..58d40abbe Binary files /dev/null and b/lobbying-scraper/tests/fixtures/2007e_summ.html.gz differ diff --git a/lobbying-scraper/tests/fixtures/2011e_disc.html.gz b/lobbying-scraper/tests/fixtures/2011e_disc.html.gz new file mode 100644 index 000000000..2a01a7963 Binary files /dev/null and b/lobbying-scraper/tests/fixtures/2011e_disc.html.gz differ diff --git a/lobbying-scraper/tests/fixtures/2011e_summ.html.gz b/lobbying-scraper/tests/fixtures/2011e_summ.html.gz new file mode 100644 index 000000000..d7b525310 Binary files /dev/null and b/lobbying-scraper/tests/fixtures/2011e_summ.html.gz differ 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 000000000..84736b4a7 Binary files /dev/null and b/lobbying-scraper/tests/fixtures/2011i_disc.html.gz differ 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 000000000..430f68591 Binary files /dev/null and b/lobbying-scraper/tests/fixtures/2011i_summ.html.gz differ 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 000000000..279fd59c0 Binary files /dev/null and b/lobbying-scraper/tests/fixtures/2016e_disc.html.gz differ 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 000000000..a0bc28b85 Binary files /dev/null and b/lobbying-scraper/tests/fixtures/2016e_summ.html.gz differ 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 000000000..9074511f5 Binary files /dev/null and b/lobbying-scraper/tests/fixtures/2024e_disc.html.gz differ diff --git a/lobbying-scraper/tests/fixtures/2024e_summ.html.gz b/lobbying-scraper/tests/fixtures/2024e_summ.html.gz new file mode 100644 index 000000000..cb6181711 Binary files /dev/null and b/lobbying-scraper/tests/fixtures/2024e_summ.html.gz differ 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 000000000..44dddc2ef Binary files /dev/null and b/lobbying-scraper/tests/fixtures/2024i_disc.html.gz differ 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 000000000..e6dc3b1b6 Binary files /dev/null and b/lobbying-scraper/tests/fixtures/2024i_summ.html.gz differ diff --git a/lobbying-scraper/tests/test_portal_parser.py b/lobbying-scraper/tests/test_portal_parser.py new file mode 100644 index 000000000..7bf5bb371 --- /dev/null +++ b/lobbying-scraper/tests/test_portal_parser.py @@ -0,0 +1,182 @@ +"""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) +# 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, 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"), + ("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_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 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(): + """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) 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 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/tests/test_writer.py b/lobbying-scraper/tests/test_writer.py new file mode 100644 index 000000000..df0ccbcda --- /dev/null +++ b/lobbying-scraper/tests/test_writer.py @@ -0,0 +1,143 @@ +"""Unit tests for Firestore document construction in writer.py. + +Uses unittest.mock to intercept Firestore calls so no live database is needed. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch, call + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from portal import BillActivity, Compensation, DisclosureDetail, DisclosureMeta +from writer import write_registrant, write_filings + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _make_db(): + """Return a MagicMock that behaves like firestore.Client.""" + db = MagicMock() + # db.collection(x).document(y).set(data, merge=True) + doc_ref = MagicMock() + db.collection.return_value.document.return_value = doc_ref + return db, doc_ref + + +def _captured_data(doc_ref) -> 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() diff --git a/lobbying-scraper/writer.py b/lobbying-scraper/writer.py new file mode 100644 index 000000000..c6d4958f8 --- /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 google.cloud import firestore +from normalize import normalize_entity_name +from portal import ( + BillActivity, + Compensation, + DisclosureDetail, + DisclosureMeta, + filing_id, + registrant_id, + year_to_general_court, +) + +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" + + +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, + "legacyTotalCompensation": detail.legacy_total_compensation, + "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/next.config.js b/next.config.js index c8980b67e..154e17d70 100644 --- a/next.config.js +++ b/next.config.js @@ -17,7 +17,29 @@ const config = { module.exports = { ...config, async redirects() { - return [redirectFirebaseAuthHandlers()] + return [ + redirectFirebaseAuthHandlers(), + // 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 + // rather than at the top. "testimony-basics" was the overview tab, which + // the whole page now covers, so it has no anchor. + ...[ + "testimony-basics", + "role-of-testimony", + "communicating-with-legislators" + ].map(slug => ({ + source: `/learn/${slug}`, + destination: + slug === "testimony-basics" + ? "/learn/testimony" + : `/learn/testimony#${slug}`, + permanent: true + })) + ] }, async rewrites() { const rewrites = [ diff --git a/pages/about/how-maple-uses-ai.tsx b/pages/about/how-maple-uses-ai.tsx index 82a8d8934..9eabee533 100644 --- a/pages/about/how-maple-uses-ai.tsx +++ b/pages/about/how-maple-uses-ai.tsx @@ -5,11 +5,7 @@ import MapleAI from "components/about/MapleAI/MapleAI" export default createPage({ titleI18nKey: "navigation.ai", Page: () => { - return ( -
    - -
    - ) + return } }) diff --git a/pages/about/support-maple.tsx b/pages/about/support-maple.tsx index dfdb31de9..96c4b6e31 100644 --- a/pages/about/support-maple.tsx +++ b/pages/about/support-maple.tsx @@ -4,13 +4,7 @@ import { createGetStaticTranslationProps } from "components/translations" export default createPage({ titleI18nKey: "titles.support_maple", - Page: () => { - return ( -
    - -
    - ) - } + Page: () => }) export const getStaticProps = createGetStaticTranslationProps([ 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/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 = { 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/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/pages/legislators/[court]/[memberCode].tsx b/pages/legislators/[court]/[memberCode].tsx new file mode 100644 index 000000000..5c6357718 --- /dev/null +++ b/pages/legislators/[court]/[memberCode].tsx @@ -0,0 +1,52 @@ +import { GetServerSideProps } from "next" +import { serverSideTranslations } from "next-i18next/serverSideTranslations" +import { z } from "zod" + +import { flags } from "components/featureFlags" +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: "navigation.legislator", + Page: ({ court, memberCode }) => ( + + ) +}) + +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" + ) + + 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", + "legislators", + "profile", + "testimony" + ])) + } + } +} 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" -]) 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/homepage/feature-preview-ballot-question.png b/public/homepage/feature-preview-ballot-question.png deleted file mode 100644 index ea5cbcb4a..000000000 Binary files a/public/homepage/feature-preview-ballot-question.png and /dev/null differ diff --git a/public/homepage/feature-preview-browse-bills.png b/public/homepage/feature-preview-browse-bills.png new file mode 100644 index 000000000..5623ca87b Binary files /dev/null and b/public/homepage/feature-preview-browse-bills.png differ diff --git a/public/locales/en/common.json b/public/locales/en/common.json index ea6789c8f..0750419c9 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", @@ -131,12 +131,13 @@ "newsfeed": "Newsfeed", "openNavMenu": "Open Navigation Menu", "openProfileMenu": "Open Profile Menu", - "team": "Team", + "team": "Our Team", "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", @@ -199,7 +200,10 @@ "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", + "writing_testimony": "How to Write Effective Testimony" }, "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/forindividuals.json b/public/locales/en/forindividuals.json index 5443654ce..196dd4223 100644 --- a/public/locales/en/forindividuals.json +++ b/public/locales/en/forindividuals.json @@ -1,23 +1,25 @@ { "title": "MAPLE for Individuals", + "tabLabel": "Individuals", + "personaLabel": "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.", - "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." + "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 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 multiples 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." + "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. 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", @@ -35,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 e24b0c0b1..c1d6168c2 100644 --- a/public/locales/en/forlegislators.json +++ b/public/locales/en/forlegislators.json @@ -1,9 +1,11 @@ { "title": "MAPLE for Legislators", + "tabLabel": "Legislators", + "personaLabel": "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.", - "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.", + "header": "Why use MAPLE", + "title": "Gain free, nonpartisan insight into how constituents are thinking about any proposed measure.", + "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": { @@ -13,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", @@ -34,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 aaedc16f6..c8d672607 100644 --- a/public/locales/en/fororgs.json +++ b/public/locales/en/fororgs.json @@ -1,45 +1,47 @@ { "title": "MAPLE for Organizations", + "tabLabel": "Organizations", + "personaLabel": "Organizations", "callToAction": { - "header": "Why use MAPLE", - "title": "MAPLE is a powerful communication tool for organizations, offered completely for free.", + "header": "Why use MAPLE", + "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", - "linkText": "searching for a bill", + "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?" }, @@ -48,7 +50,13 @@ "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!", + "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/goalsandmission.json b/public/locales/en/goalsandmission.json index 494e2607d..4a49b42b5 100644 --- a/public/locales/en/goalsandmission.json +++ b/public/locales/en/goalsandmission.json @@ -1,23 +1,27 @@ { "header": "Our Goals and Mission", + "breadcrumb": "Mission & Goals", "goals": { "title": "Our Goals", "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.", "publish1": "When you submit testimony through the MAPLE platform, it is published in our freely accessible online database (this website), allowing legislators and other stakeholders to read and benefit from your perspective. MAPLE also assists in identifying the appropriate legislative recipients for your testimony and prepares a ready-to-send email on your behalf.", "publish2": "By facilitating the submission and sharing of testimony, we aim to enhance self-governance and contribute to better policy outcomes that reflect the needs, values, and objectives of Massachusetts residents. While using MAPLE is not required to submit testimony, each contribution enriches our collective understanding and empowers more individuals to engage effectively in the legislative process.", - "submit_now": "Submit your testimony now!" + "submit_now": "Submit your testimony now!", + "ctaHeadline": "Ready to get started?", + "ctaBody": "Whether you're a resident, an organization, or a legislator, discover the ways MAPLE makes civic engagement easier.", + "ctaSecondary": "Learn More" } } 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" }, diff --git a/public/locales/en/inTheNews.json b/public/locales/en/inTheNews.json index 5d1cb3243..c84b05ac5 100644 --- a/public/locales/en/inTheNews.json +++ b/public/locales/en/inTheNews.json @@ -1,6 +1,8 @@ { "title": "Media, Articles & Insights", - "readMoreButton": "READ MORE", + "breadcrumb": "In the News", + "subhead": "Press coverage, awards, and books featuring MAPLE and its community.", + "readMoreButton": "Read more", "media": { "title": "Media" }, diff --git a/public/locales/en/learn.json b/public/locales/en/learn.json new file mode 100644 index 000000000..439bbdc86 --- /dev/null +++ b/public/locales/en/learn.json @@ -0,0 +1,240 @@ +{ + "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": "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 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": "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": "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": "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": [ + { + "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 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 to legislators." + } + ] + }, + "readyToStart": "Ready to get started?", + "how": { + "headline": "Making your voice heard", + "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. 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", + "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 contact info:", + "linkText": "malegislature.gov/Search/FindMyLegislator", + "href": "https://malegislature.gov/Search/FindMyLegislator" + } + ] + }, + "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": "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 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": "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 ways to make testimony more impactful" + }, + "include": { + "headline": "What every letter should include", + "intro": "Make sure a reader can understand these five things after reading your testimony.", + "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 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 including your district is important here." + }, + { + "title": "Why you care about this", + "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? Being specific allows staffers to act on your feedback." + } + ] + }, + "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 are a citizen, an organization leader, or a legislator, MAPLE can support you." + }, + "process": { + "breadcrumb": "Legislative Process", + "title": "Understanding the Legislative Process", + "subhead": "How most bills become laws in Massachusetts.", + "railLabel": "Legislative process stages", + "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 Massachusetts resident can petition their legislator to file a bill.", + "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": "7,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 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", + "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": "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', 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", + "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 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/learnComponents.json b/public/locales/en/learnComponents.json index c4c9d44cc..c8158ce1f 100644 --- a/public/locales/en/learnComponents.json +++ b/public/locales/en/learnComponents.json @@ -1,142 +1,9 @@ { - "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.", "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." } } diff --git a/public/locales/en/legislators.json b/public/locales/en/legislators.json index fecb79040..c422d3e01 100644 --- a/public/locales/en/legislators.json +++ b/public/locales/en/legislators.json @@ -1,11 +1,50 @@ { + "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", + "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.", + "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", + "loading": "Loading district...", + "notClaimed": "This legislator has not claimed their MAPLE account", "party": { "democratic": "Democratic Party", "party": "Party", @@ -13,6 +52,7 @@ }, "stateRepresentative": "State Representative", "stateSenator": "State Senator", + "submit": "Submit", "tabs": { "bills": "Bills", "district": "District", diff --git a/public/locales/en/mapleAI.json b/public/locales/en/mapleAI.json index 96f6bf398..bdb6223ec 100644 --- a/public/locales/en/mapleAI.json +++ b/public/locales/en/mapleAI.json @@ -1,15 +1,16 @@ { "title": "How MAPLE Uses AI", + "breadcrumb": "How MAPLE Uses AI", "mission-statement": "Our commitment is to provide you with the most reliable and accessible legislative information using cutting-edge AI technology. Stay tuned for continuous improvements and new features as we enhance MAPLE’s AI capabilities.", "legal-advisory": "We advise users to consult the full text of the legislation and official resources for the most accurate and comprehensive understanding.", "section1": { - "title": "How MAPLE Uses AI", + "title": "How MAPLE Uses Gen AI", "desc1": "We use Artificial Intelligence (AI) on MAPLE to help you quickly understand legislative information and navigate to policies that interest you. Right now, we use AI in three ways:", - "desc2Bold": "Bill Summaries", + "desc2Bold": "Plain-language Summaries:", "desc2Main": "We use AI to generate concise summaries for each piece of legislation. These summaries help you grasp the essentials of a bill without wading through dense legal text.", - "desc3Bold": "Topic Tags", + "desc3Bold": "Dynamic Tagging:", "desc3Main": "AI tags each bill with relevant topics, making it easier for you to find related legislation and understand the broader context. These tags categorize bills by subject matter, allowing for efficient searching and filtering.", - "desc4Bold": "Hearing transcriptions", + "desc4Bold": "Speech-to-text:", "desc4Main": "AI transcribes video recordings of oral hearings held at the legislature to generate a searchable text transcript." }, "section2": { @@ -24,12 +25,12 @@ "desc4Main": "We integrate feedback from a range of stakeholders to build our AI deployments, and solicit community input to continually improve our use of AI. You can email any questions or ideas to admin@mapletestimony.org." }, "section3": { - "title": "Limitations and Risks of AI", + "title": "Disclaimers", "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." @@ -37,20 +38,9 @@ "section4": { "title": "About Our AI Systems", "sub1": { - "title": "Overview", - "desc1": "Our AI features are built with foundation models and language processing technologies. Using a large language model, we generate summaries and tags by analyzing the full text of each bill and associated metadata, including the relevant sections of the existing law. Using a speech-to-text model, we transcribe audio recordings of hearings.", - "desc2": "Key features of our AI deployment include:", - "desc3Bold": "Real-time Summaries:", - "desc3Main": "AI generates summaries as new bills are filed or updated.", - "desc4Bold": "Dynamic Tagging:", - "desc4Main": "Bills are tagged with multiple relevant topics, helping you find similar legislation quickly.", - "desc5Bold": "Transcriptions:", - "desc5Main": "Oral hearing recordings are transcribed to text, making it easier to find discussion about particular bills and issues.", - "desc6Bold": "Scalability:", - "desc6Main": "Our system is designed to handle the large volume of bills in each legislative session, ensuring timely and accurate updates." + "desc1": "Our AI features are built with foundation models and language processing technologies. Using a large language model, we generate summaries and tags by analyzing the full text of each bill and associated metadata, including the relevant sections of the existing law. Using a speech-to-text model, we transcribe audio recordings of hearings. Our system is designed to handle the large volume of bills in each legislative session, ensuring timely and accurate updates." }, "sub2": { - "title": "Technical details", "desc1Bold": "Backend Integration:", "desc1Main": "We use Python functions to call commercially available AI models, integrating seamlessly with our backend systems.", "desc2Bold": "API Utilization:", @@ -67,7 +57,7 @@ "section5": { "title": "Future Deployments of AI", "desc1": "We are continually exploring new ways to leverage AI to improve your experience on MAPLE. Future enhancements may include more personalized recommendations, deeper insights into legislative trends, and interactive tools to engage with legislative data more effectively. Here are some projects we are considering:", - "desc2Bold": "Testimony Summarizer:", + "desc2Bold": "Testimony Synthesizer:", "desc2Main": "AI will summarize all of the testimonies on a single bill or related bills, and identify points of consensus, disagreement and notable points.", "desc3Bold": "Testimony-Drafting Copilot:", "desc3Main": "AI will help a user organize their thoughts, compose appropriate testimony and cite relevant sources.", diff --git a/public/locales/en/our-team.json b/public/locales/en/our-team.json index 5883a6dc7..df7503193 100644 --- a/public/locales/en/our-team.json +++ b/public/locales/en/our-team.json @@ -1,4 +1,6 @@ { + "title": "Our Team", + "breadcrumb": "Our Team", "advisory": { "title": "Advisory Board", "desc": "The Advisory Board provides strategic advice and domain expertise to the MAPLE project. We are grateful to have the support of:", diff --git a/public/locales/en/partners.json b/public/locales/en/partners.json index 12b098994..1fe478bc1 100644 --- a/public/locales/en/partners.json +++ b/public/locales/en/partners.json @@ -1,14 +1,14 @@ { "title": "Our Partners", "logo": "{{org}} logo", - "desc": "The project is developed in partnership between the NuLawLab and scholars at Boston College Law School and Harvard University's Berkman Klein Center for Internet & Society.", + "desc": "The project was developed in partnership between the NuLawLab and scholars at Boston College Law School and Harvard University's Berkman Klein Center for Internet & Society.", "nulawlab": { "title": "NuLawLab", - "content": "The NuLawLab is the interdisciplinary innovation laboratory at Northeastern University School of Law.

    The Lab is a leader in the global legal design movement and incubates projects that advance the democratization of law." + "content": "The NuLawLab is the interdisciplinary innovation laboratory at Northeastern University School of Law.

    The Lab is a leader in the global legal design movement and incubates projects that advance the democratization of law." }, "codeforboston": { "title": "Code for Boston", - "content": "Code for Boston addresses local social and civic challenges through creative uses of technology. Despite the name, they are not solely focused on coding!

    They foster relationships between government, nonprofit, academic, for-profit companies, residents, civic technologists, analysts, designers, and many more. Code for Boston's volunteer contributors have led the technical implementation and development of this website and platform as an open source project (see our repository on GitHub)." + "content": "Code for Boston addresses local social and civic challenges through creative uses of technology. Despite the name, they are not solely focused on coding!

    They foster relationships between government, nonprofit, academic, for-profit companies, residents, civic technologists, analysts, designers, and many more. Code for Boston's volunteer contributors have led the technical implementation and development of this website and platform as an open source project (see our repository on GitHub)." }, "pid": { "title": "Partners in Democracy", diff --git a/public/locales/en/supportmaple.json b/public/locales/en/supportmaple.json index c931bd69f..5e8cd2907 100644 --- a/public/locales/en/supportmaple.json +++ b/public/locales/en/supportmaple.json @@ -1,5 +1,7 @@ { "title": "How to Support MAPLE", + "subhead": "MAPLE is a volunteer-powered nonprofit. Here are a few ways you can help.", + "breadcrumb": "Support MAPLE", "useMAPLE": { "header": "Use our Platform!", "bodytext": "The best way to support MAPLE is by using our platform and sharing it with friends and colleagues! That is the best endorsement we could receive." @@ -16,10 +18,10 @@ "bodytextTwo": "and that doesn't include our advisors, partners and communication team! If you'd like to get involved, email " }, "feedback": { - "header": "Feedback", + "header": "Share Feedback", "bodytextOne": "If you would like to share thoughts on your experience using MAPLE or suggest improvements for the platform, please use our ", "feedbackForm": "feedback form!", "bodytextTwo": "You can also let us know about 'bugs' you've encountered in our ", "bugForm": "report a bug form." - } -} \ No newline at end of file + } +} diff --git a/public/northeastern_school_of_law_logo.png b/public/northeastern_school_of_law_logo.png new file mode 100644 index 000000000..3a9f77268 Binary files /dev/null and b/public/northeastern_school_of_law_logo.png differ 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/scripts/firebase-admin/backfillHearingTranscription.ts b/scripts/firebase-admin/backfillHearingTranscription.ts index a9c19daeb..fc004a2b8 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,15 @@ 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, + bucketName: bucketName + }) + if (changed) { + console.log(`Transcriptions submitted for hearing ${eventId}`) } } catch (error) { console.error(`Failed to process hearing ${eventId}:`, error) @@ -60,40 +45,24 @@ 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, + bucketName: bucketName + }) - 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/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/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/scripts/firebase-admin/scrapeLegislativeDistricts.ts b/scripts/firebase-admin/scrapeLegislativeDistricts.ts new file mode 100644 index 000000000..97a11765a --- /dev/null +++ b/scripts/firebase-admin/scrapeLegislativeDistricts.ts @@ -0,0 +1,110 @@ +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, 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: { + "User-Agent": + "Mozilla/5.0 (compatible; MAPLE district backfill; +https://mapletestimony.org)" + }, + responseType: "text", + timeout: 60_000 + }) + 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 => { + 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, args }) => { + const districts = ( + await Promise.all( + sources.map(async source => { + const html = await readHtml(source, args) + 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}` + ) +} 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) 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 = { 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}; diff --git a/styles/globals.css b/styles/globals.css index e19cd1372..a891cb497 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -262,13 +262,49 @@ } #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; } +/* 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. */ +#basic-navbar-nav .dropdown-menu .dropdown-item, +#basic-navbar-nav .dropdown-menu .navLink-primary { + color: var(--maple-text-body) !important; + font-weight: 400; +} + +/* 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 .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; +} + .offcanvas { background-color: var(--bs-body-bg) !important; } 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