/*
=========================================================
SBWPC CAMPAIGN FINANCE LIVE FILING SERVICE
City of Santa Barbara + City of Goleta
Candidate-first JSON / JSONP feed
=========================================================
*/
const CF_CONFIG = {
agencies: {
santa_barbara: {
name: "City of Santa Barbara",
aid: "csb",
portalPath: "/public/csb/campaign/elections"
},
goleta: {
name: "City of Goleta",
aid: "GOL",
portalPath: "/public/GOL/campaign/elections"
}
},
candidates: [
/* =====================================================
CITY OF SANTA BARBARA — MAYOR
===================================================== */
{
id: "sb-mayor-eric-friedman",
name: "Eric Friedman",
jurisdiction: "City of Santa Barbara",
race: "Mayor",
agency: "santa_barbara",
aliases: [
"Eric Friedman",
"Friedman for Mayor",
"Eric Friedman for Mayor"
]
},
{
id: "sb-mayor-matt-kilrain",
name: "Matt Kilrain",
jurisdiction: "City of Santa Barbara",
race: "Mayor",
agency: "santa_barbara",
aliases: [
"Matt Kilrain",
"Kilrain for Mayor"
]
},
{
id: "sb-mayor-wendy-sims-moten",
name: "Wendy Sims-Moten",
jurisdiction: "City of Santa Barbara",
race: "Mayor",
agency: "santa_barbara",
aliases: [
"Wendy Sims-Moten",
"Wendy Sims Moten",
"Sims-Moten for Mayor",
"Sims Moten for Mayor"
]
},
{
id: "sb-mayor-kristen-sneddon",
name: "Kristen Sneddon",
jurisdiction: "City of Santa Barbara",
race: "Mayor",
agency: "santa_barbara",
aliases: [
"Kristen Sneddon",
"Sneddon for Mayor",
"Kristen Sneddon for Mayor"
]
},
/* =====================================================
CITY OF SANTA BARBARA — DISTRICT 4
===================================================== */
{
id: "sb-d4-jason-dominguez",
name: "Jason Dominguez",
jurisdiction: "City of Santa Barbara",
race: "City Council District 4",
agency: "santa_barbara",
aliases: [
"Jason Dominguez",
"Dominguez for",
"Jason Dominguez for"
]
},
{
id: "sb-d4-genevieve-taft-vazquez",
name: "Genevieve Taft-Vazquez",
jurisdiction: "City of Santa Barbara",
race: "City Council District 4",
agency: "santa_barbara",
aliases: [
"Genevieve Taft-Vazquez",
"Genevieve Taft Vazquez",
"Taft-Vazquez for",
"Taft Vazquez for"
]
},
{
id: "sb-d4-devon-wardlow",
name: "Devon Wardlow",
jurisdiction: "City of Santa Barbara",
race: "City Council District 4",
agency: "santa_barbara",
aliases: [
"Devon Wardlow",
"Wardlow for",
"Devon Wardlow for"
]
},
{
id: "sb-d4-monte-wilson",
name: "Monte Wilson",
jurisdiction: "City of Santa Barbara",
race: "City Council District 4",
agency: "santa_barbara",
aliases: [
"Monte Wilson",
"Wilson for City Council",
"Monte Wilson for"
]
},
/* =====================================================
CITY OF SANTA BARBARA — DISTRICT 5
===================================================== */
{
id: "sb-d5-ian-baucke",
name: "Ian J. Baucke",
jurisdiction: "City of Santa Barbara",
race: "City Council District 5",
agency: "santa_barbara",
aliases: [
"Ian Baucke",
"Ian J Baucke",
"Baucke for",
"BauckeForSB"
]
},
{
id: "sb-d5-cevin-cathell",
name: "Cevin Cathell",
jurisdiction: "City of Santa Barbara",
race: "City Council District 5",
agency: "santa_barbara",
aliases: [
"Cevin Cathell",
"Cevin for Council",
"Cathell for"
]
},
{
id: "sb-d5-james-zurlinden",
name: "James G. Zurlinden",
jurisdiction: "City of Santa Barbara",
race: "City Council District 5",
agency: "santa_barbara",
aliases: [
"James Zurlinden",
"James G Zurlinden",
"James for SB",
"Zurlinden for"
]
},
/* =====================================================
CITY OF SANTA BARBARA — DISTRICT 6
===================================================== */
{
id: "sb-d6-jason-carlton",
name: "Jason Carlton",
jurisdiction: "City of Santa Barbara",
race: "City Council District 6",
agency: "santa_barbara",
aliases: [
"Jason Carlton",
"Carlton for"
]
},
{
id: "sb-d6-gabe-escobedo",
name: "Gabe Escobedo",
jurisdiction: "City of Santa Barbara",
race: "City Council District 6",
agency: "santa_barbara",
aliases: [
"Gabe Escobedo",
"Gabe for SB",
"Escobedo for"
]
},
{
id: "sb-d6-anabella-lehne",
name: "Anabella Lehne",
jurisdiction: "City of Santa Barbara",
race: "City Council District 6",
agency: "santa_barbara",
aliases: [
"Anabella Lehne",
"Lehne for",
"Anabella for"
]
},
{
id: "sb-d6-alexander-stoeber",
name: "Alexander Stoeber",
jurisdiction: "City of Santa Barbara",
race: "City Council District 6",
agency: "santa_barbara",
aliases: [
"Alexander Stoeber",
"Stoeber for City Council",
"Alexander Stoeber for City Council"
]
},
/* =====================================================
CITY OF GOLETA — DISTRICT 1
===================================================== */
{
id: "goleta-d1-luz-reyes-martin",
name: "Luz Reyes-Martín",
jurisdiction: "City of Goleta",
race: "City Council District 1",
agency: "goleta",
aliases: [
"Luz Reyes-Martin",
"Luz Reyes Martín",
"Reyes-Martin for",
"Reyes Martin for"
]
},
{
id: "goleta-d1-rollin-gartzke",
name: "Rollin Michael Gartzke",
jurisdiction: "City of Goleta",
race: "City Council District 1",
agency: "goleta",
aliases: [
"Rollin Michael Gartzke",
"Rollin Gartzke",
"Gartzke for"
]
},
/* =====================================================
CITY OF GOLETA — DISTRICT 2
===================================================== */
{
id: "goleta-d2-james-kyriaco",
name: "James Kyriaco",
jurisdiction: "City of Goleta",
race: "City Council District 2",
agency: "goleta",
aliases: [
"James Kyriaco",
"Kyriaco for"
]
},
{
id: "goleta-d2-phebe-mansur",
name: "Phebe Mansur",
jurisdiction: "City of Goleta",
race: "City Council District 2",
agency: "goleta",
aliases: [
"Phebe Mansur",
"Mansur for"
]
}
]
};
/*
=========================================================
HOST
Avoid hard-coding multiple source URLs throughout script
=========================================================
*/
function netfileHost_() {
return "https:" + "//" + "netfile.com";
}
/*
=========================================================
ONE-TIME SETUP
Run this manually ONCE
=========================================================
*/
function setupCampaignFinanceFeed() {
refreshCampaignFilings();
const triggers =
ScriptApp.getProjectTriggers();
triggers.forEach(function(trigger) {
if (
trigger.getHandlerFunction() ===
"refreshCampaignFilings"
) {
ScriptApp.deleteTrigger(trigger);
}
});
ScriptApp
.newTrigger("refreshCampaignFilings")
.timeBased()
.everyHours(6)
.create();
}
/*
=========================================================
PUBLIC WEB APP ENDPOINT
=========================================================
*/
function doGet(e) {
try {
refreshIfNeeded_();
} catch (error) {
console.log(error);
}
const payload =
buildPayload_();
const json =
JSON.stringify(payload);
const callback =
e &&
e.parameter &&
e.parameter.callback
? String(e.parameter.callback)
: "";
/*
JSONP avoids cross-origin problems
when Durable loads the feed
*/
if (
callback &&
/^[A-Za-z_$][0-9A-Za-z_$\.]*$/.test(callback)
) {
return ContentService
.createTextOutput(
callback + "(" + json + ");"
)
.setMimeType(
ContentService.MimeType.JAVASCRIPT
);
}
return ContentService
.createTextOutput(json)
.setMimeType(
ContentService.MimeType.JSON
);
}
/*
=========================================================
REFRESH
=========================================================
*/
function refreshIfNeeded_() {
const properties =
PropertiesService.getScriptProperties();
const last =
Number(
properties.getProperty("CF_LAST_REFRESH") || 0
);
const sixHours =
6 * 60 * 60 * 1000;
if (
!last ||
Date.now() - last > sixHours
) {
refreshCampaignFilings();
}
}
function refreshCampaignFilings() {
Object.keys(
CF_CONFIG.agencies
).forEach(function(agencyKey) {
const agency =
CF_CONFIG.agencies[agencyKey];
const feedUrl =
netfileHost_() +
"/connect2/api/public/list/filing/rss/" +
encodeURIComponent(agency.aid) +
"/campaign.xml";
let response;
try {
response =
UrlFetchApp.fetch(
feedUrl,
{
muteHttpExceptions:true,
followRedirects:true
}
);
} catch (error) {
console.log(
"Feed error for " +
agency.name +
": " +
error
);
return;
}
if (
response.getResponseCode() < 200 ||
response.getResponseCode() >= 300
) {
console.log(
"Feed returned " +
response.getResponseCode() +
" for " +
agency.name
);
return;
}
let items;
try {
items =
parseRss_(
response.getContentText()
);
} catch (error) {
console.log(
"RSS parse error for " +
agency.name +
": " +
error
);
return;
}
items.forEach(function(item) {
const searchText =
normalize_(
[
item.title,
item.description
].join(" ")
);
const candidates =
CF_CONFIG.candidates.filter(
function(candidate) {
return (
candidate.agency === agencyKey &&
candidateMatches_(
candidate,
searchText
)
);
}
);
candidates.forEach(
function(candidate) {
const filing =
makeFilingRecord_(
item,
candidate,
agency
);
saveFiling_(
candidate.id,
filing
);
}
);
});
});
PropertiesService
.getScriptProperties()
.setProperty(
"CF_LAST_REFRESH",
String(Date.now())
);
}
/*
=========================================================
RSS PARSER
=========================================================
*/
function parseRss_(xmlText) {
const document =
XmlService.parse(xmlText);
const root =
document.getRootElement();
const channel =
root.getChild("channel");
if (!channel) {
return [];
}
return channel
.getChildren("item")
.map(function(item) {
return {
title:
childText_(item,"title"),
description:
childText_(item,"description"),
link:
childText_(item,"link"),
guid:
childText_(item,"guid"),
pubDate:
childText_(item,"pubDate")
};
});
}
function childText_(element,name) {
const child =
element.getChild(name);
return child
? child.getText()
: "";
}
/*
=========================================================
CANDIDATE MATCHING
=========================================================
*/
function candidateMatches_(
candidate,
normalizedText
) {
return candidate.aliases.some(
function(alias) {
const cleanAlias =
normalize_(alias);
return (
cleanAlias &&
normalizedText.indexOf(
cleanAlias
) !== -1
);
}
);
}
function normalize_(value) {
return String(value || "")
.normalize("NFD")
.replace(
/[\u0300-\u036f]/g,
""
)
.toLowerCase()
.replace(
/[^a-z0-9]+/g,
" "
)
.replace(
/\s+/g,
" "
)
.trim();
}
/*
=========================================================
MAKE FILING RECORD
=========================================================
*/
function makeFilingRecord_(
item,
candidate,
agency
) {
const combined =
[
item.title,
item.description
].join(" ");
const filingDate =
extractFilingDate_(
combined,
item.pubDate
);
const link =
item.link ||
item.guid ||
(
netfileHost_() +
agency.portalPath
);
return {
id:
makeId_(
candidate.id +
"|" +
link +
"|" +
combined
),
form:
extractForm_(combined),
filingDate:
filingDate,
reportingPeriod:
extractReportingPeriod_(
combined
),
title:
cleanText_(
item.title ||
"Campaign filing"
).substring(0,180),
link:
link,
officialSource:
agency.name,
capturedAt:
new Date().toISOString()
};
}
/*
=========================================================
FORM TYPE
=========================================================
*/
function extractForm_(text) {
const match =
String(text || "").match(
/(?:FPPC\s*)?(?:FORM\s*)?(4(?:10|25|50|60|61|62|70|96|97)|501)\b/i
);
return match
? "Form " + match[1]
: "Campaign Filing";
}
/*
=========================================================
FILING DATE
=========================================================
*/
function extractFilingDate_(
text,
pubDate
) {
const matches =
String(text || "").match(
/\b(?:0?[1-9]|1[0-2])\/(?:0?[1-9]|[12][0-9]|3[01])\/20\d{2}\b/g
);
if (
matches &&
matches.length
) {
const parsed =
new Date(matches[0]);
if (
!isNaN(
parsed.getTime()
)
) {
return parsed.toISOString();
}
}
const fallback =
new Date(pubDate || "");
if (
!isNaN(
fallback.getTime()
)
) {
return fallback.toISOString();
}
return "";
}
/*
=========================================================
REPORTING PERIOD
=========================================================
*/
function extractReportingPeriod_(text) {
const clean =
cleanText_(text);
const match =
clean.match(
/(?:covers?\s+period|reporting\s+period)[\s:]*([0-9\/\-]+\s+(?:to|through|-)\s+[0-9\/\-]+)/i
);
return match
? match[1]
: "";
}
/*
=========================================================
STORAGE
Keep latest 8 filings per candidate
=========================================================
*/
function saveFiling_(
candidateId,
filing
) {
const properties =
PropertiesService.getScriptProperties();
const key =
"CF_DATA_" + candidateId;
let current = [];
try {
current =
JSON.parse(
properties.getProperty(key) ||
"[]"
);
} catch (error) {
current = [];
}
const exists =
current.some(
function(existing) {
return existing.id === filing.id;
}
);
if (!exists) {
current.push(filing);
}
current.sort(
function(a,b) {
const da =
new Date(
a.filingDate ||
a.capturedAt
).getTime();
const db =
new Date(
b.filingDate ||
b.capturedAt
).getTime();
return db - da;
}
);
current =
current.slice(0,8);
properties.setProperty(
key,
JSON.stringify(current)
);
}
/*
=========================================================
PUBLIC RESPONSE
=========================================================
*/
function buildPayload_() {
const properties =
PropertiesService.getScriptProperties();
const lastRefresh =
Number(
properties.getProperty(
"CF_LAST_REFRESH"
) || 0
);
const candidates =
CF_CONFIG.candidates.map(
function(candidate) {
const key =
"CF_DATA_" +
candidate.id;
let filings = [];
try {
filings =
JSON.parse(
properties.getProperty(key) ||
"[]"
);
} catch (error) {
filings = [];
}
const agency =
CF_CONFIG.agencies[
candidate.agency
];
return {
id:
candidate.id,
name:
candidate.name,
jurisdiction:
candidate.jurisdiction,
race:
candidate.race,
latestFiling:
filings.length
? filings[0]
: null,
filings:
filings.slice(0,4),
officialPortal:
netfileHost_() +
agency.portalPath
};
}
);
return {
success:true,
generatedAt:
new Date().toISOString(),
lastSourceRefresh:
lastRefresh
? new Date(
lastRefresh
).toISOString()
: null,
candidates:
candidates
};
}
/*
=========================================================
UTILITIES
=========================================================
*/
function cleanText_(value) {
return String(value || "")
.replace(
/<[^>]*>/g,
" "
)
.replace(
/ /gi,
" "
)
.replace(
/&/gi,
"&"
)
.replace(
/"/gi,
"\""
)
.replace(
/\s+/g,
" "
)
.trim();
}
function makeId_(value) {
const digest =
Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
value
);
return Utilities
.base64EncodeWebSafe(digest)
.replace(/=+$/,"")
.substring(0,28);
}
/*
=========================================================
OPTIONAL RESET
Only run if you intentionally want to erase captured history
=========================================================
*/
function resetCampaignFinanceHistory() {
const properties =
PropertiesService.getScriptProperties();
properties
.getKeys()
.filter(
function(key) {
return (
key.indexOf("CF_DATA_") === 0 ||
key === "CF_LAST_REFRESH"
);
}
)
.forEach(
function(key) {
properties.deleteProperty(key);
}
);
}
