← back to Norma
agents/price-agent/skills/crawl-ccc.js
193 lines
/**
* crawl-ccc.js — Custom route skill
*
* California Community Colleges have a statewide per-unit fee.
* This skill calculates the annual cost and inserts a tuition_history
* row for every CA community college in the scorecard table.
*/
const { query } = require('../../shared/db');
const { logAction } = require('../../shared/audit-logger');
const { CCC_CONFIG } = require('../lib/ca-schools-registry');
const AGENT_NAME = 'price-agent';
function currentAcademicYear() {
const now = new Date();
return now.getMonth() >= 6 ? now.getFullYear() : now.getFullYear() - 1;
}
module.exports = async function crawlCCC(body = {}) {
const startTime = Date.now();
const academicYear = (body && body.academic_year) || currentAcademicYear();
// 1. Get CCC config from registry
const config = CCC_CONFIG;
const feePerUnit = config.statewideFeePerUnit || config.fee_per_unit || 46; // $46/unit as of 2025-2026
const fullTimeUnits = config.fullTimeUnitsPerYear || config.full_time_units || 30; // 30 units/year typical full-time
// 2. Calculate annual tuition
const annualTuition = Math.round(feePerUnit * fullTimeUnits);
console.log(`[crawl-ccc] Fee per unit: $${feePerUnit}, Full-time units: ${fullTimeUnits}, Annual: $${annualTuition}`);
// 3. Log crawl start
const crawlLogRes = await query(
`INSERT INTO price_crawl_log (crawl_type, status, metadata)
VALUES ('ccc_calc', 'running', $1)
RETURNING id`,
[JSON.stringify({
academic_year: academicYear,
fee_per_unit: feePerUnit,
full_time_units: fullTimeUnits,
annual_tuition: annualTuition,
})]
);
const crawlLogId = crawlLogRes.rows[0].id;
let collegesUpdated = 0;
const errors = [];
try {
// 4. Query all CA community colleges from college_scorecard
const { rows: colleges } = await query(
`SELECT unit_id, school_name
FROM college_scorecard
WHERE state = 'CA'
AND institution_type = 'community_college'
ORDER BY school_name`
);
console.log(`[crawl-ccc] Found ${colleges.length} CA community colleges in scorecard`);
if (colleges.length === 0) {
// If no scorecard data yet, log and return
const durationMs = Date.now() - startTime;
await query(
`UPDATE price_crawl_log
SET status = 'completed',
schools_found = 0, schools_updated = 0,
duration_ms = $1, error_message = 'No community colleges found in scorecard — run scorecard crawl first',
completed_at = NOW()
WHERE id = $2`,
[durationMs, crawlLogId]
);
return {
colleges_updated: 0,
annual_fee_calculated: annualTuition,
crawl_log_id: crawlLogId,
message: 'No community colleges in scorecard. Run scorecard crawl first.',
};
}
// 5. For each community college, insert tuition_history
for (const college of colleges) {
try {
await query(
`INSERT INTO tuition_history (
unit_id, school_name, state, institution_type, academic_year, data_source,
tuition_in_state, per_unit_fee,
raw_data
) VALUES (
$1, $2, 'CA', 'community_college', $3, 'ccc_calc',
$4, $5,
$6
)
ON CONFLICT (unit_id, academic_year, data_source) DO UPDATE SET
school_name = EXCLUDED.school_name,
tuition_in_state = EXCLUDED.tuition_in_state,
per_unit_fee = EXCLUDED.per_unit_fee,
raw_data = EXCLUDED.raw_data`,
[
college.unit_id,
college.school_name,
academicYear,
annualTuition,
feePerUnit,
JSON.stringify({
calculation: 'statewide_fee',
fee_per_unit: feePerUnit,
full_time_units: fullTimeUnits,
annual_tuition: annualTuition,
enrollment_fee_only: true,
note: 'CCC statewide enrollment fee; does not include student activity, health, or campus-specific fees',
}),
]
);
collegesUpdated++;
} catch (err) {
errors.push({ unit_id: college.unit_id, school: college.school_name, error: err.message });
console.error(`[crawl-ccc] Error for ${college.school_name}:`, err.message);
}
}
// 6. Update crawl log
const durationMs = Date.now() - startTime;
await query(
`UPDATE price_crawl_log
SET status = 'completed',
schools_found = $1,
schools_updated = $2,
duration_ms = $3,
metadata = metadata || $4,
completed_at = NOW()
WHERE id = $5`,
[
colleges.length,
collegesUpdated,
durationMs,
JSON.stringify({ errors: errors.slice(0, 20) }),
crawlLogId,
]
);
await logAction({
agent: AGENT_NAME,
actionType: 'crawl',
platform: 'ccc_system',
content: `Calculated CCC tuition for ${collegesUpdated} colleges ($${annualTuition}/year at $${feePerUnit}/unit)`,
responseData: {
colleges_updated: collegesUpdated,
annual_tuition: annualTuition,
fee_per_unit: feePerUnit,
},
status: errors.length > 0 ? 'partial' : 'success',
errorMessage: errors.length > 0 ? `${errors.length} colleges had errors` : null,
});
console.log(`[crawl-ccc] Complete: ${collegesUpdated} colleges updated with $${annualTuition}/year in ${durationMs}ms`);
return {
colleges_updated: collegesUpdated,
annual_fee_calculated: annualTuition,
fee_per_unit: feePerUnit,
full_time_units: fullTimeUnits,
crawl_log_id: crawlLogId,
errors_count: errors.length,
duration_ms: durationMs,
};
} catch (err) {
const durationMs = Date.now() - startTime;
await query(
`UPDATE price_crawl_log
SET status = 'error', error_message = $1, duration_ms = $2, completed_at = NOW()
WHERE id = $3`,
[err.message, durationMs, crawlLogId]
).catch(() => {});
await logAction({
agent: AGENT_NAME,
actionType: 'crawl',
platform: 'ccc_system',
content: 'CCC calculation failed',
status: 'error',
errorMessage: err.message,
}).catch(() => {});
console.error('[crawl-ccc] Fatal error:', err);
throw err;
}
};