From 06a77c51ccc3f4d57aac42d9d7163f110e6b900d Mon Sep 17 00:00:00 2001 From: Sam DeMarrais Date: Tue, 25 Aug 2026 21:47:02 -0400 Subject: [PATCH 01/18] Added roll calls --- functions/src/index.ts | 1 + functions/src/malegislature.ts | 28 +- functions/src/votes/index.ts | 6 + functions/src/votes/scrapeRollCall.test.ts | 51 + functions/src/votes/scrapeRollCall.ts | 312 +++++ functions/src/votes/scrapers.ts | 316 +++++ functions/src/votes/types.ts | 225 ++++ scripts/firebase-admin/backfillRollCalls.ts | 83 ++ tests/fixtures/votes/193legislators.json | 646 ++++++++++ tests/fixtures/votes/HouseRollCall19.pdf | Bin 0 -> 45313 bytes tests/fixtures/votes/HouseRollCall19Read.json | 1117 +++++++++++++++++ .../votes/HouseRollCall19Translation.json | 160 +++ 12 files changed, 2943 insertions(+), 2 deletions(-) create mode 100644 functions/src/votes/index.ts create mode 100644 functions/src/votes/scrapeRollCall.test.ts create mode 100644 functions/src/votes/scrapeRollCall.ts create mode 100644 functions/src/votes/scrapers.ts create mode 100644 functions/src/votes/types.ts create mode 100644 scripts/firebase-admin/backfillRollCalls.ts create mode 100644 tests/fixtures/votes/193legislators.json create mode 100644 tests/fixtures/votes/HouseRollCall19.pdf create mode 100644 tests/fixtures/votes/HouseRollCall19Read.json create mode 100644 tests/fixtures/votes/HouseRollCall19Translation.json diff --git a/functions/src/index.ts b/functions/src/index.ts index 0c8a0cbd3..0fdf0ada7 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -63,6 +63,7 @@ export { export { scrapeElections } from "./legislators" export { transcriptionV2 } from "./webhooks" +export { scrapeHouseRollCalls, scrapeSenateRollCalls } from "./votes" export { matchOcpfMembersV2 } from "./ocpf/matchOcpfMembers" export { scrapeOcpfFinanceV2 } from "./ocpf/scrapeOcpfFinance" diff --git a/functions/src/malegislature.ts b/functions/src/malegislature.ts index df0d93f01..1d73d68cc 100644 --- a/functions/src/malegislature.ts +++ b/functions/src/malegislature.ts @@ -12,8 +12,10 @@ import { SessionContent, SpecialEventContent } from "./events/types" +import { APIDocument, APIRollCall } from "./votes/types" import { Timestamp } from "./firebase" import { MemberContent } from "./members/types" +import { RollCallSummary } from "./votes/types" /** * The MA Legislature website's SSL certificate only contains the first @@ -91,13 +93,13 @@ export async function getDocument({ }: { id: string court: number -}): Promise { +}): Promise { const response = await request({ url: `/GeneralCourts/${court}/Documents/${id}`, method: "GET", timeout: 30_000 }) - return response as any + return APIDocument.check(response) } export async function getDocumentPdf({ @@ -250,3 +252,25 @@ export async function getSimilarBills(court: number, id: string) { }) return SimilarBills.check(data) } + +export async function getRollCalls(court: number, branch: string) { + const data = await request({ + url: `GeneralCourts/${court}/Branches/${branch}/RollCalls`, + method: "GET", + timeout: 60_000 + }) + return Array(RollCallSummary).check(data) +} + +export async function getRollCall( + court: number, + branch: string, + number: number +): Promise { + const data = await request({ + url: `GeneralCourts/${court}/Branches/${branch}/RollCalls/${number}`, + method: "GET", + timeout: 60_000 + }) + return APIRollCall.check(data) +} diff --git a/functions/src/votes/index.ts b/functions/src/votes/index.ts new file mode 100644 index 000000000..870eb4ab1 --- /dev/null +++ b/functions/src/votes/index.ts @@ -0,0 +1,6 @@ +export { + scrapeHouseRollCalls, + scrapeSenateRollCalls, + HouseRollCallScraper, + SenateRollCallScraper +} from "./scrapers" diff --git a/functions/src/votes/scrapeRollCall.test.ts b/functions/src/votes/scrapeRollCall.test.ts new file mode 100644 index 000000000..5b851575a --- /dev/null +++ b/functions/src/votes/scrapeRollCall.test.ts @@ -0,0 +1,51 @@ +import { + getHouseRollCall, + HouseRollCallResult, + translateNames +} from "./scrapeRollCall" + +import fs from "fs" +import path from "path" + +const FIXTURES_DIR = path.resolve(__dirname, "../../../tests/fixtures/votes") + +const pdf = fs.readFileSync(path.join(FIXTURES_DIR, "HouseRollCall19.pdf")) + +global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + arrayBuffer: async () => + pdf.buffer.slice(pdf.byteOffset, pdf.byteOffset + pdf.byteLength) +}) as jest.Mock + +describe("getHouseRollCall test", () => { + it("HouseRollCall19", async () => { + const legislators_file = fs.readFileSync( + path.join(FIXTURES_DIR, "193legislators.json"), + "utf8" + ) + const legislators: [string, string][] = JSON.parse(legislators_file) + + const actualRead = await getHouseRollCall(193, 19) + const expectedRead = JSON.parse( + fs.readFileSync( + path.join(FIXTURES_DIR, "HouseRollCall19Read.json"), + "utf8" + ) + ) + expectedRead.time = new Date(expectedRead.time) + expect(actualRead).toEqual(expectedRead) + const expectedTranslation = JSON.parse( + fs.readFileSync( + path.join(FIXTURES_DIR, "HouseRollCall19Translation.json"), + "utf8" + ) + ) + const read = actualRead + const names = read.votes.map( + vote => [vote.initial, vote.lastName] as [string | null, string] + ) + const actualTranslation = translateNames(names, legislators) + expect(actualTranslation).toEqual(expectedTranslation) + }) +}) diff --git a/functions/src/votes/scrapeRollCall.ts b/functions/src/votes/scrapeRollCall.ts new file mode 100644 index 000000000..15d5aa318 --- /dev/null +++ b/functions/src/votes/scrapeRollCall.ts @@ -0,0 +1,312 @@ +import pdfParse from "pdf-parse" +import { RollCallResponse } from "./types" +import { db } from "../firebase" +import { timeZone } from "../malegislature" +import { DateTime } from "luxon" + +type HouseRollCallVote = { + lastName: string + initial: string | null + vote: RollCallResponse + afterVote: boolean + highlighted: boolean +} + +function parseTime(timeStr: string): Date { + const time = DateTime.fromFormat(timeStr, "M/d/yyyy h:mm a", { + zone: timeZone + }) + + if (!time.isValid) { + throw new Error(`Invalid date/time: ${time.invalidExplanation}`) + } + + return time.toJSDate() +} + +function parseVote( + nameLine: string, + statusLine: string +): HouseRollCallVote | string { + let entry = { + afterVote: false, + highlighted: false + } + if (nameLine[nameLine.length - 1] == "*") { + entry.afterVote = true + nameLine = nameLine.slice(0, -1).trimEnd() + } + if (/^--.*--$/.test(nameLine)) { + nameLine = nameLine.slice(2, -2).trim() + entry.highlighted = true + } + + let initial + let lastName + const nameMatch = nameLine.match(/^([^,]*),? ([a-zA-Z])\.$/) + if (nameMatch !== null) { + initial = nameMatch[2] + lastName = nameMatch[1] + } else { + initial = null + lastName = nameLine + } + + let vote: RollCallResponse + if (statusLine === "Y") { + vote = "Yea" + } else if (statusLine === "N") { + vote = "Nay" + } else if (statusLine === "X") { + vote = "Abstain" + } else { + return `${statusLine} could not be understood as a vote` + } + + return { vote, lastName, initial, ...entry } +} + +type RollCall = { + votes: HouseRollCallVote[] + bill: string | null + question: string + yeas: number + nays: number + time: Date + abstains: number +} +export type HouseRollCallResult = RollCall & { downloadUrl: string } + +function parseQuestion(number: number, input: string) { + const trim1 = "MASSACHUSETTS HOUSE OF REPRESENTATIVES" + const trim2 = "Yea and Nay" + const trim3 = `No. ${number}` + if (!input.includes(trim1)) { + return "Could not find MASSACHUSETTS HOUSE OF REPRESENTATIVES." + } + if (!input.includes(trim2)) { + return "Could not find Yea and Nay." + } + if (!input.includes(trim3)) { + return `Could not find ${trim3}.` + } + input = input.replace(trim1, " ").replace(trim2, " ").replace(trim3, " ") + + const search1 = /([0-9]+\/[0-9]+\/[0-9]+ [0-9]+:[0-9]+ (AM|PM))/ + const search2 = /([0-9]+) YEAS([0-9]+) NAYS([0-9]+) N\/V/ + const search3 = /H\. ([0-9]+)/ + + // Find all required values + const timeMatch = input.match(search1) + const voteMatch = input.match(search2) + const billMatch = input.match(search3) + + // Make sure every required value was found + if (!timeMatch) { + return "Could not find the date/time." + } + + if (!voteMatch) { + return "Could not find the Yea/Nay vote counts." + } + + // Extract the values + const timeStr = timeMatch[1] + const yeas = Number(voteMatch[1]) + const nays = Number(voteMatch[2]) + const nv = Number(voteMatch[3]) + const billNumber = billMatch?.[1] ?? null + + // Remove everything we've extracted + let question = input + .replace(search1, "") + .replace(search2, "") + .replace(search3, "") + .replace(/\s+/g, " ") + .trim() + + return { + question, + timeStr, + billNumber, + yeas, + nays, + nv + } +} + +function parseVotes(number: number, text: string): RollCall | string { + const lines = text + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean) + const untilFirstVote = lines.findIndex( + line => line == "Y" || line == "N" || line == "X" + ) + const lastIntroLine = untilFirstVote - 2 + + const result = parseQuestion( + number, + lines.slice(0, lastIntroLine + 1).join(" ") + ) + if (typeof result === "string") { + return result + } + const { question, timeStr, billNumber, yeas, nays, nv } = result + const time = parseTime(timeStr) + + const results = [] + for (let i = untilFirstVote - 1; i < lines.length; i += 2) { + let nameLine = lines[i] + const statusLine = lines[i + 1] + + if (statusLine === undefined) { + break + } + + const thisVote = parseVote(nameLine, statusLine) + if (typeof thisVote === "string") { + return thisVote + } + results.push(thisVote) + } + + const bill = billNumber ? `H${billNumber}` : null + return { + votes: results, + bill, + question, + time, + yeas: Number(yeas), + nays: Number(nays), + abstains: Number(nv) + } +} + +function downloadUrl(court: number, number: number): string { + return `https://malegislature.gov/RollCall/${court}/HouseRollCall${number}.pdf` +} + +export async function getHouseRollCall( + court: number, + number: number +): Promise { + const url = downloadUrl(court, number) + const pdf = await fetch(url) + if (pdf.status === 404) { + return null + } + const result = await pdfParse(Buffer.from(await pdf.arrayBuffer())) + const votes = parseVotes(number, result.text) + if (typeof votes === "string") { + return votes + } + return { downloadUrl: url, ...votes } +} + +// Used to collect senate legislator ids +export async function getSenateLegislators( + court: number +): Promise> { + const snapshot = await db.collection(`generalCourts/${court}/members`).get() + + const result: Set = new Set() + for (const doc of snapshot.docs) { + const data = doc.data() + if (data.content.Branch === "Senate") { + result.add(data.id) + } + } + return result +} + +// Used to cross-reference legislator names to ids +// Returns a [legislator name as present on pdfs, legislator id][] | error +export async function getHouseLegislators( + court: number +): Promise<[string, string][] | string> { + const snapshot = await db.collection(`generalCourts/${court}/members`).get() + + const result = [] + for (const doc of snapshot.docs) { + const data = doc.data() + if (data.content.Branch === "House") { + // Cut off affixes (Jr., III) + const match = data.content.Name.match(/^(.*), [^\s]+$/) + let name: string = data.content.Name + if (match !== null) { + name = match[1] + } + if (data.content.LeadershipPosition === "Speaker of the House") { + name = "The Speaker" + } + result.push([name, data.id] as [string, string]) + } + } + return result +} + +function normalize(s: string): string { + return s + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[ʼʻ’‘ꞌˈ′ʹ՚]/g, "'") +} + +// Arguments: +// [first initial | null, last name][] +// [legislator name from database, legislator id][] +// Returns: +// corresponding legislator id for each item in argument 1[] | error +export function translateNames( + searching: [string | null, string][], + legislatorNames: [string, string][] +): string[] | string { + const searchMap = new Map() + let i = 0 + for (let [initial, lastName] of searching) { + lastName = normalize(lastName) + // Normalize Mr., Mrs. Speaker + if (lastName.endsWith(" Speaker")) { + lastName = "The Speaker" + } + searchMap.set(`${initial}, ${lastName}`, i) + i += 1 + } + + const translations = new Array(searching.length) + let j = 0 + for (let [name, id] of legislatorNames) { + name = normalize(name) + const initial = name[0] + for (let i = name.length - 1; i >= -1; i--) { + if (i === -1 || name[i] === " ") { + const namePart = name.slice(i + 1) + const getInitial = searchMap.get(`${initial}, ${namePart}`) + if (getInitial !== undefined) { + translations[getInitial] = id + j += 1 + break + } + const getNoInitial = searchMap.get(`null, ${namePart}`) + if (getNoInitial !== undefined) { + translations[getNoInitial] = id + j += 1 + break + } + } + } + } + if (j !== searching.length) { + const missing = [] + for (let i = 0; i < searching.length; i++) { + if (!translations[i]) { + missing.push(searching[i]) + } + } + return `couldn't find all legislators (${JSON.stringify( + missing + )} remaining)` + } + return translations +} diff --git a/functions/src/votes/scrapers.ts b/functions/src/votes/scrapers.ts new file mode 100644 index 000000000..c86e372f9 --- /dev/null +++ b/functions/src/votes/scrapers.ts @@ -0,0 +1,316 @@ +import { runWith, RuntimeOptions } from "firebase-functions" +import { db } from "../firebase" +import { + getHouseLegislators, + getSenateLegislators, + getHouseRollCall, + translateNames +} from "./scrapeRollCall" +import * as functions from "firebase-functions" +import { currentGeneralCourt } from "../shared" +import { RollCallVote, HouseRollCall, SenateRollCall } from "./types" +import { BulkWriter, Timestamp } from "firebase-admin/firestore" +import { getRollCall } from "../malegislature" + +export class SenateRollCallScraper { + private schedule + private timeout + private memory + private legislators: Set | null + + constructor( + schedule: string = "every 24 hours", + timeout: number = 480, + memory: RuntimeOptions["memory"] = "256MB" + ) { + this.schedule = schedule + this.timeout = timeout + this.memory = memory + this.legislators = null + } + + get function() { + return runWith({ + timeoutSeconds: this.timeout, + memory: this.memory, + maxInstances: 1 + }) + .pubsub.schedule(this.schedule) + .onRun(() => this.run()) + } + + async addRollCall( + writer: BulkWriter, + court: number, + rollCallNumber: number + ): Promise { + if (!this.legislators) { + const legislators = await getSenateLegislators(court) + if (typeof legislators === "string") { + this.legislators = new Set() + return `error collecting legislators: ${legislators}` + } else { + if (!legislators.size) { + return `error no legislators found in database` + } + this.legislators = legislators + } + } + if (this.legislators!.size < 40) { + return `discovered senate legislature size is ${ + this.legislators!.size + } (expected 40)` + } + + let rollCall + try { + rollCall = await getRollCall(court, "Senate", rollCallNumber) + } catch (e) { + return `fetch error: ${e}` + } + const rollCallRewritten: SenateRollCall = { + type: "RollCall", + generalCourtNumber: court, + branch: "Senate", + questionMotion: rollCall.QuestionMotion ?? null, + downloadUrl: + rollCall.DownloadUrl ?? + `https://malegislature.gov/api/DownloadRollCall?Branch=House&generalCourtNumber=${court}&rollCallNumber=${rollCallNumber}`, + rollCallNumber + } + + const docId = `senate-${court}-${rollCallNumber}` + writer.set(db.collection("votes").doc(docId), rollCallRewritten) + + const remainingMembers = new Set(this.legislators) + for (let i = 0; i < (rollCall.Yeas ?? []).length; i++) { + const memberCode = rollCall?.Yeas?.[i]?.MemberCode + if (!memberCode) { + continue + } + const voteRewritten: RollCallVote = { + response: "Yea", + branch: "Senate", + memberCode: memberCode!, + generalCourtNumber: court, + rollCallNumber + } + remainingMembers.delete(memberCode) + writer.set( + db.collection(`votes/${docId}/vote`).doc(memberCode), + voteRewritten + ) + } + for (let i = 0; i < (rollCall.Nays ?? []).length; i++) { + const memberCode = rollCall?.Nays?.[i]?.MemberCode + if (!memberCode) { + continue + } + const voteRewritten: RollCallVote = { + response: "Nay", + branch: "Senate", + memberCode: memberCode!, + generalCourtNumber: court, + rollCallNumber + } + remainingMembers.delete(memberCode) + writer.set( + db.collection(`votes/${docId}/vote`).doc(memberCode), + voteRewritten + ) + } + // This includes members that are only present for part of a session + // (should be altered?) + for (const memberCode of remainingMembers) { + const voteRewritten: RollCallVote = { + response: "Abstain", + branch: "Senate", + memberCode: memberCode, + generalCourtNumber: court, + rollCallNumber + } + writer.set( + db.collection(`votes/${docId}/vote`).doc(memberCode), + voteRewritten + ) + } + return "success" + } + + private async run() { + const snapshot = await db + .collection("votes") + .where("type", "==", "rollcall") + .where("branch", "==", "Senate") + .get() + + let rollCallNumber = 1 + for (const doc of snapshot.docs) { + const name = doc.ref.id + const thisCourt = Number(name.split("-")[1]) + const thisNumber = Number(name.split("-")[2]) + if (thisCourt === currentGeneralCourt && thisNumber > rollCallNumber) { + rollCallNumber = thisNumber + } + } + + const writer = db.bulkWriter() + + let rollcall = await this.addRollCall( + writer, + currentGeneralCourt, + rollCallNumber + ) + while (!rollcall.startsWith("fetch error")) { + rollCallNumber += 1 + if (rollcall !== "success") { + functions.logger.error( + `Error collecting senate rollcall ${rollCallNumber}: ${rollcall}` + ) + } + + rollcall = await this.addRollCall( + writer, + currentGeneralCourt, + rollCallNumber + ) + } + + await writer.close() + } +} + +export class HouseRollCallScraper { + private schedule + private timeout + private memory + private legislators: [string, string][] | null + + constructor( + schedule: string = "every 24 hours", + timeout: number = 480, + memory: RuntimeOptions["memory"] = "256MB" + ) { + this.schedule = schedule + this.timeout = timeout + this.memory = memory + this.legislators = null + } + + get function() { + return runWith({ + timeoutSeconds: this.timeout, + memory: this.memory, + maxInstances: 1 + }) + .pubsub.schedule(this.schedule) + .onRun(() => this.run()) + } + + async addRollCall( + writer: BulkWriter, + court: number, + rollCallNumber: number + ): Promise { + if (!this.legislators) { + const legislators = await getHouseLegislators(court) + if (typeof legislators === "string") { + this.legislators = [] + return `error collecting legislators: ${legislators}` + } else { + if (!legislators.length) { + return `error no legislators found in database` + } + this.legislators = legislators + } + } + if (this.legislators!.length === 0) { + return "no legislators available" + } + const rollCall = await getHouseRollCall(court, rollCallNumber) + if (!rollCall) { + return "fetch error" + } + if (typeof rollCall === "string") { + return rollCall + } + const rollcallRewritten: HouseRollCall = { + type: "RollCall", + rollCallNumber, + generalCourtNumber: court, + branch: "House", + bill: rollCall.bill, + time: Timestamp.fromDate(rollCall.time), + questionMotion: rollCall.question, + downloadUrl: rollCall.downloadUrl + } + const docId = `house-${court}-${rollCallNumber}` + writer.set(db.collection("votes").doc(docId), rollcallRewritten) + const names = rollCall.votes.map( + vote => [vote.initial, vote.lastName] as [string | null, string] + ) + const ids = translateNames(names, this.legislators) + if (typeof ids === "string") { + return `matching error: ${ids}` + } + for (let i = 0; i < rollCall.votes.length; i++) { + const voteRewritten: RollCallVote = { + response: rollCall.votes[i].vote, + branch: "House", + memberCode: ids[i], + generalCourtNumber: court, + rollCallNumber: rollCallNumber + } + writer.set( + db.collection(`votes/${docId}/vote`).doc(ids[i]), + voteRewritten + ) + } + return "success" + } + + private async run() { + const snapshot = await db + .collection("votes") + .where("type", "==", "rollcall") + .where("branch", "==", "House") + .get() + + let rollCallNumber = 1 + for (const doc of snapshot.docs) { + const name = doc.ref.id + const thisCourt = Number(name.split("-")[1]) + const thisNumber = Number(name.split("-")[2]) + if (thisCourt === currentGeneralCourt && thisNumber > rollCallNumber) { + rollCallNumber = thisNumber + } + } + + const writer = db.bulkWriter() + + let rollcall = await this.addRollCall( + writer, + currentGeneralCourt, + rollCallNumber + ) + // stop at 404 + while (!rollcall.startsWith("fetch error")) { + rollCallNumber += 1 + if (rollcall !== "success") { + functions.logger.error( + `Error collecting house rollcall ${rollCallNumber}: ${rollcall}` + ) + } + rollcall = await this.addRollCall( + writer, + currentGeneralCourt, + rollCallNumber + ) + } + + await writer.close() + } +} + +export const scrapeHouseRollCalls = new HouseRollCallScraper().function +export const scrapeSenateRollCalls = new SenateRollCallScraper().function diff --git a/functions/src/votes/types.ts b/functions/src/votes/types.ts new file mode 100644 index 000000000..7da73c0e6 --- /dev/null +++ b/functions/src/votes/types.ts @@ -0,0 +1,225 @@ +import { Timestamp } from "firebase-admin/firestore" +import { + Array, + Boolean, + Literal, + InstanceOf, + Number, + Optional, + Partial, + Record, + Static, + String, + Union, + Null +} from "runtypes" + +// Types in our database + +export type CommitteeVoteResponse = Static +export const CommitteeVoteResponse = Union( + Literal("Favorable"), + Literal("Adverse"), + Literal("ReserveRight"), + Literal("NoVote") +) + +export type Branch = Static +export const Branch = Union(Literal("Senate"), Literal("House")) + +export type VoteType = Static +export const VoteType = Union(Literal("RollCall"), Literal("Committee")) + +export type CommitteeVote = Static +export const CommitteeVote = Record({ + response: CommitteeVoteResponse, + memberCode: String +}) + +export type CommitteeVotes = Static +export const CommitteeVotes = Record({ + type: VoteType, + generalCourtNumber: Number, + bill: String, + committee: String +}) + +export type RollCallResponse = Static +export const RollCallResponse = Union( + Literal("Yea"), + Literal("Nay"), + Literal("Abstain") +) + +export type RollCallVote = Static +export const RollCallVote = Record({ + response: RollCallResponse, + memberCode: String, + generalCourtNumber: Number, + rollCallNumber: Number, + branch: Branch +}) + +// Roll call API provides less information - +// most importantly, it does not have the associated bill +export type SenateRollCall = Static +export const SenateRollCall = Record({ + type: VoteType, + generalCourtNumber: Number, + branch: Branch, + rollCallNumber: Number, + questionMotion: Union(String, Null), + downloadUrl: String +}) + +export type HouseRollCall = Static +export const HouseRollCall = Record({ + type: VoteType, + generalCourtNumber: Number, + branch: Branch, + time: InstanceOf(Timestamp), + bill: Union(String, Null), + rollCallNumber: Number, + questionMotion: String, + downloadUrl: String +}) + +// Types from MA Legislature API + +export type LegislativeMemberSummary = Static +export const LegislativeMemberSummary = Record({ + GeneralCourtNumber: Number, + MemberCode: Optional(String), + Details: Optional(String) +}) + +export type RollCallSummary = Static +export const RollCallSummary = Record({ + GeneralCourtNumber: Number, + Branch: Optional(String), + RollCallNumber: Number, + Details: Optional(String) +}) + +export type APIRollCall = Static +export const APIRollCall = Record({ + GeneralCourtNumber: Number, + Branch: Optional(String), + QuestionMotion: Optional(Union(String, Null)), + RollCallNumber: Number, + Yeas: Optional(Array(LegislativeMemberSummary)), + Nays: Optional(Array(LegislativeMemberSummary)), + Absent: Optional(Array(LegislativeMemberSummary)), + DownloadUrl: Optional(String) +}) + +export type FiscalAmount = Static +export const FiscalAmount = Record({ + FiscalType: Optional(String), + Amount: Optional(String) +}) + +export type CommitteeVoteRecord = Static +export const CommitteeVoteRecord = Record({ + Favorable: Optional(Array(LegislativeMemberSummary)), + Adverse: Optional(Array(LegislativeMemberSummary)), + ReserveRight: Optional(Array(LegislativeMemberSummary)), + NoVoteRecorded: Optional(Array(LegislativeMemberSummary)) +}) + +export type BillSponsor = Static +export const BillSponsor = Record({ + Id: Optional(String), + Name: Optional(String), + /** + * Type of the Bill Sponsor: + * 1 = Legislative Member + * 2 = Committee + * 3 = Public Request + * 4 = Special Request + */ + Type: Number, + /** + * Only Committees and Legislative Members will have further details. + */ + Details: Optional(String), + ResponseDate: Optional(String) +}) + +export type CommitteeVoteBill = Static +export const CommitteeVoteBill = Record({ + BillNumber: Optional(String), + DocketNumber: Optional(String), + Title: Optional(String), + PrimarySponsor: Optional(BillSponsor), + Cosponsors: Optional(Array(BillSponsor)), + JointSponsor: Optional(BillSponsor), + GeneralCourtNumber: Number, + Details: Optional(String), + IsDocketBookOnly: Boolean +}) + +export type Committee = Static +export const Committee = Record({ + CommitteeCode: Optional(String), + GeneralCourtNumber: Optional(Number), + Details: Optional(String) +}) + +export type APICommitteeVote = Static +export const APICommitteeVote = Record({ + Question: Optional(String), + Bill: Optional(CommitteeVoteBill), + Committee: Optional(Committee), + Date: String, + Vote: Optional(Array(CommitteeVoteRecord)) +}) + +export type CommitteeRecommendation = Static +export const CommitteeRecommendation = Record({ + Action: Optional(String), + FiscalAmounts: Optional(Array(FiscalAmount)), + Committee: Optional(Committee), + Votes: Optional(Array(APICommitteeVote)) +}) + +export type Attachment = Static +export const Attachment = Record({ + Description: Optional(String), + DownloadUrl: Optional(String) +}) + +export type AmendmentSummary = Static +export const AmendmentSummary = Record({ + GeneralCourtNumber: Number +}).And( + Partial({ + AmendmentNumber: String, + ParentBillNumber: String, + Branch: String, + Details: String + }) +) + +export type APIDocument = Static +export const APIDocument = Record({ + GeneralCourtNumber: Number +}).And( + Partial({ + Title: String, + BillNumber: String, + DocketNumber: String, + PrimarySponsor: BillSponsor, + Cosponsors: Array(BillSponsor), + JointSponsor: BillSponsor, + BillHistory: String, + LegislationTypeName: String, + Pinslip: String, + DocumentText: String, + EmergencyPreamble: String, + RollCalls: Array(RollCallSummary), + Attachments: Array(Attachment), + CommitteeRecommendations: Array(CommitteeRecommendation), + Amendments: Array(AmendmentSummary) + }) +) diff --git a/scripts/firebase-admin/backfillRollCalls.ts b/scripts/firebase-admin/backfillRollCalls.ts new file mode 100644 index 000000000..6a1513076 --- /dev/null +++ b/scripts/firebase-admin/backfillRollCalls.ts @@ -0,0 +1,83 @@ +import { Record, Number, Optional, String } from "runtypes" +import { Script } from "./types" +import { + HouseRollCallScraper, + SenateRollCallScraper +} from "functions/src/votes" +import { getSenateLegislators } from "functions/src/votes/scrapeRollCall" + +const Args = Record({ + court: Number, + endCourt: Optional(Number), + number: Optional(Number), + endNumber: Optional(Number), + branch: Optional(String) +}) + +export const script: Script = async ({ db, args }) => { + let { court, endCourt, number, endNumber, branch } = Args.check(args) + + endCourt = endCourt ?? court + if (number && !endNumber) { + endNumber = number + } + if (!number) { + number = 0 + } + const writer = db.bulkWriter() + const houseScraper = new HouseRollCallScraper() + const senateScraper = new SenateRollCallScraper() + for (; court <= endCourt; court++) { + for ( + let rollCallNumber = number; + !endNumber || rollCallNumber <= endNumber; + rollCallNumber++ + ) { + if (!branch) { + let result = await senateScraper.addRollCall( + writer, + court, + rollCallNumber + ) + if (result !== "success") { + console.log( + `Error fetching senate rollcall ${rollCallNumber} for court ${court}: ${result}` + ) + return + } + result = await houseScraper.addRollCall(writer, court, rollCallNumber) + if (result !== "success") { + console.log( + `Error fetching house rollcall ${rollCallNumber} for court ${court}: ${result}` + ) + return + } + } else if (branch === "Senate") { + const result = await senateScraper.addRollCall( + writer, + court, + rollCallNumber + ) + if (result !== "success") { + console.log( + `Error fetching senate rollcall ${rollCallNumber} for court ${court}: ${result}` + ) + return + } + } else if (branch === "House") { + const result = await houseScraper.addRollCall( + writer, + court, + rollCallNumber + ) + if (result !== "success") { + console.log( + `Error fetching house rollcall ${rollCallNumber} for court ${court}: ${result}` + ) + return + } + } + } + } + await writer.close() +} diff --git a/tests/fixtures/votes/193legislators.json b/tests/fixtures/votes/193legislators.json new file mode 100644 index 000000000..7aef00f6a --- /dev/null +++ b/tests/fixtures/votes/193legislators.json @@ -0,0 +1,646 @@ +[ + [ + "Adrian C. Madaro", + "ACM1" + ], + [ + "Antonio F. D. Cabral", + "AFC1" + ], + [ + "Alice Hanlon Peisch", + "AHP1" + ], + [ + "Angelo J. Puppolo", + "AJP1" + ], + [ + "Adam J. Scanlon", + "AJS1" + ], + [ + "Angelo L. D'Emilia", + "ALD1" + ], + [ + "Aaron L. Saunders", + "ALS1" + ], + [ + "Ann-Margaret Ferrante", + "AMF1" + ], + [ + "Aaron Michlewitz", + "AMM1" + ], + [ + "Alyson M. Sullivan-Almeida", + "AMS2" + ], + [ + "Adrianne Pasateri Ramos", + "APR1" + ], + [ + "Andres X. Vargas", + "AXV1" + ], + [ + "Alan Silvia", + "A_S1" + ], + [ + "Brandy Fluker Oakley", + "BFO1" + ], + [ + "Bradley H. Jones", + "BHJ1" + ], + [ + "Bruce J. Ayers", + "BJA1" + ], + [ + "Bud L. Williams", + "BLW1" + ], + [ + "Brian M. Ashe", + "BMA1" + ], + [ + "Brian W. Murray", + "BWM1" + ], + [ + "Carol A. Doherty", + "CAD1" + ], + [ + "Carole A. Fiola", + "CAF1" + ], + [ + "Christopher J. Worrell", + "CJW1" + ], + [ + "Carmine Lawrence Gentile", + "CLG1" + ], + [ + "Colleen M. Garry", + "CMG1" + ], + [ + "Christopher M. Markey", + "CMM1" + ], + [ + "Christine P. Barber", + "CPB2" + ], + [ + "Christopher Richard Flanagan", + "CRF1" + ], + [ + "Carlos González", + "C_G1" + ], + [ + "Christopher Hendricks", + "C_H1" + ], + [ + "Chynah Tyler", + "C_T1" + ], + [ + "Dylan A. Fernandes", + "DAF1" + ], + [ + "David Henry Argosky LeBoeuf", + "DAL1" + ], + [ + "Danillo A. Sena", + "DAS1" + ], + [ + "Denise C. Garlick", + "DCG1" + ], + [ + "Daniel Cahill", + "DFC1" + ], + [ + "David F. DeCoste", + "DFD1" + ], + [ + "Donald H. Wong", + "DHW1" + ], + [ + "David K. Muradian", + "DKM1" + ], + [ + "Daniel M. Donahue", + "DMD1" + ], + [ + "David M. Rogers", + "DMR1" + ], + [ + "David Paul Linsky", + "DPL1" + ], + [ + "Donald R. Berthiaume", + "DRB1" + ], + [ + "Daniel R. Carey", + "DRC1" + ], + [ + "David T. Vieira", + "DTV1" + ], + [ + "Danielle W. Gregoire", + "DWG1" + ], + [ + "David Biele", + "D_B1" + ], + [ + "David Allen Robertson", + "D_R1" + ], + [ + "Dawne Shand", + "D_S1" + ], + [ + "Estela A. Reyes", + "EAR1" + ], + [ + "Edward R. Philips", + "ERP1" + ], + [ + "Edward F. Coppinger", + "E_C1" + ], + [ + "Erika Uyterhoeven", + "E_U1" + ], + [ + "Frank A. Moran", + "FAM1" + ], + [ + "Francisco E. Paulino", + "FEP1" + ], + [ + "F. Jay Barrows", + "FJB1" + ], + [ + "Gerard J. Cassidy", + "G_C2" + ], + [ + "Hannah Kane", + "HEK1" + ], + [ + "Jessica Ann Giannino", + "JAG1" + ], + [ + "Judith A. Garcia", + "JAG2" + ], + [ + "Jerald A. Parisella", + "JAP1" + ], + [ + "Jennifer Balinsky Armini", + "JBA1" + ], + [ + "James C. Arena-DeRosa", + "JCD1" + ], + [ + "Joseph D. McKenna", + "JDM1" + ], + [ + "Jonathan D. Zlotnik", + "JDZ1" + ], + [ + "John H. Rogers", + "JHR1" + ], + [ + "John J. Lawn", + "JJL2" + ], + [ + "John J. Mahoney", + "JJM1" + ], + [ + "James J. O'Day", + "JJO1" + ], + [ + "James K. Hawkins", + "JKH1" + ], + [ + "James M. Murphy", + "JMM1" + ], + [ + "Jeffrey N. Roy", + "JNR1" + ], + [ + "Jack Patrick Lewis", + "JPL1" + ], + [ + "Jeffrey Rosario Turco", + "JRT1" + ], + [ + "Josh S. Cutler", + "JSC1" + ], + [ + "James Arciero", + "J_A1" + ], + [ + "John Barrett", + "J_B1" + ], + [ + "Jay D. Livingstone", + "J_L1" + ], + [ + "Joan Meschino", + "J_M1" + ], + [ + "Jon Santiago", + "J_S2" + ], + [ + "Kip A. Diggs", + "KAD1" + ], + [ + "Kevin G. Honan", + "KGH1" + ], + [ + "Kenneth I. Gordon", + "KIG1" + ], + [ + "Kate Lipper-Garabedian", + "KLG1" + ], + [ + "Kimberly N. Ferguson", + "KNF1" + ], + [ + "Kathleen R. LaNatra", + "KPL1" + ], + [ + "Kelly W. Pease", + "KWP1" + ], + [ + "Kate Donaghue", + "K_D1" + ], + [ + "Kate Hogan", + "K_H1" + ], + [ + "Kay Khan", + "K_K1" + ], + [ + "Kristin Kassner", + "K_K2" + ], + [ + "Lenny Mirra", + "L_M1" + ], + [ + "Lindsay N. Sabadosa", + "L_S1" + ], + [ + "Marjorie C. Decker", + "MCD1" + ], + [ + "Mark J. Cusack", + "MJC1" + ], + [ + "Michael J. Finn", + "MJF1" + ], + [ + "Michael J. Moran", + "MJM1" + ], + [ + "Mathew J. Muratore", + "MJM2" + ], + [ + "Michael J. Soter", + "MJS3" + ], + [ + "Michelle M. DuBois", + "MMD1" + ], + [ + "Michael P. Kushmerek", + "MPK1" + ], + [ + "Margaret R. Scarsdale", + "MRS1" + ], + [ + "Michael S. Day", + "MSD1" + ], + [ + "Mary S. Keefe", + "MSK1" + ], + [ + "Marcus S. Vaughn", + "MSV1" + ], + [ + "Marc T. Lombardo", + "MTL1" + ], + [ + "Mike Connolly", + "M_C1" + ], + [ + "Michelle L. Ciccolo", + "M_C2" + ], + [ + "Manny Cruz", + "M_C3" + ], + [ + "Mindy Domb", + "M_D2" + ], + [ + "Meghan Kilcoyne", + "M_K1" + ], + [ + "Nicholas A. Boldyga", + "NAG1" + ], + [ + "Norman J. Orrall", + "NJO1" + ], + [ + "Natalie M. Blais", + "NMB1" + ], + [ + "Natalie M. Higgins", + "N_H1" + ], + [ + "Orlando Ramos", + "O_R1" + ], + [ + "Patricia A. Duffy", + "PAD1" + ], + [ + "Patricia A. Haddad", + "PAH1" + ], + [ + "Paul A. Schmid", + "PAS1" + ], + [ + "Paul J. Donato", + "PJD1" + ], + [ + "Peter J. Durant", + "PJD2" + ], + [ + "Patrick Joseph Kearney", + "PJK1" + ], + [ + "Paul K. Frost", + "PKF1" + ], + [ + "Peter Capano", + "PLC1" + ], + [ + "Priscila S. Sousa", + "PSS1" + ], + [ + "Paul McMurtry", + "P_M1" + ], + [ + "Rita A. Mendes", + "RAM1" + ], + [ + "Ruth B. Balser", + "RBB1" + ], + [ + "Russell E. Holmes", + "REH1" + ], + [ + "Rodney M. Elliott", + "RME1" + ], + [ + "Richard M. Haggerty", + "RMH1" + ], + [ + "Ryan M. Hamilton", + "RMH2" + ], + [ + "Rob Consalvo", + "R_C1" + ], + [ + "The Speaker", + "R_M1" + ], + [ + "Rady Mom", + "R_M2" + ], + [ + "Shirley B. Arriaga", + "SBA1" + ], + [ + "Steven C. Owens", + "SCO1" + ], + [ + "Steven G. Xiarhos", + "SGX1" + ], + [ + "Sarah K. Peake", + "SKP1" + ], + [ + "Susannah M. Whipps", + "SLG1" + ], + [ + "Sally P. Kerans", + "SPK1" + ], + [ + "Steven S. Howitt", + "SSH1" + ], + [ + "Susan Williams Gifford", + "SWG1" + ], + [ + "Simon Cataldo", + "S_C1" + ], + [ + "Sean Garballey", + "S_G1" + ], + [ + "Steven Ultrino", + "S_G2" + ], + [ + "Samantha Montaño", + "S_M1" + ], + [ + "Tricia Farley-Bouvier", + "TFB1" + ], + [ + "Thomas P. Walsh", + "TJW1" + ], + [ + "Thomas M. Stanley", + "TMS1" + ], + [ + "Todd M. Smola", + "TMS2" + ], + [ + "Tram T. Nguyen", + "TTN1" + ], + [ + "Tackey Chan", + "T_C1" + ], + [ + "Tommy Vitolo", + "T_V1" + ], + [ + "Vanna Howard", + "V_H1" + ], + [ + "William C. Galvin", + "WCG1" + ], + [ + "William J. Driscoll", + "WJD1" + ], + [ + "William M. Straus", + "WMS1" + ], + [ + "Smitty Pignatelli", + "WSP1" + ], + [ + "Daniel J. Hunt", + "djh1" + ], + [ + "Daniel J. Ryan", + "djr1" + ], + [ + "Joseph W. McGonagle", + "jwm1" + ] +] \ No newline at end of file diff --git a/tests/fixtures/votes/HouseRollCall19.pdf b/tests/fixtures/votes/HouseRollCall19.pdf new file mode 100644 index 0000000000000000000000000000000000000000..534f84838330618f1869e0ee0927a425c86ee06b GIT binary patch literal 45313 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 z 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/votes/HouseRollCall19Read.json b/tests/fixtures/votes/HouseRollCall19Read.json new file mode 100644 index 000000000..3b8094379 --- /dev/null +++ b/tests/fixtures/votes/HouseRollCall19Read.json @@ -0,0 +1,1117 @@ +{ + "downloadUrl": "https://malegislature.gov/RollCall/193/HouseRollCall19.pdf", + "votes": [ + { + "vote": "Yea", + "lastName": "Mr. Speaker", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Duffy", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Linsky", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Sousa", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Moran", + "initial": "M", + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Elliott", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Lipper-Garabedian", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Stanley", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Hogan", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Farley-Bouvier", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Livingstone", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Straus", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Peisch", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Fernandes", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Madaro", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Turco", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Moran", + "initial": "F", + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Ferrante", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Mahoney", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Tyler", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Peake", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Finn", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Markey", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Ultrino", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Arciero", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Fiola", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "McGonagle", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Uyterhoeven", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Arena-DeRosa", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Flanagan", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "McMurtry", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Vargas", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Armini", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Fluker Oakley", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Mendes", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Vitolo", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Arriaga", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Galvin", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Meschino", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Walsh", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Ashe", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Garballey", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Michlewitz", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Williams", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Ayers", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Garcia", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Mom", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Worrell", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Balser", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Garlick", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Montano", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Zlotnik", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Barber", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Garry", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Murphy", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Jones", + "initial": null, + "afterVote": false, + "highlighted": true + }, + { + "vote": "Yea", + "lastName": "Barrett", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Gentile", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Murray", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Ferguson", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Biele", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Giannino", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Nguyen", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Frost", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Blais", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Gonzalez", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "O’Day", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Gifford", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Cabral", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Gordon", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Owens", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Barrows", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Cahill", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Gregoire", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Parisella", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Berthiaume", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Capano", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Haddad", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Paulino", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Boldyga", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Carey", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Haggerty", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Philips", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "D'Emilia", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Cassidy", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Hamilton", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Pignatelli", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "DeCoste", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Cataldo", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Hawkins", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Puppolo", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Durant", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Chan", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Hendricks", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Ramos", + "initial": "A", + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Howitt", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Ciccolo", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Higgins", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Ramos", + "initial": "O", + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Kane", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Connolly", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Holmes", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Reyes", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Lombardo", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Consalvo", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Honan", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Robertson", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "McKenna", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Cruz", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Howard", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Rogers", + "initial": "D", + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Muradian", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Cusack", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Hunt", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Rogers", + "initial": "J", + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Muratore", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Cutler", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Kassner", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Roy", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Orrall", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Day", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Kearney", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Ryan", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Pease", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Decker", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Keefe", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Sabadosa", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Smola", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Diggs", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Kerans", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Saunders", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Soter", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Doherty", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Khan", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Scanlon", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Sullivan-Almeida", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Domb", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Kilcoyne", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Scarsdale", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Vaughn", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Donaghue", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Kushmerek", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Schmid", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Vieira", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Donahue", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "LaNatra", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Sena", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Wong", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Donato", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Lawn", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Shand", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Xiarhos", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Driscoll", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "LeBoeuf", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Silvia", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Whipps", + "initial": null, + "afterVote": false, + "highlighted": true + }, + { + "vote": "Yea", + "lastName": "DuBois", + "initial": null, + "afterVote": false, + "highlighted": false + }, + { + "vote": "Yea", + "lastName": "Lewis", + "initial": null, + "afterVote": false, + "highlighted": false + } + ], + "bill": "H3900", + "question": "Consol. \"B\" H.H.S. and Elder Affairs", + "time": "2023-04-25T01:06:00.000Z", + "yeas": 158, + "nays": 0, + "abstains": 0 +} \ No newline at end of file diff --git a/tests/fixtures/votes/HouseRollCall19Translation.json b/tests/fixtures/votes/HouseRollCall19Translation.json new file mode 100644 index 000000000..ac88bc4ef --- /dev/null +++ b/tests/fixtures/votes/HouseRollCall19Translation.json @@ -0,0 +1,160 @@ +[ + "R_M1", + "PAD1", + "DPL1", + "PSS1", + "MJM1", + "RME1", + "KLG1", + "TMS1", + "K_H1", + "TFB1", + "J_L1", + "WMS1", + "AHP1", + "DAF1", + "ACM1", + "JRT1", + "FAM1", + "AMF1", + "JJM1", + "C_T1", + "SKP1", + "MJF1", + "CMM1", + "S_G2", + "J_A1", + "CAF1", + "jwm1", + "E_U1", + "JCD1", + "CRF1", + "P_M1", + "AXV1", + "JBA1", + "BFO1", + "RAM1", + "T_V1", + "SBA1", + "WCG1", + "J_M1", + "TJW1", + "BMA1", + "S_G1", + "AMM1", + "BLW1", + "BJA1", + "JAG2", + "R_M2", + "CJW1", + "RBB1", + "DCG1", + "S_M1", + "JDZ1", + "CPB2", + "CMG1", + "JMM1", + "BHJ1", + "J_B1", + "CLG1", + "BWM1", + "KNF1", + "D_B1", + "JAG1", + "TTN1", + "PKF1", + "NMB1", + "C_G1", + "JJO1", + "SWG1", + "AFC1", + "KIG1", + "SCO1", + "FJB1", + "DFC1", + "DWG1", + "JAP1", + "DRB1", + "PLC1", + "PAH1", + "FEP1", + "NAG1", + "DRC1", + "RMH1", + "ERP1", + "ALD1", + "G_C2", + "RMH2", + "WSP1", + "DFD1", + "S_C1", + "JKH1", + "AJP1", + "PJD2", + "T_C1", + "C_H1", + "APR1", + "SSH1", + "M_C2", + "N_H1", + "O_R1", + "HEK1", + "M_C1", + "REH1", + "EAR1", + "MTL1", + "R_C1", + "KGH1", + "D_R1", + "JDM1", + "M_C3", + "V_H1", + "DMR1", + "DKM1", + "MJC1", + "djh1", + "JHR1", + "MJM2", + "JSC1", + "K_K2", + "JNR1", + "NJO1", + "MSD1", + "PJK1", + "djr1", + "KWP1", + "MCD1", + "MSK1", + "L_S1", + "TMS2", + "KAD1", + "SPK1", + "ALS1", + "MJS3", + "CAD1", + "K_K1", + "AJS1", + "AMS2", + "M_D2", + "M_K1", + "MRS1", + "MSV1", + "K_D1", + "MPK1", + "PAS1", + "DTV1", + "DMD1", + "KPL1", + "DAS1", + "DHW1", + "PJD1", + "JJL2", + "D_S1", + "SGX1", + "WJD1", + "DAL1", + "A_S1", + "SLG1", + "MMD1", + "JPL1" +] From a81432782f12ac8c93d500f4e69f23f9a9bb2e8f Mon Sep 17 00:00:00 2001 From: Sam DeMarrais Date: Tue, 25 Aug 2026 21:47:02 -0400 Subject: [PATCH 02/18] Added roll calls --- functions/src/votes/scrapeRollCall.ts | 119 +++++++++++++++----------- 1 file changed, 67 insertions(+), 52 deletions(-) diff --git a/functions/src/votes/scrapeRollCall.ts b/functions/src/votes/scrapeRollCall.ts index 15d5aa318..ba41fa16a 100644 --- a/functions/src/votes/scrapeRollCall.ts +++ b/functions/src/votes/scrapeRollCall.ts @@ -12,58 +12,78 @@ type HouseRollCallVote = { highlighted: boolean } -function parseTime(timeStr: string): Date { +function parseTime(timeStr: string): Date | string { const time = DateTime.fromFormat(timeStr, "M/d/yyyy h:mm a", { zone: timeZone }) if (!time.isValid) { - throw new Error(`Invalid date/time: ${time.invalidExplanation}`) + return `Invalid date/time: ${time.invalidExplanation}` } return time.toJSDate() } -function parseVote( - nameLine: string, - statusLine: string -): HouseRollCallVote | string { - let entry = { - afterVote: false, - highlighted: false - } - if (nameLine[nameLine.length - 1] == "*") { - entry.afterVote = true - nameLine = nameLine.slice(0, -1).trimEnd() - } - if (/^--.*--$/.test(nameLine)) { - nameLine = nameLine.slice(2, -2).trim() - entry.highlighted = true +function parseVoteLines( + lines: string[], + expectedVoteCount: number +): HouseRollCallVote[] | string { + const results = [] + const votes: ("Yea" | "Nay" | "Abstain")[] = [] + for (const line of lines) { + if (line.match(/^[YNX]*$/)) { + for (const char of line) { + if (char === "Y") { + votes.push("Yea") + } else if (char === "N") { + votes.push("Nay") + } else if (char === "X") { + votes.push("Abstain") + } + } + } } - - let initial - let lastName - const nameMatch = nameLine.match(/^([^,]*),? ([a-zA-Z])\.$/) - if (nameMatch !== null) { - initial = nameMatch[2] - lastName = nameMatch[1] - } else { - initial = null - lastName = nameLine + if (votes.length !== expectedVoteCount) { + return `Expected ${expectedVoteCount} votes; found ${votes.length} votes` } + let nextVote = 0 + let i = 0 + for (; i < lines.length; i++) { + if (nextVote >= votes.length) { + break + } + let line = lines[i] + if (line.match(/^[YNX]*$/)) { + continue + } + let entry = { + afterVote: false, + highlighted: false + } + if (line[line.length - 1] == "*") { + entry.afterVote = true + line = line.slice(0, -1).trimEnd() + } + if (/^--.*--$/.test(line)) { + line = line.slice(2, -2).trim() + entry.highlighted = true + } - let vote: RollCallResponse - if (statusLine === "Y") { - vote = "Yea" - } else if (statusLine === "N") { - vote = "Nay" - } else if (statusLine === "X") { - vote = "Abstain" - } else { - return `${statusLine} could not be understood as a vote` + let initial + let lastName + const nameMatch = line.match(/^([^,]*),? ([a-zA-Z])\.$/) + if (nameMatch !== null) { + initial = nameMatch[2] + lastName = nameMatch[1] + } else { + initial = null + lastName = line + } + results.push({ vote: votes[nextVote], lastName, initial, ...entry }) + nextVote += 1 } - return { vote, lastName, initial, ...entry } + return results } type RollCall = { @@ -141,7 +161,7 @@ function parseVotes(number: number, text: string): RollCall | string { .map(line => line.trim()) .filter(Boolean) const untilFirstVote = lines.findIndex( - line => line == "Y" || line == "N" || line == "X" + line => line.match(/^[YNX]+$/) !== null ) const lastIntroLine = untilFirstVote - 2 @@ -154,21 +174,16 @@ function parseVotes(number: number, text: string): RollCall | string { } const { question, timeStr, billNumber, yeas, nays, nv } = result const time = parseTime(timeStr) + if (typeof time === "string") { + return `Error interpreting time string ${timeStr}: ${time}` + } - const results = [] - for (let i = untilFirstVote - 1; i < lines.length; i += 2) { - let nameLine = lines[i] - const statusLine = lines[i + 1] - - if (statusLine === undefined) { - break - } - - const thisVote = parseVote(nameLine, statusLine) - if (typeof thisVote === "string") { - return thisVote - } - results.push(thisVote) + const results = parseVoteLines( + lines.slice(untilFirstVote - 1), + yeas + nays + nv + ) + if (typeof results === "string") { + return `Error interpreting pdf for house roll call ${number}: ${results}` } const bill = billNumber ? `H${billNumber}` : null From d80be83eacd1ed79b267f5519a0c2fcfabda9396 Mon Sep 17 00:00:00 2001 From: Sam DeMarrais Date: Tue, 25 Aug 2026 21:47:02 -0400 Subject: [PATCH 03/18] Added 'present' votes --- functions/src/votes/scrapeRollCall.ts | 14 +++++++++----- functions/src/votes/scrapers.ts | 7 +++++-- functions/src/votes/types.ts | 3 ++- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/functions/src/votes/scrapeRollCall.ts b/functions/src/votes/scrapeRollCall.ts index ba41fa16a..6a5fe955b 100644 --- a/functions/src/votes/scrapeRollCall.ts +++ b/functions/src/votes/scrapeRollCall.ts @@ -29,9 +29,10 @@ function parseVoteLines( expectedVoteCount: number ): HouseRollCallVote[] | string { const results = [] - const votes: ("Yea" | "Nay" | "Abstain")[] = [] + let present = 0 + const votes: ("Yea" | "Nay" | "Abstain" | "Present")[] = [] for (const line of lines) { - if (line.match(/^[YNX]*$/)) { + if (line.match(/^[YNPX]*$/)) { for (const char of line) { if (char === "Y") { votes.push("Yea") @@ -39,11 +40,14 @@ function parseVoteLines( votes.push("Nay") } else if (char === "X") { votes.push("Abstain") + } else if (char === "P") { + votes.push("Present") + present += 1 } } } } - if (votes.length !== expectedVoteCount) { + if (votes.length !== expectedVoteCount+present) { return `Expected ${expectedVoteCount} votes; found ${votes.length} votes` } let nextVote = 0 @@ -53,7 +57,7 @@ function parseVoteLines( break } let line = lines[i] - if (line.match(/^[YNX]*$/)) { + if (line.match(/^[YNPX]*$/)) { continue } let entry = { @@ -161,7 +165,7 @@ function parseVotes(number: number, text: string): RollCall | string { .map(line => line.trim()) .filter(Boolean) const untilFirstVote = lines.findIndex( - line => line.match(/^[YNX]+$/) !== null + line => line.match(/^[YNPX]+$/) !== null ) const lastIntroLine = untilFirstVote - 2 diff --git a/functions/src/votes/scrapers.ts b/functions/src/votes/scrapers.ts index c86e372f9..c0cfc75b9 100644 --- a/functions/src/votes/scrapers.ts +++ b/functions/src/votes/scrapers.ts @@ -212,6 +212,7 @@ export class HouseRollCallScraper { court: number, rollCallNumber: number ): Promise { + court = court - 1 if (!this.legislators) { const legislators = await getHouseLegislators(court) if (typeof legislators === "string") { @@ -293,8 +294,10 @@ export class HouseRollCallScraper { currentGeneralCourt, rollCallNumber ) - // stop at 404 - while (!rollcall.startsWith("fetch error")) { + + console.log("entering") + while ((this.legislators ?? []).length && rollcall === "success") { + console.log(rollCallNumber) rollCallNumber += 1 if (rollcall !== "success") { functions.logger.error( diff --git a/functions/src/votes/types.ts b/functions/src/votes/types.ts index 7da73c0e6..680c2d51d 100644 --- a/functions/src/votes/types.ts +++ b/functions/src/votes/types.ts @@ -48,7 +48,8 @@ export type RollCallResponse = Static export const RollCallResponse = Union( Literal("Yea"), Literal("Nay"), - Literal("Abstain") + Literal("Abstain"), + Literal("Present") ) export type RollCallVote = Static From 8169efbd6d2f293e18624c65f2efc203ded6e643 Mon Sep 17 00:00:00 2001 From: Delexagon Date: Tue, 29 Sep 2026 18:08:43 -0400 Subject: [PATCH 04/18] Prettier --- functions/src/votes/scrapeRollCall.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/votes/scrapeRollCall.ts b/functions/src/votes/scrapeRollCall.ts index 6a5fe955b..801902077 100644 --- a/functions/src/votes/scrapeRollCall.ts +++ b/functions/src/votes/scrapeRollCall.ts @@ -47,7 +47,7 @@ function parseVoteLines( } } } - if (votes.length !== expectedVoteCount+present) { + if (votes.length !== expectedVoteCount + present) { return `Expected ${expectedVoteCount} votes; found ${votes.length} votes` } let nextVote = 0 From 67a0db6b4a0cdb546bc8d1b565097b7e8a13453f Mon Sep 17 00:00:00 2001 From: Delexagon Date: Tue, 29 Sep 2026 18:15:07 -0400 Subject: [PATCH 05/18] Remove senate scraping for later; remove debug --- functions/src/index.ts | 2 +- functions/src/votes/index.ts | 7 +- functions/src/votes/scrapers.ts | 176 +------------------------------- functions/src/votes/types.ts | 12 --- 4 files changed, 3 insertions(+), 194 deletions(-) diff --git a/functions/src/index.ts b/functions/src/index.ts index 0fdf0ada7..a539416bc 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -63,7 +63,7 @@ export { export { scrapeElections } from "./legislators" export { transcriptionV2 } from "./webhooks" -export { scrapeHouseRollCalls, scrapeSenateRollCalls } from "./votes" +export { scrapeHouseRollCalls } from "./votes" export { matchOcpfMembersV2 } from "./ocpf/matchOcpfMembers" export { scrapeOcpfFinanceV2 } from "./ocpf/scrapeOcpfFinance" diff --git a/functions/src/votes/index.ts b/functions/src/votes/index.ts index 870eb4ab1..f216c46da 100644 --- a/functions/src/votes/index.ts +++ b/functions/src/votes/index.ts @@ -1,6 +1 @@ -export { - scrapeHouseRollCalls, - scrapeSenateRollCalls, - HouseRollCallScraper, - SenateRollCallScraper -} from "./scrapers" +export { scrapeHouseRollCalls, HouseRollCallScraper } from "./scrapers" diff --git a/functions/src/votes/scrapers.ts b/functions/src/votes/scrapers.ts index c0cfc75b9..bb0260741 100644 --- a/functions/src/votes/scrapers.ts +++ b/functions/src/votes/scrapers.ts @@ -2,183 +2,13 @@ import { runWith, RuntimeOptions } from "firebase-functions" import { db } from "../firebase" import { getHouseLegislators, - getSenateLegislators, getHouseRollCall, translateNames } from "./scrapeRollCall" import * as functions from "firebase-functions" import { currentGeneralCourt } from "../shared" -import { RollCallVote, HouseRollCall, SenateRollCall } from "./types" +import { RollCallVote, HouseRollCall } from "./types" import { BulkWriter, Timestamp } from "firebase-admin/firestore" -import { getRollCall } from "../malegislature" - -export class SenateRollCallScraper { - private schedule - private timeout - private memory - private legislators: Set | null - - constructor( - schedule: string = "every 24 hours", - timeout: number = 480, - memory: RuntimeOptions["memory"] = "256MB" - ) { - this.schedule = schedule - this.timeout = timeout - this.memory = memory - this.legislators = null - } - - get function() { - return runWith({ - timeoutSeconds: this.timeout, - memory: this.memory, - maxInstances: 1 - }) - .pubsub.schedule(this.schedule) - .onRun(() => this.run()) - } - - async addRollCall( - writer: BulkWriter, - court: number, - rollCallNumber: number - ): Promise { - if (!this.legislators) { - const legislators = await getSenateLegislators(court) - if (typeof legislators === "string") { - this.legislators = new Set() - return `error collecting legislators: ${legislators}` - } else { - if (!legislators.size) { - return `error no legislators found in database` - } - this.legislators = legislators - } - } - if (this.legislators!.size < 40) { - return `discovered senate legislature size is ${ - this.legislators!.size - } (expected 40)` - } - - let rollCall - try { - rollCall = await getRollCall(court, "Senate", rollCallNumber) - } catch (e) { - return `fetch error: ${e}` - } - const rollCallRewritten: SenateRollCall = { - type: "RollCall", - generalCourtNumber: court, - branch: "Senate", - questionMotion: rollCall.QuestionMotion ?? null, - downloadUrl: - rollCall.DownloadUrl ?? - `https://malegislature.gov/api/DownloadRollCall?Branch=House&generalCourtNumber=${court}&rollCallNumber=${rollCallNumber}`, - rollCallNumber - } - - const docId = `senate-${court}-${rollCallNumber}` - writer.set(db.collection("votes").doc(docId), rollCallRewritten) - - const remainingMembers = new Set(this.legislators) - for (let i = 0; i < (rollCall.Yeas ?? []).length; i++) { - const memberCode = rollCall?.Yeas?.[i]?.MemberCode - if (!memberCode) { - continue - } - const voteRewritten: RollCallVote = { - response: "Yea", - branch: "Senate", - memberCode: memberCode!, - generalCourtNumber: court, - rollCallNumber - } - remainingMembers.delete(memberCode) - writer.set( - db.collection(`votes/${docId}/vote`).doc(memberCode), - voteRewritten - ) - } - for (let i = 0; i < (rollCall.Nays ?? []).length; i++) { - const memberCode = rollCall?.Nays?.[i]?.MemberCode - if (!memberCode) { - continue - } - const voteRewritten: RollCallVote = { - response: "Nay", - branch: "Senate", - memberCode: memberCode!, - generalCourtNumber: court, - rollCallNumber - } - remainingMembers.delete(memberCode) - writer.set( - db.collection(`votes/${docId}/vote`).doc(memberCode), - voteRewritten - ) - } - // This includes members that are only present for part of a session - // (should be altered?) - for (const memberCode of remainingMembers) { - const voteRewritten: RollCallVote = { - response: "Abstain", - branch: "Senate", - memberCode: memberCode, - generalCourtNumber: court, - rollCallNumber - } - writer.set( - db.collection(`votes/${docId}/vote`).doc(memberCode), - voteRewritten - ) - } - return "success" - } - - private async run() { - const snapshot = await db - .collection("votes") - .where("type", "==", "rollcall") - .where("branch", "==", "Senate") - .get() - - let rollCallNumber = 1 - for (const doc of snapshot.docs) { - const name = doc.ref.id - const thisCourt = Number(name.split("-")[1]) - const thisNumber = Number(name.split("-")[2]) - if (thisCourt === currentGeneralCourt && thisNumber > rollCallNumber) { - rollCallNumber = thisNumber - } - } - - const writer = db.bulkWriter() - - let rollcall = await this.addRollCall( - writer, - currentGeneralCourt, - rollCallNumber - ) - while (!rollcall.startsWith("fetch error")) { - rollCallNumber += 1 - if (rollcall !== "success") { - functions.logger.error( - `Error collecting senate rollcall ${rollCallNumber}: ${rollcall}` - ) - } - - rollcall = await this.addRollCall( - writer, - currentGeneralCourt, - rollCallNumber - ) - } - - await writer.close() - } -} export class HouseRollCallScraper { private schedule @@ -212,7 +42,6 @@ export class HouseRollCallScraper { court: number, rollCallNumber: number ): Promise { - court = court - 1 if (!this.legislators) { const legislators = await getHouseLegislators(court) if (typeof legislators === "string") { @@ -295,9 +124,7 @@ export class HouseRollCallScraper { rollCallNumber ) - console.log("entering") while ((this.legislators ?? []).length && rollcall === "success") { - console.log(rollCallNumber) rollCallNumber += 1 if (rollcall !== "success") { functions.logger.error( @@ -316,4 +143,3 @@ export class HouseRollCallScraper { } export const scrapeHouseRollCalls = new HouseRollCallScraper().function -export const scrapeSenateRollCalls = new SenateRollCallScraper().function diff --git a/functions/src/votes/types.ts b/functions/src/votes/types.ts index 680c2d51d..d1b33f417 100644 --- a/functions/src/votes/types.ts +++ b/functions/src/votes/types.ts @@ -61,18 +61,6 @@ export const RollCallVote = Record({ branch: Branch }) -// Roll call API provides less information - -// most importantly, it does not have the associated bill -export type SenateRollCall = Static -export const SenateRollCall = Record({ - type: VoteType, - generalCourtNumber: Number, - branch: Branch, - rollCallNumber: Number, - questionMotion: Union(String, Null), - downloadUrl: String -}) - export type HouseRollCall = Static export const HouseRollCall = Record({ type: VoteType, From e80128769b63b5b542bb3815a02ea836e0bc5c62 Mon Sep 17 00:00:00 2001 From: Delexagon Date: Tue, 29 Sep 2026 18:43:51 -0400 Subject: [PATCH 06/18] Readd senate scraping --- functions/src/index.ts | 2 +- functions/src/votes/index.ts | 7 +- functions/src/votes/scrapeRollCall.ts | 16 -- functions/src/votes/scrapers.ts | 163 +++++++++++++++++++- functions/src/votes/types.ts | 12 ++ scripts/firebase-admin/backfillRollCalls.ts | 1 - 6 files changed, 176 insertions(+), 25 deletions(-) diff --git a/functions/src/index.ts b/functions/src/index.ts index a539416bc..0fdf0ada7 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -63,7 +63,7 @@ export { export { scrapeElections } from "./legislators" export { transcriptionV2 } from "./webhooks" -export { scrapeHouseRollCalls } from "./votes" +export { scrapeHouseRollCalls, scrapeSenateRollCalls } from "./votes" export { matchOcpfMembersV2 } from "./ocpf/matchOcpfMembers" export { scrapeOcpfFinanceV2 } from "./ocpf/scrapeOcpfFinance" diff --git a/functions/src/votes/index.ts b/functions/src/votes/index.ts index f216c46da..870eb4ab1 100644 --- a/functions/src/votes/index.ts +++ b/functions/src/votes/index.ts @@ -1 +1,6 @@ -export { scrapeHouseRollCalls, HouseRollCallScraper } from "./scrapers" +export { + scrapeHouseRollCalls, + scrapeSenateRollCalls, + HouseRollCallScraper, + SenateRollCallScraper +} from "./scrapers" diff --git a/functions/src/votes/scrapeRollCall.ts b/functions/src/votes/scrapeRollCall.ts index 801902077..c15c877b7 100644 --- a/functions/src/votes/scrapeRollCall.ts +++ b/functions/src/votes/scrapeRollCall.ts @@ -223,22 +223,6 @@ export async function getHouseRollCall( return { downloadUrl: url, ...votes } } -// Used to collect senate legislator ids -export async function getSenateLegislators( - court: number -): Promise> { - const snapshot = await db.collection(`generalCourts/${court}/members`).get() - - const result: Set = new Set() - for (const doc of snapshot.docs) { - const data = doc.data() - if (data.content.Branch === "Senate") { - result.add(data.id) - } - } - return result -} - // Used to cross-reference legislator names to ids // Returns a [legislator name as present on pdfs, legislator id][] | error export async function getHouseLegislators( diff --git a/functions/src/votes/scrapers.ts b/functions/src/votes/scrapers.ts index bb0260741..7209f03c5 100644 --- a/functions/src/votes/scrapers.ts +++ b/functions/src/votes/scrapers.ts @@ -7,8 +7,158 @@ import { } from "./scrapeRollCall" import * as functions from "firebase-functions" import { currentGeneralCourt } from "../shared" -import { RollCallVote, HouseRollCall } from "./types" +import { RollCallVote, HouseRollCall, SenateRollCall } from "./types" import { BulkWriter, Timestamp } from "firebase-admin/firestore" +import { getRollCall } from "../malegislature" + +export class SenateRollCallScraper { + 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()) + } + + async addRollCall( + writer: BulkWriter, + court: number, + rollCallNumber: number + ): Promise { + let rollCall + try { + rollCall = await getRollCall(court, "Senate", rollCallNumber) + } catch (e) { + return `fetch error: ${e}` + } + const rollCallRewritten: SenateRollCall = { + type: "RollCall", + generalCourtNumber: court, + branch: "Senate", + questionMotion: rollCall.QuestionMotion ?? null, + downloadUrl: + rollCall.DownloadUrl ?? + `https://malegislature.gov/api/DownloadRollCall?Branch=House&generalCourtNumber=${court}&rollCallNumber=${rollCallNumber}`, + rollCallNumber + } + + const docId = `senate-${court}-${rollCallNumber}` + writer.set(db.collection("votes").doc(docId), rollCallRewritten) + + for (let i = 0; i < (rollCall.Yeas ?? []).length; i++) { + const memberCode = rollCall?.Yeas?.[i]?.MemberCode + if (!memberCode) { + continue + } + const voteRewritten: RollCallVote = { + response: "Yea", + branch: "Senate", + memberCode: memberCode!, + generalCourtNumber: court, + rollCallNumber + } + writer.set( + db.collection(`votes/${docId}/vote`).doc(memberCode), + voteRewritten + ) + } + for (let i = 0; i < (rollCall.Nays ?? []).length; i++) { + const memberCode = rollCall?.Nays?.[i]?.MemberCode + if (!memberCode) { + continue + } + const voteRewritten: RollCallVote = { + response: "Nay", + branch: "Senate", + memberCode: memberCode!, + generalCourtNumber: court, + rollCallNumber + } + writer.set( + db.collection(`votes/${docId}/vote`).doc(memberCode), + voteRewritten + ) + } + // This includes members that are only present for part of a session + // (should be altered?) + for (let i = 0; i < (rollCall.Absent ?? []).length; i++) { + const memberCode = rollCall?.Absent?.[i]?.MemberCode + if (!memberCode) { + continue + } + const voteRewritten: RollCallVote = { + response: "Abstain", + branch: "Senate", + memberCode: memberCode!, + generalCourtNumber: court, + rollCallNumber + } + writer.set( + db.collection(`votes/${docId}/vote`).doc(memberCode), + voteRewritten + ) + } + return "success" + } + + private async run() { + const snapshot = await db + .collection("votes") + .where("type", "==", "rollcall") + .where("branch", "==", "Senate") + .get() + + let rollCallNumber = 1 + for (const doc of snapshot.docs) { + const name = doc.ref.id + const thisCourt = Number(name.split("-")[1]) + const thisNumber = Number(name.split("-")[2]) + if (thisCourt === currentGeneralCourt && thisNumber > rollCallNumber) { + rollCallNumber = thisNumber + } + } + + const writer = db.bulkWriter() + + let rollcall = await this.addRollCall( + writer, + currentGeneralCourt, + rollCallNumber + ) + while (rollcall === "success") { + rollCallNumber += 1 + + rollcall = await this.addRollCall( + writer, + currentGeneralCourt, + rollCallNumber + ) + } + if (!rollcall.startsWith("fetch error")) { + functions.logger.error( + `Error collecting house rollcall ${rollCallNumber}: ${rollcall}` + ) + } + + await writer.close() + } +} export class HouseRollCallScraper { private schedule @@ -126,20 +276,21 @@ export class HouseRollCallScraper { while ((this.legislators ?? []).length && rollcall === "success") { rollCallNumber += 1 - if (rollcall !== "success") { - functions.logger.error( - `Error collecting house rollcall ${rollCallNumber}: ${rollcall}` - ) - } rollcall = await this.addRollCall( writer, currentGeneralCourt, rollCallNumber ) } + if (!rollcall.startsWith("fetch error")) { + functions.logger.error( + `Error collecting house rollcall ${rollCallNumber}: ${rollcall}` + ) + } await writer.close() } } export const scrapeHouseRollCalls = new HouseRollCallScraper().function +export const scrapeSenateRollCalls = new SenateRollCallScraper().function diff --git a/functions/src/votes/types.ts b/functions/src/votes/types.ts index d1b33f417..680c2d51d 100644 --- a/functions/src/votes/types.ts +++ b/functions/src/votes/types.ts @@ -61,6 +61,18 @@ export const RollCallVote = Record({ branch: Branch }) +// Roll call API provides less information - +// most importantly, it does not have the associated bill +export type SenateRollCall = Static +export const SenateRollCall = Record({ + type: VoteType, + generalCourtNumber: Number, + branch: Branch, + rollCallNumber: Number, + questionMotion: Union(String, Null), + downloadUrl: String +}) + export type HouseRollCall = Static export const HouseRollCall = Record({ type: VoteType, diff --git a/scripts/firebase-admin/backfillRollCalls.ts b/scripts/firebase-admin/backfillRollCalls.ts index 6a1513076..e40a2ad4f 100644 --- a/scripts/firebase-admin/backfillRollCalls.ts +++ b/scripts/firebase-admin/backfillRollCalls.ts @@ -4,7 +4,6 @@ import { HouseRollCallScraper, SenateRollCallScraper } from "functions/src/votes" -import { getSenateLegislators } from "functions/src/votes/scrapeRollCall" const Args = Record({ court: Number, From 30bf546a6649b20c8f3cba0b111e9677d65af0f0 Mon Sep 17 00:00:00 2001 From: Delexagon Date: Tue, 13 Oct 2026 19:32:32 -0400 Subject: [PATCH 07/18] Retrieve bill from senate scraping --- functions/src/votes/scrapeRollCall.ts | 27 +++++++++++++++++++++++++-- functions/src/votes/scrapers.ts | 5 +++++ functions/src/votes/types.ts | 1 + 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/functions/src/votes/scrapeRollCall.ts b/functions/src/votes/scrapeRollCall.ts index c15c877b7..69e6f283c 100644 --- a/functions/src/votes/scrapeRollCall.ts +++ b/functions/src/votes/scrapeRollCall.ts @@ -202,15 +202,19 @@ function parseVotes(number: number, text: string): RollCall | string { } } -function downloadUrl(court: number, number: number): string { +function houseDownloadUrl(court: number, number: number): string { return `https://malegislature.gov/RollCall/${court}/HouseRollCall${number}.pdf` } +export function senateDownloadUrl(court: number, number: number): string { + return `https://malegislature.gov/RollCall/${court}/SenateRollCall${number}.pdf` +} + export async function getHouseRollCall( court: number, number: number ): Promise { - const url = downloadUrl(court, number) + const url = houseDownloadUrl(court, number) const pdf = await fetch(url) if (pdf.status === 404) { return null @@ -313,3 +317,22 @@ export function translateNames( } return translations } + +export async function fetchSenateBillNumber( + url: string +): Promise { + const pdf = await fetch(url) + if (pdf.status === 404) { + return null + } + const result = await pdfParse(Buffer.from(await pdf.arrayBuffer())) + const text = result.text + text.split("\n") + for (const line of text.split("\n")) { + const match = line.match(/S\.([0-9]+)/) + if (match) { + return `S${match[1]}` + } + } + return null +} diff --git a/functions/src/votes/scrapers.ts b/functions/src/votes/scrapers.ts index 7209f03c5..efea1e843 100644 --- a/functions/src/votes/scrapers.ts +++ b/functions/src/votes/scrapers.ts @@ -1,8 +1,10 @@ import { runWith, RuntimeOptions } from "firebase-functions" import { db } from "../firebase" import { + fetchSenateBillNumber, getHouseLegislators, getHouseRollCall, + senateDownloadUrl, translateNames } from "./scrapeRollCall" import * as functions from "firebase-functions" @@ -47,10 +49,13 @@ export class SenateRollCallScraper { } catch (e) { return `fetch error: ${e}` } + const pdfURL = senateDownloadUrl(court, rollCallNumber) + const bill = await fetchSenateBillNumber(pdfURL) const rollCallRewritten: SenateRollCall = { type: "RollCall", generalCourtNumber: court, branch: "Senate", + bill, questionMotion: rollCall.QuestionMotion ?? null, downloadUrl: rollCall.DownloadUrl ?? diff --git a/functions/src/votes/types.ts b/functions/src/votes/types.ts index 680c2d51d..141dab78d 100644 --- a/functions/src/votes/types.ts +++ b/functions/src/votes/types.ts @@ -68,6 +68,7 @@ export const SenateRollCall = Record({ type: VoteType, generalCourtNumber: Number, branch: Branch, + bill: Union(String, Null), rollCallNumber: Number, questionMotion: Union(String, Null), downloadUrl: String From c92386349ece4ff7589d33950dee07af2659fbd8 Mon Sep 17 00:00:00 2001 From: Delexagon Date: Tue, 13 Oct 2026 19:41:04 -0400 Subject: [PATCH 08/18] Move to v2 functions --- functions/src/votes/scrapers.ts | 39 +++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/functions/src/votes/scrapers.ts b/functions/src/votes/scrapers.ts index efea1e843..dba52eddc 100644 --- a/functions/src/votes/scrapers.ts +++ b/functions/src/votes/scrapers.ts @@ -1,4 +1,5 @@ -import { runWith, RuntimeOptions } from "firebase-functions" +import type { MemoryOption } from "firebase-functions/v2" +import { onSchedule } from "firebase-functions/v2/scheduler" import { db } from "../firebase" import { fetchSenateBillNumber, @@ -21,7 +22,7 @@ export class SenateRollCallScraper { constructor( schedule: string = "every 24 hours", timeout: number = 480, - memory: RuntimeOptions["memory"] = "256MB" + memory: MemoryOption = "256MiB" ) { this.schedule = schedule this.timeout = timeout @@ -29,13 +30,15 @@ export class SenateRollCallScraper { } get function() { - return runWith({ - timeoutSeconds: this.timeout, - memory: this.memory, - maxInstances: 1 - }) - .pubsub.schedule(this.schedule) - .onRun(() => this.run()) + return onSchedule( + { + schedule: this.schedule, + timeoutSeconds: this.timeout, + memory: this.memory, + maxInstances: 1 + }, + () => this.run() + ) } async addRollCall( @@ -174,7 +177,7 @@ export class HouseRollCallScraper { constructor( schedule: string = "every 24 hours", timeout: number = 480, - memory: RuntimeOptions["memory"] = "256MB" + memory: MemoryOption = "256MiB" ) { this.schedule = schedule this.timeout = timeout @@ -183,13 +186,15 @@ export class HouseRollCallScraper { } get function() { - return runWith({ - timeoutSeconds: this.timeout, - memory: this.memory, - maxInstances: 1 - }) - .pubsub.schedule(this.schedule) - .onRun(() => this.run()) + return onSchedule( + { + schedule: this.schedule, + timeoutSeconds: this.timeout, + memory: this.memory, + maxInstances: 1 + }, + () => this.run() + ) } async addRollCall( From aa826d0b038288f3d7c775b2348f3cb3d47f617d Mon Sep 17 00:00:00 2001 From: Delexagon Date: Tue, 13 Oct 2026 20:21:13 -0400 Subject: [PATCH 09/18] Bugfixes --- functions/src/malegislature.ts | 2 +- functions/src/votes/scrapers.ts | 64 +++++++++------------ scripts/firebase-admin/backfillRollCalls.ts | 42 ++++++++++---- 3 files changed, 59 insertions(+), 49 deletions(-) diff --git a/functions/src/malegislature.ts b/functions/src/malegislature.ts index 1d73d68cc..3ffbc473b 100644 --- a/functions/src/malegislature.ts +++ b/functions/src/malegislature.ts @@ -99,7 +99,7 @@ export async function getDocument({ method: "GET", timeout: 30_000 }) - return APIDocument.check(response) + return response as any } export async function getDocumentPdf({ diff --git a/functions/src/votes/scrapers.ts b/functions/src/votes/scrapers.ts index dba52eddc..55f847088 100644 --- a/functions/src/votes/scrapers.ts +++ b/functions/src/votes/scrapers.ts @@ -10,10 +10,26 @@ import { } from "./scrapeRollCall" import * as functions from "firebase-functions" import { currentGeneralCourt } from "../shared" -import { RollCallVote, HouseRollCall, SenateRollCall } from "./types" +import { RollCallVote, HouseRollCall, SenateRollCall, Branch } from "./types" import { BulkWriter, Timestamp } from "firebase-admin/firestore" import { getRollCall } from "../malegislature" +async function getHighestProcessedRollCall(branch: Branch): Promise { + const lastVote = await db + .collection("votes") + .where("type", "==", "RollCall") + .where("branch", "==", branch) + .where("generalCourtNumber", "==", currentGeneralCourt) + .orderBy("rollCallNumber", "desc") + .select("rollCallNumber") + .limit(1) + .get() + + // Roll call numbering starts at 1 + const number = lastVote.empty ? 0 : lastVote.docs[0].get("rollCallNumber") + return number +} + export class SenateRollCallScraper { private schedule private timeout @@ -62,7 +78,7 @@ export class SenateRollCallScraper { questionMotion: rollCall.QuestionMotion ?? null, downloadUrl: rollCall.DownloadUrl ?? - `https://malegislature.gov/api/DownloadRollCall?Branch=House&generalCourtNumber=${court}&rollCallNumber=${rollCallNumber}`, + `http://malegislature.gov/api/DownloadRollCall?Branch=Senate&generalCourtNumber=${court}&rollCallNumber=${rollCallNumber}`, rollCallNumber } @@ -103,8 +119,6 @@ export class SenateRollCallScraper { voteRewritten ) } - // This includes members that are only present for part of a session - // (should be altered?) for (let i = 0; i < (rollCall.Absent ?? []).length; i++) { const memberCode = rollCall?.Absent?.[i]?.MemberCode if (!memberCode) { @@ -126,21 +140,7 @@ export class SenateRollCallScraper { } private async run() { - const snapshot = await db - .collection("votes") - .where("type", "==", "rollcall") - .where("branch", "==", "Senate") - .get() - - let rollCallNumber = 1 - for (const doc of snapshot.docs) { - const name = doc.ref.id - const thisCourt = Number(name.split("-")[1]) - const thisNumber = Number(name.split("-")[2]) - if (thisCourt === currentGeneralCourt && thisNumber > rollCallNumber) { - rollCallNumber = thisNumber - } - } + let rollCallNumber = (await getHighestProcessedRollCall("Senate")) + 1 const writer = db.bulkWriter() @@ -158,13 +158,14 @@ export class SenateRollCallScraper { rollCallNumber ) } + + await writer.close() + if (!rollcall.startsWith("fetch error")) { functions.logger.error( `Error collecting house rollcall ${rollCallNumber}: ${rollcall}` ) } - - await writer.close() } } @@ -260,21 +261,7 @@ export class HouseRollCallScraper { } private async run() { - const snapshot = await db - .collection("votes") - .where("type", "==", "rollcall") - .where("branch", "==", "House") - .get() - - let rollCallNumber = 1 - for (const doc of snapshot.docs) { - const name = doc.ref.id - const thisCourt = Number(name.split("-")[1]) - const thisNumber = Number(name.split("-")[2]) - if (thisCourt === currentGeneralCourt && thisNumber > rollCallNumber) { - rollCallNumber = thisNumber - } - } + let rollCallNumber = await getHighestProcessedRollCall("House") const writer = db.bulkWriter() @@ -292,13 +279,14 @@ export class HouseRollCallScraper { rollCallNumber ) } + + await writer.close() + if (!rollcall.startsWith("fetch error")) { functions.logger.error( `Error collecting house rollcall ${rollCallNumber}: ${rollcall}` ) } - - await writer.close() } } diff --git a/scripts/firebase-admin/backfillRollCalls.ts b/scripts/firebase-admin/backfillRollCalls.ts index e40a2ad4f..70a17d198 100644 --- a/scripts/firebase-admin/backfillRollCalls.ts +++ b/scripts/firebase-admin/backfillRollCalls.ts @@ -27,13 +27,13 @@ export const script: Script = async ({ db, args }) => { const houseScraper = new HouseRollCallScraper() const senateScraper = new SenateRollCallScraper() for (; court <= endCourt; court++) { - for ( - let rollCallNumber = number; - !endNumber || rollCallNumber <= endNumber; - rollCallNumber++ - ) { - if (!branch) { - let result = await senateScraper.addRollCall( + if (!branch) { + for ( + let rollCallNumber = number; + !endNumber || rollCallNumber <= endNumber; + rollCallNumber++ + ) { + const result = await senateScraper.addRollCall( writer, court, rollCallNumber @@ -44,14 +44,30 @@ export const script: Script = async ({ db, args }) => { ) return } - result = await houseScraper.addRollCall(writer, court, rollCallNumber) + } + for ( + let rollCallNumber = number; + !endNumber || rollCallNumber <= endNumber; + rollCallNumber++ + ) { + const result = await houseScraper.addRollCall( + writer, + court, + rollCallNumber + ) if (result !== "success") { console.log( `Error fetching house rollcall ${rollCallNumber} for court ${court}: ${result}` ) return } - } else if (branch === "Senate") { + } + } else if (branch === "Senate") { + for ( + let rollCallNumber = number; + !endNumber || rollCallNumber <= endNumber; + rollCallNumber++ + ) { const result = await senateScraper.addRollCall( writer, court, @@ -63,7 +79,13 @@ export const script: Script = async ({ db, args }) => { ) return } - } else if (branch === "House") { + } + } else if (branch === "House") { + for ( + let rollCallNumber = number; + !endNumber || rollCallNumber <= endNumber; + rollCallNumber++ + ) { const result = await houseScraper.addRollCall( writer, court, From 1fe37ab2faad938bb16f857c9febbd43b722b7ff Mon Sep 17 00:00:00 2001 From: Delexagon Date: Tue, 13 Oct 2026 21:57:12 -0400 Subject: [PATCH 10/18] Description for not voting --- functions/src/votes/scrapeRollCall.ts | 8 ++++---- functions/src/votes/scrapers.ts | 6 +++++- functions/src/votes/types.ts | 9 ++++++++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/functions/src/votes/scrapeRollCall.ts b/functions/src/votes/scrapeRollCall.ts index 69e6f283c..70a52df30 100644 --- a/functions/src/votes/scrapeRollCall.ts +++ b/functions/src/votes/scrapeRollCall.ts @@ -30,7 +30,7 @@ function parseVoteLines( ): HouseRollCallVote[] | string { const results = [] let present = 0 - const votes: ("Yea" | "Nay" | "Abstain" | "Present")[] = [] + const votes: ("Yea" | "Nay" | "Not Voting" | "Present")[] = [] for (const line of lines) { if (line.match(/^[YNPX]*$/)) { for (const char of line) { @@ -39,7 +39,7 @@ function parseVoteLines( } else if (char === "N") { votes.push("Nay") } else if (char === "X") { - votes.push("Abstain") + votes.push("Not Voting") } else if (char === "P") { votes.push("Present") present += 1 @@ -97,7 +97,7 @@ type RollCall = { yeas: number nays: number time: Date - abstains: number + nv: number } export type HouseRollCallResult = RollCall & { downloadUrl: string } @@ -198,7 +198,7 @@ function parseVotes(number: number, text: string): RollCall | string { time, yeas: Number(yeas), nays: Number(nays), - abstains: Number(nv) + nv: Number(nv) } } diff --git a/functions/src/votes/scrapers.ts b/functions/src/votes/scrapers.ts index 55f847088..acc56179d 100644 --- a/functions/src/votes/scrapers.ts +++ b/functions/src/votes/scrapers.ts @@ -119,13 +119,17 @@ export class SenateRollCallScraper { voteRewritten ) } + // The senate PDFs, members who are listed as absent + // are listed in the section "ABSENT OR NOT VOTING", + // so absent is switch to "Not Voting" (the more general + // description) here. for (let i = 0; i < (rollCall.Absent ?? []).length; i++) { const memberCode = rollCall?.Absent?.[i]?.MemberCode if (!memberCode) { continue } const voteRewritten: RollCallVote = { - response: "Abstain", + response: "Not Voting", branch: "Senate", memberCode: memberCode!, generalCourtNumber: court, diff --git a/functions/src/votes/types.ts b/functions/src/votes/types.ts index 141dab78d..e7e268d0f 100644 --- a/functions/src/votes/types.ts +++ b/functions/src/votes/types.ts @@ -44,11 +44,18 @@ export const CommitteeVotes = Record({ committee: String }) +// The API states "Absent" votes, which theoretically excludes "Present" +// votes. A "Present" vote is where a member explicitly states they are present +// without voting, which can affect the quorum. +// For "Not Voting", we cannot determine whether the member was absent or whether +// they were physically present but made no statement from the roll call record. +// Since it is additional information, "Present" votes are separated from "Not Voting" +// (unknown status), but they can also be considered an example of not voting. export type RollCallResponse = Static export const RollCallResponse = Union( Literal("Yea"), Literal("Nay"), - Literal("Abstain"), + Literal("Not Voting"), Literal("Present") ) From 995da2264eeee557f3f96ed38cc1636b9d121f8c Mon Sep 17 00:00:00 2001 From: Delexagon Date: Tue, 13 Oct 2026 22:05:12 -0400 Subject: [PATCH 11/18] Fixed unit test --- tests/fixtures/votes/HouseRollCall19Read.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/votes/HouseRollCall19Read.json b/tests/fixtures/votes/HouseRollCall19Read.json index 3b8094379..fc18bf643 100644 --- a/tests/fixtures/votes/HouseRollCall19Read.json +++ b/tests/fixtures/votes/HouseRollCall19Read.json @@ -1113,5 +1113,5 @@ "time": "2023-04-25T01:06:00.000Z", "yeas": 158, "nays": 0, - "abstains": 0 + "nv": 0 } \ No newline at end of file From 0b53f824392e39b2c842ab4cb74a5b81fa8d846d Mon Sep 17 00:00:00 2001 From: Delexagon Date: Tue, 20 Oct 2026 19:55:05 -0400 Subject: [PATCH 12/18] Fix +1 --- functions/src/votes/scrapers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/votes/scrapers.ts b/functions/src/votes/scrapers.ts index acc56179d..789d119a8 100644 --- a/functions/src/votes/scrapers.ts +++ b/functions/src/votes/scrapers.ts @@ -265,7 +265,7 @@ export class HouseRollCallScraper { } private async run() { - let rollCallNumber = await getHighestProcessedRollCall("House") + let rollCallNumber = (await getHighestProcessedRollCall("House")) + 1 const writer = db.bulkWriter() From 74b81b123bf6fde82b5c6874ef1efb08ac88a8d5 Mon Sep 17 00:00:00 2001 From: Delexagon Date: Tue, 20 Oct 2026 20:19:17 -0400 Subject: [PATCH 13/18] Remove outdated comment --- functions/src/votes/types.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/functions/src/votes/types.ts b/functions/src/votes/types.ts index e7e268d0f..40794d77f 100644 --- a/functions/src/votes/types.ts +++ b/functions/src/votes/types.ts @@ -68,8 +68,6 @@ export const RollCallVote = Record({ branch: Branch }) -// Roll call API provides less information - -// most importantly, it does not have the associated bill export type SenateRollCall = Static export const SenateRollCall = Record({ type: VoteType, From 1e641b058f3e6c07bdbf2cc74aae3a395ff3067d Mon Sep 17 00:00:00 2001 From: Delexagon Date: Tue, 20 Oct 2026 20:41:50 -0400 Subject: [PATCH 14/18] Check for cross senate/house bill numbers --- functions/src/votes/scrapeRollCall.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/functions/src/votes/scrapeRollCall.ts b/functions/src/votes/scrapeRollCall.ts index 70a52df30..7bab5da37 100644 --- a/functions/src/votes/scrapeRollCall.ts +++ b/functions/src/votes/scrapeRollCall.ts @@ -118,7 +118,7 @@ function parseQuestion(number: number, input: string) { const search1 = /([0-9]+\/[0-9]+\/[0-9]+ [0-9]+:[0-9]+ (AM|PM))/ const search2 = /([0-9]+) YEAS([0-9]+) NAYS([0-9]+) N\/V/ - const search3 = /H\. ([0-9]+)/ + const search3 = /(H|S)\. ([0-9]+)/ // Find all required values const timeMatch = input.match(search1) @@ -139,7 +139,12 @@ function parseQuestion(number: number, input: string) { const yeas = Number(voteMatch[1]) const nays = Number(voteMatch[2]) const nv = Number(voteMatch[3]) - const billNumber = billMatch?.[1] ?? null + let billNumber + if (!billMatch) { + billNumber = null + } else { + billNumber = billMatch[1] + billMatch[2] + } // Remove everything we've extracted let question = input @@ -329,9 +334,9 @@ export async function fetchSenateBillNumber( const text = result.text text.split("\n") for (const line of text.split("\n")) { - const match = line.match(/S\.([0-9]+)/) + const match = line.match(/(S|H)\.([0-9]+)/) if (match) { - return `S${match[1]}` + return `${match[1]}${match[2]}` } } return null From dfad2f312afae8517a192da806a0fc3c19cdb65b Mon Sep 17 00:00:00 2001 From: Delexagon Date: Tue, 20 Oct 2026 20:53:20 -0400 Subject: [PATCH 15/18] Fix roll call gets --- functions/src/votes/scrapeRollCall.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/functions/src/votes/scrapeRollCall.ts b/functions/src/votes/scrapeRollCall.ts index 7bab5da37..1321eaa7e 100644 --- a/functions/src/votes/scrapeRollCall.ts +++ b/functions/src/votes/scrapeRollCall.ts @@ -118,7 +118,7 @@ function parseQuestion(number: number, input: string) { const search1 = /([0-9]+\/[0-9]+\/[0-9]+ [0-9]+:[0-9]+ (AM|PM))/ const search2 = /([0-9]+) YEAS([0-9]+) NAYS([0-9]+) N\/V/ - const search3 = /(H|S)\. ([0-9]+)/ + const search3 = /(H|S)\. ([0-9]+)(, )?/ // Find all required values const timeMatch = input.match(search1) @@ -195,10 +195,9 @@ function parseVotes(number: number, text: string): RollCall | string { return `Error interpreting pdf for house roll call ${number}: ${results}` } - const bill = billNumber ? `H${billNumber}` : null return { votes: results, - bill, + bill: billNumber, question, time, yeas: Number(yeas), From 7dd8e6c23b735a1e249635d36f184ffd28a6106f Mon Sep 17 00:00:00 2001 From: Delexagon Date: Tue, 20 Oct 2026 22:11:20 -0400 Subject: [PATCH 16/18] Start at 1 --- scripts/firebase-admin/backfillRollCalls.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/firebase-admin/backfillRollCalls.ts b/scripts/firebase-admin/backfillRollCalls.ts index 70a17d198..1c85fee58 100644 --- a/scripts/firebase-admin/backfillRollCalls.ts +++ b/scripts/firebase-admin/backfillRollCalls.ts @@ -21,7 +21,7 @@ export const script: Script = async ({ db, args }) => { endNumber = number } if (!number) { - number = 0 + number = 1 } const writer = db.bulkWriter() const houseScraper = new HouseRollCallScraper() From b1e0631f28d1f9ed3e9316fef7f10ad1f29d342a Mon Sep 17 00:00:00 2001 From: Delexagon Date: Tue, 20 Oct 2026 23:03:13 -0400 Subject: [PATCH 17/18] Better logging for duplicate legislators --- functions/src/votes/scrapeRollCall.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/functions/src/votes/scrapeRollCall.ts b/functions/src/votes/scrapeRollCall.ts index 1321eaa7e..47fb2dfb0 100644 --- a/functions/src/votes/scrapeRollCall.ts +++ b/functions/src/votes/scrapeRollCall.ts @@ -295,12 +295,18 @@ export function translateNames( const namePart = name.slice(i + 1) const getInitial = searchMap.get(`${initial}, ${namePart}`) if (getInitial !== undefined) { + if (translations[getInitial] !== undefined) { + return `two legislators matched ${namePart}, ${initial}: ${translations[getInitial]}, ${id}` + } translations[getInitial] = id j += 1 break } const getNoInitial = searchMap.get(`null, ${namePart}`) if (getNoInitial !== undefined) { + if (translations[getNoInitial] !== undefined) { + return `two legislators matched ${namePart}: ${translations[getNoInitial]}, ${id}` + } translations[getNoInitial] = id j += 1 break @@ -310,6 +316,9 @@ export function translateNames( } if (j !== searching.length) { const missing = [] + if (j > searching.length) { + return "multiple database legislators matched with a single rollcall legislator" + } for (let i = 0; i < searching.length; i++) { if (!translations[i]) { missing.push(searching[i]) From 1557caa1f3668402a353d3dd37aee593f4791168 Mon Sep 17 00:00:00 2001 From: Delexagon Date: Tue, 20 Oct 2026 23:16:43 -0400 Subject: [PATCH 18/18] Prune spare ' , 's --- functions/src/votes/scrapeRollCall.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/votes/scrapeRollCall.ts b/functions/src/votes/scrapeRollCall.ts index 47fb2dfb0..85e12b974 100644 --- a/functions/src/votes/scrapeRollCall.ts +++ b/functions/src/votes/scrapeRollCall.ts @@ -118,7 +118,7 @@ function parseQuestion(number: number, input: string) { const search1 = /([0-9]+\/[0-9]+\/[0-9]+ [0-9]+:[0-9]+ (AM|PM))/ const search2 = /([0-9]+) YEAS([0-9]+) NAYS([0-9]+) N\/V/ - const search3 = /(H|S)\. ([0-9]+)(, )?/ + const search3 = /(H|S)\. ([0-9]+)( , )?/ // Find all required values const timeMatch = input.match(search1)