/* ========================================================= 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); } ); }