tn-elections-mcp 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -0
- package/dist/api/easyvote-client.d.ts +51 -0
- package/dist/api/easyvote-client.js +254 -0
- package/dist/api/easyvote-client.js.map +1 -0
- package/dist/api/easyvote-client.test.d.ts +1 -0
- package/dist/api/easyvote-client.test.js +47 -0
- package/dist/api/easyvote-client.test.js.map +1 -0
- package/dist/api/gemini-pdf.d.ts +23 -0
- package/dist/api/gemini-pdf.js +261 -0
- package/dist/api/gemini-pdf.js.map +1 -0
- package/dist/api/sos-client.d.ts +15 -0
- package/dist/api/sos-client.js +74 -0
- package/dist/api/sos-client.js.map +1 -0
- package/dist/api/sumner-client.d.ts +12 -0
- package/dist/api/sumner-client.js +57 -0
- package/dist/api/sumner-client.js.map +1 -0
- package/dist/api/tncamp-client.d.ts +61 -0
- package/dist/api/tncamp-client.js +310 -0
- package/dist/api/tncamp-client.js.map +1 -0
- package/dist/data/sumner-cycles.d.ts +3 -0
- package/dist/data/sumner-cycles.js +19 -0
- package/dist/data/sumner-cycles.js.map +1 -0
- package/dist/data/sumner-cycles.test.d.ts +1 -0
- package/dist/data/sumner-cycles.test.js +29 -0
- package/dist/data/sumner-cycles.test.js.map +1 -0
- package/dist/data/tn-counties.d.ts +23 -0
- package/dist/data/tn-counties.js +162 -0
- package/dist/data/tn-counties.js.map +1 -0
- package/dist/data/tn-counties.test.d.ts +1 -0
- package/dist/data/tn-counties.test.js +71 -0
- package/dist/data/tn-counties.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/parsers/sos-xlsx.d.ts +24 -0
- package/dist/parsers/sos-xlsx.js +149 -0
- package/dist/parsers/sos-xlsx.js.map +1 -0
- package/dist/parsers/sos-xlsx.test.d.ts +1 -0
- package/dist/parsers/sos-xlsx.test.js +111 -0
- package/dist/parsers/sos-xlsx.test.js.map +1 -0
- package/dist/parsers/sumner-html.d.ts +8 -0
- package/dist/parsers/sumner-html.js +200 -0
- package/dist/parsers/sumner-html.js.map +1 -0
- package/dist/parsers/sumner-html.test.d.ts +1 -0
- package/dist/parsers/sumner-html.test.js +79 -0
- package/dist/parsers/sumner-html.test.js.map +1 -0
- package/dist/parsers/tncamp-form.d.ts +15 -0
- package/dist/parsers/tncamp-form.js +54 -0
- package/dist/parsers/tncamp-form.js.map +1 -0
- package/dist/parsers/tncamp-form.test.d.ts +1 -0
- package/dist/parsers/tncamp-form.test.js +114 -0
- package/dist/parsers/tncamp-form.test.js.map +1 -0
- package/dist/parsers/tncamp-report.d.ts +6 -0
- package/dist/parsers/tncamp-report.js +189 -0
- package/dist/parsers/tncamp-report.js.map +1 -0
- package/dist/parsers/tncamp-results.d.ts +10 -0
- package/dist/parsers/tncamp-results.js +113 -0
- package/dist/parsers/tncamp-results.js.map +1 -0
- package/dist/parsers/tncamp-results.test.d.ts +1 -0
- package/dist/parsers/tncamp-results.test.js +86 -0
- package/dist/parsers/tncamp-results.test.js.map +1 -0
- package/dist/tools/easyvote.d.ts +2 -0
- package/dist/tools/easyvote.js +382 -0
- package/dist/tools/easyvote.js.map +1 -0
- package/dist/tools/sos.d.ts +2 -0
- package/dist/tools/sos.js +130 -0
- package/dist/tools/sos.js.map +1 -0
- package/dist/tools/sumner.d.ts +2 -0
- package/dist/tools/sumner.js +134 -0
- package/dist/tools/sumner.js.map +1 -0
- package/dist/tools/tncamp.d.ts +2 -0
- package/dist/tools/tncamp.js +401 -0
- package/dist/tools/tncamp.js.map +1 -0
- package/dist/types.d.ts +270 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +47 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { resolveCounty, EASYVOTE_COUNTIES, TN_COUNTIES } from './tn-counties.js';
|
|
3
|
+
describe('resolveCounty', () => {
|
|
4
|
+
it('resolves by exact name', () => {
|
|
5
|
+
const result = resolveCounty('Knox');
|
|
6
|
+
expect(result).not.toBeNull();
|
|
7
|
+
expect(result.name).toBe('Knox');
|
|
8
|
+
expect(result.slug).toBe('knoxcountytn');
|
|
9
|
+
});
|
|
10
|
+
it('resolves by exact name (case-insensitive)', () => {
|
|
11
|
+
const result = resolveCounty('davidson');
|
|
12
|
+
expect(result).not.toBeNull();
|
|
13
|
+
expect(result.name).toBe('Davidson');
|
|
14
|
+
});
|
|
15
|
+
it('resolves by slug', () => {
|
|
16
|
+
const result = resolveCounty('shelbycountytn');
|
|
17
|
+
expect(result).not.toBeNull();
|
|
18
|
+
expect(result.name).toBe('Shelby');
|
|
19
|
+
});
|
|
20
|
+
it('resolves by county seat', () => {
|
|
21
|
+
const result = resolveCounty('Nashville');
|
|
22
|
+
expect(result).not.toBeNull();
|
|
23
|
+
expect(result.name).toBe('Davidson');
|
|
24
|
+
});
|
|
25
|
+
it('resolves by county seat (case-insensitive)', () => {
|
|
26
|
+
const result = resolveCounty('knoxville');
|
|
27
|
+
expect(result).not.toBeNull();
|
|
28
|
+
expect(result.name).toBe('Knox');
|
|
29
|
+
});
|
|
30
|
+
it('resolves by partial name prefix', () => {
|
|
31
|
+
const result = resolveCounty('david');
|
|
32
|
+
expect(result).not.toBeNull();
|
|
33
|
+
expect(result.name).toBe('Davidson');
|
|
34
|
+
});
|
|
35
|
+
it('resolves by partial county seat', () => {
|
|
36
|
+
const result = resolveCounty('chatt');
|
|
37
|
+
expect(result).not.toBeNull();
|
|
38
|
+
expect(result.name).toBe('Hamilton');
|
|
39
|
+
});
|
|
40
|
+
it('returns null for unknown county', () => {
|
|
41
|
+
expect(resolveCounty('FakeCounty')).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
it('returns null for empty string', () => {
|
|
44
|
+
expect(resolveCounty('')).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('EASYVOTE_COUNTIES', () => {
|
|
48
|
+
it('has 14 entries', () => {
|
|
49
|
+
expect(EASYVOTE_COUNTIES).toHaveLength(14);
|
|
50
|
+
});
|
|
51
|
+
it('all have required fields', () => {
|
|
52
|
+
for (const county of EASYVOTE_COUNTIES) {
|
|
53
|
+
expect(county.slug).toBeTruthy();
|
|
54
|
+
expect(county.name).toBeTruthy();
|
|
55
|
+
expect(county.tenantId).toMatch(/^[A-F0-9-]+$/i);
|
|
56
|
+
expect(county.countySeat).toBeTruthy();
|
|
57
|
+
expect(typeof county.filerCount).toBe('number');
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
it('slug follows {name}countytn pattern', () => {
|
|
61
|
+
for (const county of EASYVOTE_COUNTIES) {
|
|
62
|
+
expect(county.slug).toBe(`${county.name.toLowerCase()}countytn`);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe('TN_COUNTIES', () => {
|
|
67
|
+
it('has 95 entries', () => {
|
|
68
|
+
expect(Object.keys(TN_COUNTIES)).toHaveLength(95);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
//# sourceMappingURL=tn-counties.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tn-counties.test.js","sourceRoot":"","sources":["../../src/data/tn-counties.test.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { registerEasyVoteTools } from './tools/easyvote.js';
|
|
5
|
+
// Phase 2: State campaign finance (tncamp)
|
|
6
|
+
import { registerTncampTools } from './tools/tncamp.js';
|
|
7
|
+
// Sumner County campaign finance (PDF-based)
|
|
8
|
+
import { registerSumnerTools } from './tools/sumner.js';
|
|
9
|
+
// TN Secretary of State candidate filings
|
|
10
|
+
import { registerSosTools } from './tools/sos.js';
|
|
11
|
+
const server = new McpServer({
|
|
12
|
+
name: 'tn-elections-mcp',
|
|
13
|
+
version: '0.5.0',
|
|
14
|
+
});
|
|
15
|
+
// County campaign finance (EasyVote — 14 counties)
|
|
16
|
+
registerEasyVoteTools(server);
|
|
17
|
+
// Phase 2: State campaign finance (tncamp)
|
|
18
|
+
registerTncampTools(server);
|
|
19
|
+
// Sumner County campaign finance (PDF-based)
|
|
20
|
+
registerSumnerTools(server);
|
|
21
|
+
// TN Secretary of State candidate filings
|
|
22
|
+
registerSosTools(server);
|
|
23
|
+
async function main() {
|
|
24
|
+
const transport = new StdioServerTransport();
|
|
25
|
+
await server.connect(transport);
|
|
26
|
+
}
|
|
27
|
+
main().catch((error) => {
|
|
28
|
+
console.error('Fatal error:', error);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
});
|
|
31
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAA;AACnE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAA;AAChF,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAA;AAE3D,2CAA2C;AAC3C,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAA;AAEvD,6CAA6C;AAC7C,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAA;AAEvD,0CAA0C;AAC1C,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAEjD,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC;IAC3B,IAAI,EAAE,kBAAkB;IACxB,OAAO,EAAE,OAAO;CACjB,CAAC,CAAA;AAEF,mDAAmD;AACnD,qBAAqB,CAAC,MAAM,CAAC,CAAA;AAE7B,2CAA2C;AAC3C,mBAAmB,CAAC,MAAM,CAAC,CAAA;AAE3B,6CAA6C;AAC7C,mBAAmB,CAAC,MAAM,CAAC,CAAA;AAE3B,0CAA0C;AAC1C,gBAAgB,CAAC,MAAM,CAAC,CAAA;AAExB,KAAK,UAAU,IAAI;IACjB,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAA;IAC5C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAA;AACjC,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE;IACrB,OAAO,CAAC,KAAK,CAAC,cAAc,EAAE,KAAK,CAAC,CAAA;IACpC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;AACjB,CAAC,CAAC,CAAA"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for TN Secretary of State candidate filing data.
|
|
3
|
+
* Pure functions — no network calls.
|
|
4
|
+
*
|
|
5
|
+
* Two data formats:
|
|
6
|
+
* 1. HTML page listing xlsx/pdf links (parsed with node-html-parser)
|
|
7
|
+
* 2. xlsx files with 6 columns of candidate data (parsed with xlsx)
|
|
8
|
+
*/
|
|
9
|
+
import type { SosFiledCandidate, SosOfficeCategory } from '../types.js';
|
|
10
|
+
/**
|
|
11
|
+
* Parse the SOS candidate list HTML page to extract office categories with xlsx/pdf URLs.
|
|
12
|
+
*
|
|
13
|
+
* Expected HTML structure:
|
|
14
|
+
* <li>Office Name: <a href="...pdf">PDF</a> | <a href="...xlsx">Excel</a></li>
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseCandidateListPage(html: string): SosOfficeCategory[];
|
|
17
|
+
/**
|
|
18
|
+
* Parse an xlsx buffer into an array of SosFiledCandidate.
|
|
19
|
+
*
|
|
20
|
+
* @param buffer - Raw xlsx file contents
|
|
21
|
+
* @param officeCategory - Category name (e.g., "Governor", "TN Senate")
|
|
22
|
+
* @param year - Election cycle year (used to infer year for date-only strings)
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseXlsx(buffer: ArrayBuffer, officeCategory: string, year: number): SosFiledCandidate[];
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for TN Secretary of State candidate filing data.
|
|
3
|
+
* Pure functions — no network calls.
|
|
4
|
+
*
|
|
5
|
+
* Two data formats:
|
|
6
|
+
* 1. HTML page listing xlsx/pdf links (parsed with node-html-parser)
|
|
7
|
+
* 2. xlsx files with 6 columns of candidate data (parsed with xlsx)
|
|
8
|
+
*/
|
|
9
|
+
import { parse } from 'node-html-parser';
|
|
10
|
+
import * as XLSX from 'xlsx';
|
|
11
|
+
/** Header variations across federal vs state xlsx files */
|
|
12
|
+
const HEADER_MAP = {
|
|
13
|
+
'Office': 'office',
|
|
14
|
+
'Candidate': 'candidateName',
|
|
15
|
+
'Party': 'party',
|
|
16
|
+
'Party Name': 'party',
|
|
17
|
+
'City': 'city',
|
|
18
|
+
'Filing Date': 'filingDate',
|
|
19
|
+
'Filed': 'filingDate',
|
|
20
|
+
'Status': 'status',
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Parse the SOS candidate list HTML page to extract office categories with xlsx/pdf URLs.
|
|
24
|
+
*
|
|
25
|
+
* Expected HTML structure:
|
|
26
|
+
* <li>Office Name: <a href="...pdf">PDF</a> | <a href="...xlsx">Excel</a></li>
|
|
27
|
+
*/
|
|
28
|
+
export function parseCandidateListPage(html) {
|
|
29
|
+
const root = parse(html);
|
|
30
|
+
const offices = [];
|
|
31
|
+
const listItems = root.querySelectorAll('li');
|
|
32
|
+
for (const li of listItems) {
|
|
33
|
+
const links = li.querySelectorAll('a');
|
|
34
|
+
let xlsxUrl = '';
|
|
35
|
+
let pdfUrl = '';
|
|
36
|
+
for (const link of links) {
|
|
37
|
+
const href = link.getAttribute('href') || '';
|
|
38
|
+
if (href.endsWith('.xlsx'))
|
|
39
|
+
xlsxUrl = href;
|
|
40
|
+
else if (href.endsWith('.pdf'))
|
|
41
|
+
pdfUrl = href;
|
|
42
|
+
}
|
|
43
|
+
if (!xlsxUrl)
|
|
44
|
+
continue;
|
|
45
|
+
// Office name is the text before the colon
|
|
46
|
+
const rawText = li.text || '';
|
|
47
|
+
const colonIdx = rawText.indexOf(':');
|
|
48
|
+
const name = colonIdx > 0 ? rawText.substring(0, colonIdx).trim() : rawText.trim();
|
|
49
|
+
if (name) {
|
|
50
|
+
offices.push({ name, xlsxUrl, pdfUrl, candidateCount: 0 });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return offices;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Normalize a date string to YYYY-MM-DD format.
|
|
57
|
+
*
|
|
58
|
+
* Handles three cases:
|
|
59
|
+
* - Full date: "3/6/2026" → "2026-03-06"
|
|
60
|
+
* - Year-less date: "4/4" → "{year}-04-04" (state-level files omit the year)
|
|
61
|
+
* - Already formatted: "2026-03-06" → pass through
|
|
62
|
+
*/
|
|
63
|
+
function normalizeDate(raw, year) {
|
|
64
|
+
if (!raw)
|
|
65
|
+
return '';
|
|
66
|
+
const trimmed = raw.trim();
|
|
67
|
+
// Already ISO format
|
|
68
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed))
|
|
69
|
+
return trimmed;
|
|
70
|
+
// M/D/YYYY or M/D/YY
|
|
71
|
+
const fullMatch = trimmed.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/);
|
|
72
|
+
if (fullMatch) {
|
|
73
|
+
const month = fullMatch[1].padStart(2, '0');
|
|
74
|
+
const day = fullMatch[2].padStart(2, '0');
|
|
75
|
+
let yr = fullMatch[3];
|
|
76
|
+
if (yr.length === 2)
|
|
77
|
+
yr = `20${yr}`;
|
|
78
|
+
return `${yr}-${month}-${day}`;
|
|
79
|
+
}
|
|
80
|
+
// M/D (no year — state-level files)
|
|
81
|
+
const shortMatch = trimmed.match(/^(\d{1,2})\/(\d{1,2})$/);
|
|
82
|
+
if (shortMatch) {
|
|
83
|
+
const month = shortMatch[1].padStart(2, '0');
|
|
84
|
+
const day = shortMatch[2].padStart(2, '0');
|
|
85
|
+
return `${year}-${month}-${day}`;
|
|
86
|
+
}
|
|
87
|
+
// Fallback: return as-is
|
|
88
|
+
return trimmed;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Parse an xlsx buffer into an array of SosFiledCandidate.
|
|
92
|
+
*
|
|
93
|
+
* @param buffer - Raw xlsx file contents
|
|
94
|
+
* @param officeCategory - Category name (e.g., "Governor", "TN Senate")
|
|
95
|
+
* @param year - Election cycle year (used to infer year for date-only strings)
|
|
96
|
+
*/
|
|
97
|
+
export function parseXlsx(buffer, officeCategory, year) {
|
|
98
|
+
const wb = XLSX.read(buffer, { type: 'array' });
|
|
99
|
+
const sheetName = wb.SheetNames[0];
|
|
100
|
+
if (!sheetName)
|
|
101
|
+
return [];
|
|
102
|
+
const sheet = wb.Sheets[sheetName];
|
|
103
|
+
if (!sheet)
|
|
104
|
+
return [];
|
|
105
|
+
// Read as array of arrays to handle header normalization ourselves
|
|
106
|
+
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' });
|
|
107
|
+
if (rows.length < 2)
|
|
108
|
+
return []; // Need at least header + 1 data row
|
|
109
|
+
// Map header row to our field names
|
|
110
|
+
const headerRow = rows[0];
|
|
111
|
+
const columnMap = headerRow.map(h => {
|
|
112
|
+
const trimmed = String(h).trim();
|
|
113
|
+
return HEADER_MAP[trimmed] || null;
|
|
114
|
+
});
|
|
115
|
+
const candidates = [];
|
|
116
|
+
for (let i = 1; i < rows.length; i++) {
|
|
117
|
+
const row = rows[i];
|
|
118
|
+
// Skip empty rows
|
|
119
|
+
const hasData = row.some(cell => String(cell).trim() !== '');
|
|
120
|
+
if (!hasData)
|
|
121
|
+
continue;
|
|
122
|
+
const candidate = { officeCategory };
|
|
123
|
+
for (let col = 0; col < columnMap.length; col++) {
|
|
124
|
+
const field = columnMap[col];
|
|
125
|
+
if (!field)
|
|
126
|
+
continue;
|
|
127
|
+
let value = String(row[col] ?? '').trim();
|
|
128
|
+
if (field === 'filingDate') {
|
|
129
|
+
value = normalizeDate(value, year);
|
|
130
|
+
}
|
|
131
|
+
;
|
|
132
|
+
candidate[field] = value;
|
|
133
|
+
}
|
|
134
|
+
// Only add if we have at least a candidate name
|
|
135
|
+
if (candidate.candidateName) {
|
|
136
|
+
candidates.push({
|
|
137
|
+
office: candidate.office || '',
|
|
138
|
+
candidateName: candidate.candidateName,
|
|
139
|
+
party: candidate.party || '',
|
|
140
|
+
city: candidate.city || '',
|
|
141
|
+
filingDate: candidate.filingDate || '',
|
|
142
|
+
status: candidate.status || '',
|
|
143
|
+
officeCategory,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return candidates;
|
|
148
|
+
}
|
|
149
|
+
//# sourceMappingURL=sos-xlsx.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sos-xlsx.js","sourceRoot":"","sources":["../../src/parsers/sos-xlsx.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as XLSX from 'xlsx';
|
|
3
|
+
import { parseCandidateListPage, parseXlsx } from './sos-xlsx.js';
|
|
4
|
+
// ============================================================
|
|
5
|
+
// HTML page parsing tests
|
|
6
|
+
// ============================================================
|
|
7
|
+
const CANDIDATE_LIST_HTML = `
|
|
8
|
+
<html>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="paragraph paragraph--type--additional-information">
|
|
11
|
+
<div class="field field--name-field-info-text">
|
|
12
|
+
<ul>
|
|
13
|
+
<li>Governor: <a href="https://sos-prod.tnsosgovfiles.com/s3fs-public/document/Governor_Filed_2026-03-24.pdf">PDF</a> | <a href="https://sos-prod.tnsosgovfiles.com/s3fs-public/document/Governor_Filed_2026-03-24.xlsx">Excel</a></li>
|
|
14
|
+
<li>U.S. Senate: <a href="https://sos-prod.tnsosgovfiles.com/s3fs-public/document/USSenate_Filed_2026-03-24.pdf">PDF</a> | <a href="https://sos-prod.tnsosgovfiles.com/s3fs-public/document/USSenate_Filed_2026-03-24.xlsx">Excel</a></li>
|
|
15
|
+
<li>TN Senate: <a href="https://sos-prod.tnsosgovfiles.com/s3fs-public/document/TNSenate_Filed_2026-03-24.pdf">PDF</a> | <a href="https://sos-prod.tnsosgovfiles.com/s3fs-public/document/TNSenate_Filed_2026-03-24.xlsx">Excel</a></li>
|
|
16
|
+
</ul>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
</body>
|
|
20
|
+
</html>
|
|
21
|
+
`;
|
|
22
|
+
describe('parseCandidateListPage', () => {
|
|
23
|
+
it('extracts office names and xlsx/pdf URLs', () => {
|
|
24
|
+
const offices = parseCandidateListPage(CANDIDATE_LIST_HTML);
|
|
25
|
+
expect(offices).toHaveLength(3);
|
|
26
|
+
expect(offices[0].name).toBe('Governor');
|
|
27
|
+
expect(offices[0].xlsxUrl).toContain('Governor_Filed_2026-03-24.xlsx');
|
|
28
|
+
expect(offices[0].pdfUrl).toContain('Governor_Filed_2026-03-24.pdf');
|
|
29
|
+
expect(offices[1].name).toBe('U.S. Senate');
|
|
30
|
+
expect(offices[2].name).toBe('TN Senate');
|
|
31
|
+
});
|
|
32
|
+
it('skips list items without xlsx links', () => {
|
|
33
|
+
const html = '<ul><li>No files here</li><li>Also nothing: <a href="page.html">Link</a></li></ul>';
|
|
34
|
+
expect(parseCandidateListPage(html)).toHaveLength(0);
|
|
35
|
+
});
|
|
36
|
+
it('returns empty for no list items', () => {
|
|
37
|
+
expect(parseCandidateListPage('<html></html>')).toHaveLength(0);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
// ============================================================
|
|
41
|
+
// XLSX parsing tests
|
|
42
|
+
// ============================================================
|
|
43
|
+
/** Helper: create an xlsx ArrayBuffer from headers + rows */
|
|
44
|
+
function createXlsx(headers, rows) {
|
|
45
|
+
const aoa = [headers, ...rows];
|
|
46
|
+
const ws = XLSX.utils.aoa_to_sheet(aoa);
|
|
47
|
+
const wb = XLSX.utils.book_new();
|
|
48
|
+
XLSX.utils.book_append_sheet(wb, ws, 'Sheet1');
|
|
49
|
+
const out = XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
describe('parseXlsx', () => {
|
|
53
|
+
it('parses federal-style headers (Party Name, Filing Date)', () => {
|
|
54
|
+
const buffer = createXlsx(['Office', 'Candidate', 'Party Name', 'City', 'Filing Date', 'Status'], [
|
|
55
|
+
['Governor', 'DOE, JANE', 'Republican', 'Nashville', '3/6/2026', 'Signatures Approved'],
|
|
56
|
+
['Governor', 'SMITH, JOHN', 'Democratic', 'Memphis', '3/10/2026', 'Signatures Approved'],
|
|
57
|
+
]);
|
|
58
|
+
const candidates = parseXlsx(buffer, 'Governor', 2026);
|
|
59
|
+
expect(candidates).toHaveLength(2);
|
|
60
|
+
expect(candidates[0].candidateName).toBe('DOE, JANE');
|
|
61
|
+
expect(candidates[0].party).toBe('Republican');
|
|
62
|
+
expect(candidates[0].city).toBe('Nashville');
|
|
63
|
+
expect(candidates[0].filingDate).toBe('2026-03-06');
|
|
64
|
+
expect(candidates[0].status).toBe('Signatures Approved');
|
|
65
|
+
expect(candidates[0].officeCategory).toBe('Governor');
|
|
66
|
+
});
|
|
67
|
+
it('parses state-style headers (Party, Filed) with year-less dates', () => {
|
|
68
|
+
const buffer = createXlsx(['Office', 'Candidate', 'Party', 'City', 'Filed', 'Status'], [
|
|
69
|
+
['State Senate District 5', 'POWERS, BILL', 'Republican', 'Knoxville', '1/1', 'Signatures Approved'],
|
|
70
|
+
['State Senate District 5', 'JONES, MARY', 'Democratic', 'Chattanooga', '4/4', 'Signatures Approved'],
|
|
71
|
+
]);
|
|
72
|
+
const candidates = parseXlsx(buffer, 'TN Senate', 2026);
|
|
73
|
+
expect(candidates).toHaveLength(2);
|
|
74
|
+
expect(candidates[0].candidateName).toBe('POWERS, BILL');
|
|
75
|
+
expect(candidates[0].filingDate).toBe('2026-01-01');
|
|
76
|
+
expect(candidates[0].officeCategory).toBe('TN Senate');
|
|
77
|
+
expect(candidates[1].filingDate).toBe('2026-04-04');
|
|
78
|
+
});
|
|
79
|
+
it('normalizes full dates to YYYY-MM-DD', () => {
|
|
80
|
+
const buffer = createXlsx(['Office', 'Candidate', 'Party Name', 'City', 'Filing Date', 'Status'], [['US House District 1', 'TEST, CANDIDATE', 'Independent', 'Bristol', '3/6/2026', 'Signatures Approved']]);
|
|
81
|
+
const candidates = parseXlsx(buffer, 'US House', 2026);
|
|
82
|
+
expect(candidates[0].filingDate).toBe('2026-03-06');
|
|
83
|
+
});
|
|
84
|
+
it('handles two-digit year dates', () => {
|
|
85
|
+
const buffer = createXlsx(['Office', 'Candidate', 'Party', 'City', 'Filed', 'Status'], [['Governor', 'TEST, NAME', 'Republican', 'Nashville', '3/6/26', 'Signatures Approved']]);
|
|
86
|
+
const candidates = parseXlsx(buffer, 'Governor', 2026);
|
|
87
|
+
expect(candidates[0].filingDate).toBe('2026-03-06');
|
|
88
|
+
});
|
|
89
|
+
it('skips empty rows', () => {
|
|
90
|
+
const buffer = createXlsx(['Office', 'Candidate', 'Party', 'City', 'Filed', 'Status'], [
|
|
91
|
+
['Governor', 'DOE, JANE', 'Republican', 'Nashville', '3/6', 'Signatures Approved'],
|
|
92
|
+
['', '', '', '', '', ''],
|
|
93
|
+
['Governor', 'SMITH, JOHN', 'Democratic', 'Memphis', '3/10', 'Signatures Approved'],
|
|
94
|
+
]);
|
|
95
|
+
const candidates = parseXlsx(buffer, 'Governor', 2026);
|
|
96
|
+
expect(candidates).toHaveLength(2);
|
|
97
|
+
});
|
|
98
|
+
it('returns empty for header-only sheet', () => {
|
|
99
|
+
const buffer = createXlsx(['Office', 'Candidate', 'Party', 'City', 'Filed', 'Status'], []);
|
|
100
|
+
const candidates = parseXlsx(buffer, 'Governor', 2026);
|
|
101
|
+
expect(candidates).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
it('returns empty for empty workbook', () => {
|
|
104
|
+
const wb = XLSX.utils.book_new();
|
|
105
|
+
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([]), 'Sheet1');
|
|
106
|
+
const buffer = XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
|
|
107
|
+
const candidates = parseXlsx(buffer, 'Governor', 2026);
|
|
108
|
+
expect(candidates).toHaveLength(0);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
//# sourceMappingURL=sos-xlsx.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sos-xlsx.test.js","sourceRoot":"","sources":["../../src/parsers/sos-xlsx.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sumner County financial disclosure page parser.
|
|
3
|
+
*
|
|
4
|
+
* Parses votesumnertn.org disclosure pages where candidate/office info
|
|
5
|
+
* is encoded in inline style colors (not semantic HTML classes).
|
|
6
|
+
*/
|
|
7
|
+
import type { SumnerCyclePage, SumnerElectionCycle } from '../types.js';
|
|
8
|
+
export declare function parseSumnerCyclePage(html: string, cycle: SumnerElectionCycle): SumnerCyclePage;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sumner County financial disclosure page parser.
|
|
3
|
+
*
|
|
4
|
+
* Parses votesumnertn.org disclosure pages where candidate/office info
|
|
5
|
+
* is encoded in inline style colors (not semantic HTML classes).
|
|
6
|
+
*/
|
|
7
|
+
import { parse } from 'node-html-parser';
|
|
8
|
+
const BASE_URL = 'https://www.votesumnertn.org';
|
|
9
|
+
/** Match office header background color (rgb or hex variants) */
|
|
10
|
+
function isOfficeHeader(style) {
|
|
11
|
+
return (style.includes('background-color') || style.includes('background:')) &&
|
|
12
|
+
(style.includes('71, 85, 119') || style.includes('71,85,119') ||
|
|
13
|
+
style.includes('#475577') || style.includes('#47557'));
|
|
14
|
+
}
|
|
15
|
+
/** Match candidate/district text color (without background) */
|
|
16
|
+
function isCandidateColor(style) {
|
|
17
|
+
if (style.includes('background'))
|
|
18
|
+
return false;
|
|
19
|
+
return style.includes('71, 85, 119') || style.includes('71,85,119') ||
|
|
20
|
+
style.includes('#475577') || style.includes('#47557');
|
|
21
|
+
}
|
|
22
|
+
/** Match municipal header color */
|
|
23
|
+
function isMunicipalColor(style) {
|
|
24
|
+
return (style.includes('41, 105, 176') || style.includes('41,105,176') ||
|
|
25
|
+
style.includes('#2969b0') || style.includes('#2969B0'));
|
|
26
|
+
}
|
|
27
|
+
/** Check if text looks like a district identifier rather than a name */
|
|
28
|
+
function isDistrictText(text) {
|
|
29
|
+
return /^(district|zone|seat|ward)\s+\d/i.test(text.trim());
|
|
30
|
+
}
|
|
31
|
+
/** Check if text is a section header or office title rather than a candidate name */
|
|
32
|
+
function isSectionOrOfficeTitle(text) {
|
|
33
|
+
const lower = text.toLowerCase().trim();
|
|
34
|
+
return lower === 'municipal offices' ||
|
|
35
|
+
lower === 'county offices' ||
|
|
36
|
+
lower === 'school districts' ||
|
|
37
|
+
lower === 'state offices' ||
|
|
38
|
+
lower === 'mayor' ||
|
|
39
|
+
lower === 'alderman' ||
|
|
40
|
+
lower === 'aldermen' ||
|
|
41
|
+
lower === 'vice mayor' ||
|
|
42
|
+
lower === 'city council' ||
|
|
43
|
+
lower === 'city judge' ||
|
|
44
|
+
lower === 'city recorder' ||
|
|
45
|
+
lower === 'non-disclosure' ||
|
|
46
|
+
lower === 'statements of non-disclosure' ||
|
|
47
|
+
/^(register|clerk|trustee|sheriff|assessor|superintendent)\s*$/i.test(lower);
|
|
48
|
+
}
|
|
49
|
+
export function parseSumnerCyclePage(html, cycle) {
|
|
50
|
+
const root = parse(html);
|
|
51
|
+
const candidates = [];
|
|
52
|
+
const nonDisclosures = [];
|
|
53
|
+
// Find the content area
|
|
54
|
+
const contentArea = root.querySelector('.fr-view') ||
|
|
55
|
+
root.querySelector('.ContentPadding') ||
|
|
56
|
+
root.querySelector('.entry-content') ||
|
|
57
|
+
root;
|
|
58
|
+
// The page content is one big blob of inline-styled spans, strongs, and links.
|
|
59
|
+
// We need to walk through the raw HTML and track state as we encounter markers.
|
|
60
|
+
const contentHtml = contentArea.innerHTML;
|
|
61
|
+
// Strategy: split on <br> and block-level tags to get "lines", then classify each
|
|
62
|
+
// This is more robust than walking the DOM because the markup is flat (not nested hierarchically).
|
|
63
|
+
// Extract all meaningful elements in order using regex on the raw HTML
|
|
64
|
+
const elements = [];
|
|
65
|
+
// Office headers: span with background-color
|
|
66
|
+
const officePattern = /<span[^>]*style="[^"]*background-color[^"]*"[^>]*>(.*?)<\/span>/gi;
|
|
67
|
+
// Candidate names: strong > span OR span > strong with color (no background)
|
|
68
|
+
// Both patterns exist: <strong><span style="color:...">Name</span></strong>
|
|
69
|
+
// and: <span style="color:..."><strong>Name</strong></span>
|
|
70
|
+
const candidatePattern = /(?:<strong[^>]*>\s*<span[^>]*style="[^"]*color[^"]*"[^>]*>(.*?)<\/span>\s*<\/strong>|<span[^>]*style="[^"]*color:\s*rgb\(71,?\s*85,?\s*119\)[^"]*"[^>]*>\s*<strong[^>]*>(.*?)<\/strong>\s*<\/span>)/gi;
|
|
71
|
+
// Municipal headers: span with blue color inside strong
|
|
72
|
+
const municipalPattern = /<span[^>]*style="[^"]*color:\s*rgb\(41,?\s*105,?\s*176\)[^"]*"[^>]*>\s*<strong[^>]*>(.*?)<\/strong>\s*<\/span>/gi;
|
|
73
|
+
// Also catch strong > span variant for municipal
|
|
74
|
+
const municipal2Pattern = /<strong[^>]*>\s*<span[^>]*style="[^"]*color:\s*rgb\(41,?\s*105,?\s*176\)[^"]*"[^>]*>(.*?)<\/span>\s*<\/strong>/gi;
|
|
75
|
+
// PDF links
|
|
76
|
+
const linkPattern = /<a[^>]*class="fr-file"[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi;
|
|
77
|
+
// Build a position-ordered list of all elements
|
|
78
|
+
const allMatches = [];
|
|
79
|
+
let m;
|
|
80
|
+
while ((m = officePattern.exec(contentHtml)) !== null) {
|
|
81
|
+
const text = m[1].replace(/<[^>]+>/g, '').trim();
|
|
82
|
+
if (text)
|
|
83
|
+
allMatches.push({ pos: m.index, type: 'office', text });
|
|
84
|
+
}
|
|
85
|
+
while ((m = municipalPattern.exec(contentHtml)) !== null) {
|
|
86
|
+
const text = m[1].replace(/<[^>]+>/g, '').trim();
|
|
87
|
+
if (text)
|
|
88
|
+
allMatches.push({ pos: m.index, type: 'municipal', text });
|
|
89
|
+
}
|
|
90
|
+
while ((m = municipal2Pattern.exec(contentHtml)) !== null) {
|
|
91
|
+
const text = m[1].replace(/<[^>]+>/g, '').trim();
|
|
92
|
+
if (text && !allMatches.some(x => x.type === 'municipal' && x.text === text)) {
|
|
93
|
+
allMatches.push({ pos: m.index, type: 'municipal', text });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
while ((m = candidatePattern.exec(contentHtml)) !== null) {
|
|
97
|
+
// Two capture groups: m[1] from strong>span pattern, m[2] from span>strong pattern
|
|
98
|
+
const text = (m[1] || m[2] || '').replace(/<[^>]+>/g, '').trim();
|
|
99
|
+
if (text) {
|
|
100
|
+
// Skip if this was already matched as an office header (overlapping regex)
|
|
101
|
+
const isOffice = allMatches.some(x => x.type === 'office' && Math.abs(x.pos - m.index) < 20);
|
|
102
|
+
if (!isOffice) {
|
|
103
|
+
allMatches.push({ pos: m.index, type: 'candidate', text });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
while ((m = linkPattern.exec(contentHtml)) !== null) {
|
|
108
|
+
const href = m[1];
|
|
109
|
+
const text = m[2].replace(/<[^>]+>/g, '').trim();
|
|
110
|
+
if (href && text) {
|
|
111
|
+
const pdfUrl = href.startsWith('http') ? href : `${BASE_URL}${href}`;
|
|
112
|
+
allMatches.push({ pos: m.index, type: 'link', text, href: pdfUrl });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Sort by position in the HTML
|
|
116
|
+
allMatches.sort((a, b) => a.pos - b.pos);
|
|
117
|
+
// Walk through matches and build the candidate list
|
|
118
|
+
let currentMunicipality = undefined;
|
|
119
|
+
let currentOffice = undefined;
|
|
120
|
+
let currentDistrict = undefined;
|
|
121
|
+
let isMunicipal = false;
|
|
122
|
+
let inNonDisclosure = false;
|
|
123
|
+
for (const match of allMatches) {
|
|
124
|
+
if (match.type === 'municipal') {
|
|
125
|
+
currentMunicipality = match.text;
|
|
126
|
+
isMunicipal = true;
|
|
127
|
+
inNonDisclosure = false;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (match.type === 'office') {
|
|
131
|
+
// Check for non-disclosure section
|
|
132
|
+
if (match.text.toLowerCase().includes('non-disclosure') ||
|
|
133
|
+
match.text.toLowerCase().includes('non disclosure')) {
|
|
134
|
+
inNonDisclosure = true;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
inNonDisclosure = false;
|
|
138
|
+
currentOffice = match.text;
|
|
139
|
+
currentDistrict = undefined;
|
|
140
|
+
// Extract district from office text if present
|
|
141
|
+
const distMatch = match.text.match(/(district|zone|seat|ward)\s+\d+/i);
|
|
142
|
+
if (distMatch) {
|
|
143
|
+
currentDistrict = distMatch[0];
|
|
144
|
+
currentOffice = match.text.replace(distMatch[0], '').replace(/[-–—,]\s*$/, '').trim();
|
|
145
|
+
if (!currentOffice)
|
|
146
|
+
currentOffice = match.text;
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (match.type === 'candidate') {
|
|
151
|
+
// Check if this is a district identifier, not a candidate name
|
|
152
|
+
if (isDistrictText(match.text)) {
|
|
153
|
+
currentDistrict = match.text;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
// Check if this is a section header or office title (Mayor, Alderman, etc.)
|
|
157
|
+
if (isSectionOrOfficeTitle(match.text)) {
|
|
158
|
+
// Treat as a sub-office within a municipal section
|
|
159
|
+
if (isMunicipal) {
|
|
160
|
+
currentOffice = match.text.toUpperCase();
|
|
161
|
+
}
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (inNonDisclosure) {
|
|
165
|
+
nonDisclosures.push({ name: match.text, cycle: cycle.year });
|
|
166
|
+
}
|
|
167
|
+
else if (currentOffice) {
|
|
168
|
+
candidates.push({
|
|
169
|
+
name: match.text,
|
|
170
|
+
office: {
|
|
171
|
+
name: currentOffice,
|
|
172
|
+
district: currentDistrict,
|
|
173
|
+
isMunicipal,
|
|
174
|
+
municipality: isMunicipal ? currentMunicipality : undefined,
|
|
175
|
+
},
|
|
176
|
+
reports: [],
|
|
177
|
+
cycle: cycle.year,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (match.type === 'link') {
|
|
183
|
+
// Attach to most recent candidate
|
|
184
|
+
if (candidates.length > 0) {
|
|
185
|
+
candidates[candidates.length - 1].reports.push({
|
|
186
|
+
reportName: match.text,
|
|
187
|
+
pdfUrl: match.href,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
cycle,
|
|
195
|
+
candidates,
|
|
196
|
+
nonDisclosures,
|
|
197
|
+
scrapedAt: new Date().toISOString(),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
//# sourceMappingURL=sumner-html.js.map
|