← back to SDCC Stories
lib/data-import.js
401 lines
/**
* Data Import — Parse master CSV, split multi-value columns, normalize, score candidates
*/
const fs = require('fs');
const path = require('path');
const MASTER_CSV = '/root/DW-Agents/sdcc/sheet1-master.csv';
const FRESH_CSV = '/tmp/sdcc-sheet2-fresh.csv';
const SHEET_URL = 'https://docs.google.com/spreadsheets/d/1sojrO2NtRb9p-W_sJiCOyEbB-1kRr86yU8v0I0-qlnE/export?format=csv&gid=1691047120';
// ── Column Mapping: Full question headers → short field names ──
const QUESTION_MAP = {
'Do you currently have FEDERAL student loans?': 'has_federal',
'What type(s) of federal student loans do you have?': 'loan_types',
'How much do you currently owe?': 'amount_owed',
'what year did you take out your FIRST federal student loan?': 'first_loan_year',
'Which repayment plan are you currently enrolled in?': 'repayment_plan',
'Are you aware that some repayment plans': 'aware_phaseout',
'What is your monthly student loan payment?': 'monthly_payment',
'Describe your current repayment status': 'repayment_status',
'contacted by the government about your loans being in default': 'contacted_default',
'notified by the government about any potential collections': 'notified_collections',
'experienced because of your delinquent or defaulted': 'default_experiences',
'receive resources about bringing your loans': 'want_resources',
'counting on any sort of student loan forgiveness': 'counting_forgiveness',
'specify what program you are counting on': 'forgiveness_program',
'concerned that rising costs': 'concern_inflation',
'emotional impact of your student loan debt': 'emotional_impact',
'impacted your ability to afford': 'affordability_impacts',
'what would you be spending that money on instead': 'alt_spending',
'logged into your student loan servicer': 'logged_in_recently',
'information I receive from my student loan servicer': 'trust_servicer',
'information I receive from the U.S. Department': 'trust_dept_ed',
'What source do you trust': 'trusted_sources',
'received conflicting or confusing information': 'conflicting_info',
'Which sources did you receive conflicting': 'conflicting_sources',
'What type of cancellation or forgiveness did you receive': 'forgiveness_type_received',
'What type(s) of loans did you receive cancellation': 'forgiveness_loan_type',
'experience with student': 'civic_action_impact',
'Are you a voter?': 'is_voter',
'vote in the 2024': 'voted_2024',
'vote in the 2026': 'plan_vote_2026',
'student debt landscape impact how you feel about the government': 'govt_trust_impact',
'First name': 'first_name',
'Last name': 'last_name',
'What is your email': 'email',
'What is your zip code': 'zip_code',
'How old are you?': 'age',
'marital status': 'marital_status',
'How many people are in your household': 'household_size',
'employment situation': 'employment',
'income level': 'income',
'race/ethnicity': 'race_ethnicity',
'gender': 'gender',
'highest level of education': 'education',
'share your student loan story': 'story',
'share your story with lawmakers': 'sharing_consent',
'utm_campaign': 'utm_campaign',
'Submitted At': 'submitted_at',
'Token': 'token'
};
// Map a full question header to a short field name
function mapHeader(header) {
// Exact match first
if (QUESTION_MAP[header]) return QUESTION_MAP[header];
// Partial match (question text is often truncated)
const lower = header.toLowerCase();
for (const [question, field] of Object.entries(QUESTION_MAP)) {
if (lower.includes(question.toLowerCase())) return field;
}
// Already a short name (from processed CSV)
if (/^[a-z_]+$/.test(header)) return header;
return header;
}
// Download fresh data from Google Sheet
async function downloadFreshData() {
const https = require('https');
return new Promise((resolve, reject) => {
const follow = (url, depth) => {
if (depth > 5) return reject(new Error('Too many redirects'));
const mod = url.startsWith('https') ? https : require('http');
mod.get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return follow(res.headers.location, depth + 1);
}
let body = '';
res.on('data', c => body += c);
res.on('end', () => {
if (body.includes('<!DOCTYPE html>')) return reject(new Error('Got HTML instead of CSV — sheet may require auth'));
fs.writeFileSync(FRESH_CSV, body);
resolve(FRESH_CSV);
});
}).on('error', reject);
};
follow(SHEET_URL, 0);
});
}
// ── Multi-Value Column Splitters ──
// Income: "Household - 20,000-50,000" → { income_type: "Household", income_range: "20,000-50,000" }
function splitIncome(raw) {
if (!raw) return { income_type: '', income_range: '', income_amount_low: 0, income_amount_high: 0 };
const m = raw.match(/^(Household|Individual)\s*-\s*(.+)$/i);
if (!m) return { income_type: '', income_range: raw, income_amount_low: 0, income_amount_high: 0 };
const type = m[1].trim();
const range = m[2].trim();
let low = 0, high = 0;
if (range.includes('Less than')) {
low = 0; high = 20000;
} else if (range.includes('More than')) {
low = 100000; high = 999999;
} else {
const nums = range.replace(/[$,]/g, '').match(/(\d+)\s*-\s*(\d+)/);
if (nums) { low = parseInt(nums[1]); high = parseInt(nums[2]); }
}
return { income_type: type, income_range: range, income_amount_low: low, income_amount_high: high };
}
// Amount owed: "$100,001 - $150,000" → { amount_low, amount_high, amount_display }
function splitAmount(raw) {
if (!raw) return { amount_low: 0, amount_high: 0, amount_display: '' };
const clean = raw.replace(/[$,]/g, '');
if (raw.includes('More than')) {
return { amount_low: 200000, amount_high: 999999, amount_display: raw };
}
if (raw.includes('Less than')) {
return { amount_low: 0, amount_high: 20000, amount_display: raw };
}
const nums = clean.match(/(\d+)\s*-\s*(\d+)/);
if (nums) {
return { amount_low: parseInt(nums[1]), amount_high: parseInt(nums[2]), amount_display: raw };
}
return { amount_low: 0, amount_high: 0, amount_display: raw };
}
// Affordability impacts: comma-separated list → array + individual boolean columns
function splitAffordability(raw) {
if (!raw) return { affordability_list: [], affordability_count: 0 };
const items = raw.split(',').map(s => s.trim()).filter(Boolean);
return {
affordability_list: items,
affordability_count: items.length,
aff_food: items.some(i => /food|groceries/i.test(i)),
aff_healthcare: items.some(i => /health/i.test(i)),
aff_housing: items.some(i => /rent|mortgage|hous/i.test(i)),
aff_savings: items.some(i => /saving|retirement|invest/i.test(i)),
aff_childcare: items.some(i => /child/i.test(i)),
aff_education: items.some(i => /tuition|school/i.test(i)),
aff_debt: items.some(i => /credit|debt/i.test(i)),
aff_charity: items.some(i => /charit|donat/i.test(i))
};
}
// Loan types: comma-separated → array
function splitLoanTypes(raw) {
if (!raw) return { loan_type_list: [], has_undergraduate: false, has_graduate: false, has_parent_plus: false, has_perkins: false, has_consolidation: false };
const items = raw.split(',').map(s => s.trim()).filter(Boolean);
return {
loan_type_list: items,
has_undergraduate: items.some(i => /undergraduate/i.test(i)),
has_graduate: items.some(i => /graduate\s*plus|grad/i.test(i)),
has_parent_plus: items.some(i => /parent/i.test(i)),
has_perkins: items.some(i => /perkins/i.test(i)),
has_consolidation: items.some(i => /consolidat/i.test(i))
};
}
// ── Candidate Scoring ──
// Higher score = better story candidate
function scoreCandidate(record) {
let score = 0;
// P: Emotional impact (richest stories have detailed emotional narratives)
const emoLen = (record.emotional_impact || '').length;
if (emoLen > 200) score += 30;
else if (emoLen > 100) score += 20;
else if (emoLen > 30) score += 10;
// Q: Affordability impacts (more impacts = more compelling)
score += Math.min((record.affordability_count || 0) * 5, 25);
// R: Alt spending (concrete alternatives make stories relatable)
const altLen = (record.alt_spending || '').length;
if (altLen > 100) score += 20;
else if (altLen > 30) score += 10;
// Story field present
if ((record.story || '').length > 50) score += 15;
// Has consent
if (/yes/i.test(record.sharing_consent)) score += 10;
if (/yes/i.test(record.press_ready)) score += 10;
// Key quote exists
if ((record.key_quote || '').length > 20) score += 10;
// Has name + zip (needed for lawmaker mapping)
if (record.first_name) score += 5;
if (record.zip_code) score += 5;
// Amount owed (higher debt = more impactful)
if (record.amount_high > 100000) score += 10;
else if (record.amount_high > 50000) score += 5;
// Voter status (civic engagement adds credibility)
if (/yes/i.test(record.is_voter)) score += 5;
return Math.min(score, 100);
}
// ── Candidate Tier ──
function getTier(score) {
if (score >= 70) return 'A'; // Press + social media + lawmakers
if (score >= 45) return 'B'; // Social media + lawmakers
if (score >= 25) return 'C'; // Social media only
return 'D'; // Needs enrichment
}
// ── Parse CSV Manually (no external deps) ──
function parseCSV(text) {
const rows = [];
let current = '';
let inQuotes = false;
const lines = [];
// Split into lines respecting quoted fields
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '"') {
if (inQuotes && text[i + 1] === '"') { current += '"'; i++; }
else { inQuotes = !inQuotes; current += ch; }
} else if ((ch === '\n' || ch === '\r') && !inQuotes) {
if (current.length > 0) lines.push(current);
current = '';
if (ch === '\r' && text[i + 1] === '\n') i++;
} else {
current += ch;
}
}
if (current.length > 0) lines.push(current);
if (lines.length === 0) return [];
// Parse header
const headers = splitCSVLine(lines[0]);
// Parse data rows
for (let i = 1; i < lines.length; i++) {
const vals = splitCSVLine(lines[i]);
const row = {};
for (let j = 0; j < headers.length; j++) {
row[headers[j]] = (vals[j] || '').replace(/^"|"$/g, '').trim();
}
rows.push(row);
}
return rows;
}
function splitCSVLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQuotes && line[i + 1] === '"') { current += '"'; i++; }
else { inQuotes = !inQuotes; }
} else if (ch === ',' && !inQuotes) {
result.push(current);
current = '';
} else {
current += ch;
}
}
result.push(current);
return result;
}
// ── Main Import Function ──
function importData(csvPath) {
// Try fresh CSV first, then master CSV
const filePath = csvPath || (fs.existsSync(FRESH_CSV) ? FRESH_CSV : MASTER_CSV);
const text = fs.readFileSync(filePath, 'utf8');
const rawRows = parseCSV(text);
// Detect if headers are full questions or short names, and normalize
if (rawRows.length > 0) {
const firstRow = rawRows[0];
const keys = Object.keys(firstRow);
// If first key looks like a question, remap all rows
if (keys[0] && keys[0].length > 30) {
const headerMap = {};
for (const k of keys) headerMap[k] = mapHeader(k);
for (const row of rawRows) {
for (const [oldKey, newKey] of Object.entries(headerMap)) {
if (oldKey !== newKey) {
row[newKey] = row[oldKey];
delete row[oldKey];
}
}
}
}
}
const processed = rawRows.map((raw, idx) => {
const income = splitIncome(raw.income);
const amount = splitAmount(raw.amount_owed);
const aff = splitAffordability(raw.affordability_impacts);
const loans = splitLoanTypes(raw.loan_types);
const record = {
id: idx + 1,
// Core demographics
first_name: raw.first_name || '',
last_name: raw.last_name || '',
email: raw.email || '',
zip_code: raw.zip_code || '',
age: raw.age || '',
marital_status: raw.marital_status || '',
household_size: raw.household_size || '',
employment: raw.employment || '',
race_ethnicity: raw.race_ethnicity || '',
gender: raw.gender || '',
education: raw.education || '',
// Income — SPLIT
income_raw: raw.income || '',
...income,
// Loan info
has_federal: raw.has_federal || '',
loan_types_raw: raw.loan_types || '',
...loans,
first_loan_year: raw.first_loan_year || '',
repayment_plan: raw.repayment_plan || '',
monthly_payment: raw.monthly_payment || '',
repayment_status: raw.repayment_status || '',
// Amount — SPLIT
amount_owed_raw: raw.amount_owed || '',
...amount,
// P, Q, R — THE STORY ESSENTIALS
emotional_impact: raw.emotional_impact || '', // P
affordability_impacts_raw: raw.affordability_impacts || '', // Q (raw)
...aff, // Q (split)
alt_spending: raw.alt_spending || '', // R
// Story
story: raw.story || '',
key_quote: raw.key_quote || '',
story_type: raw.story_type || '',
story_category: raw.story_category || '',
platform_fit: raw.platform_fit || '',
story_strength: parseInt(raw.story_strength) || 0,
press_pitch_angle: raw.press_pitch_angle || '',
// Consent & readiness
sharing_consent: raw.sharing_consent || '',
press_ready: raw.press_ready || '',
// Civic
is_voter: raw.is_voter || '',
voted_2024: raw.voted_2024 || '',
plan_vote_2026: raw.plan_vote_2026 || '',
civic_action_impact: raw.civic_action_impact || '',
govt_trust_impact: raw.govt_trust_impact || '',
// Trust
trust_servicer: raw.trust_servicer || '',
trust_dept_ed: raw.trust_dept_ed || '',
trusted_sources: raw.trusted_sources || '',
// Other
aware_phaseout: raw.aware_phaseout || '',
counting_forgiveness: raw.counting_forgiveness || '',
forgiveness_program: raw.forgiveness_program || '',
concern_inflation: raw.concern_inflation || '',
default_experiences: raw.default_experiences || '',
utm_campaign: raw.utm_campaign || '',
submitted_at: raw.submitted_at || ''
};
// Score and tier
record.candidate_score = scoreCandidate(record);
record.tier = getTier(record.candidate_score);
return record;
});
return processed;
}
module.exports = { importData, downloadFreshData, scoreCandidate, getTier, splitIncome, splitAmount, splitAffordability, splitLoanTypes };