← back to Norma
agents/price-agent/skills/crawl-csu.js
258 lines
/**
* crawl-csu.js — Reply skill
*
* Scrapes CSU system tuition/fee pages for each campus.
* Same pattern as UC crawler but uses CSU_CAMPUSES registry
* and the CSU-specific HTML parser.
*/
const fetch = require('node-fetch');
const { query } = require('../../shared/db');
const { logAction } = require('../../shared/audit-logger');
const { CSU_CAMPUSES } = require('../lib/ca-schools-registry');
const { parseCSUCostsPage } = require('../lib/html-parser');
const AGENT_NAME = 'price-agent';
const FETCH_DELAY_MS = 2000;
const MIN_FIELDS_THRESHOLD = 3;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function currentAcademicYear() {
const now = new Date();
return now.getMonth() >= 6 ? now.getFullYear() : now.getFullYear() - 1;
}
function countCostFields(parsed) {
if (!parsed || typeof parsed !== 'object') return 0;
const costKeys = [
'tuition_in_state', 'tuition_out_state', 'mandatory_fees',
'room_board_on_campus', 'room_board_off_campus',
'books_supplies', 'other_expenses', 'transportation',
'coa_on_campus', 'coa_off_campus',
];
return costKeys.filter((k) => parsed[k] != null).length;
}
module.exports = async function crawlCSU(body = {}) {
const startTime = Date.now();
const academicYear = (body && body.academic_year) || currentAcademicYear();
// 1. Log crawl start
const crawlLogRes = await query(
`INSERT INTO price_crawl_log (crawl_type, status, metadata)
VALUES ('csu_scrape', 'running', $1)
RETURNING id`,
[JSON.stringify({ academic_year: academicYear })]
);
const crawlLogId = crawlLogRes.rows[0].id;
let campusesCrawled = 0;
let campusesWithData = 0;
const errors = [];
try {
// Lazy-load enrich skill for fallback
let enrichSkill = null;
try {
enrichSkill = require('./enrich');
} catch (e) {
console.warn('[crawl-csu] Enrich skill not available for fallback:', e.message);
}
for (const campus of CSU_CAMPUSES) {
const campusName = campus.name || campus.school_name;
console.log(`[crawl-csu] Crawling ${campusName}...`);
try {
if (!campus.costsUrl) {
errors.push({ campus: campusName, error: 'No costsUrl configured' });
continue;
}
// Fetch the costs page
const resp = await fetch(campus.costsUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; NormaPriceAgent/1.0; education-research)',
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'en-US,en;q=0.9',
},
timeout: 20000,
redirect: 'follow',
});
if (!resp.ok) {
errors.push({ campus: campusName, error: `HTTP ${resp.status}` });
console.warn(`[crawl-csu] ${campusName}: HTTP ${resp.status}`);
campusesCrawled++;
await sleep(FETCH_DELAY_MS);
continue;
}
const html = await resp.text();
campusesCrawled++;
// Parse HTML with CSU-specific parser
let parsed = null;
try {
parsed = parseCSUCostsPage(html, campusName);
} catch (parseErr) {
console.warn(`[crawl-csu] ${campusName}: Parse error:`, parseErr.message);
}
// If parsing returned incomplete data, try Gemini enrich as fallback
if (countCostFields(parsed) < MIN_FIELDS_THRESHOLD && enrichSkill) {
console.log(`[crawl-csu] ${campusName}: Only ${countCostFields(parsed)} fields, trying Gemini fallback...`);
try {
const enriched = await enrichSkill({
html: html,
school_name: campusName,
campus_system: 'CSU',
});
if (enriched && countCostFields(enriched) > countCostFields(parsed)) {
parsed = enriched;
console.log(`[crawl-csu] ${campusName}: Gemini returned ${countCostFields(parsed)} fields`);
}
} catch (enrichErr) {
console.warn(`[crawl-csu] ${campusName}: Gemini fallback failed:`, enrichErr.message);
}
}
if (!parsed || countCostFields(parsed) === 0) {
errors.push({ campus: campusName, error: 'No cost data extracted' });
await sleep(FETCH_DELAY_MS);
continue;
}
// Upsert into tuition_history
const unitId = campus.unit_id || campus.unitId;
const resolvedYear = parsed.academic_year || academicYear;
await query(
`INSERT INTO tuition_history (
unit_id, school_name, state, institution_type, academic_year, data_source,
tuition_in_state, tuition_out_state, mandatory_fees,
tuition_and_fees_in, tuition_and_fees_out,
room_board_on_campus, room_board_off_campus,
books_supplies, other_expenses, transportation,
coa_on_campus, coa_off_campus,
raw_data
) VALUES (
$1,$2,'CA','4year_public',$3,'csu_scrape',
$4,$5,$6,
$7,$8,
$9,$10,
$11,$12,$13,
$14,$15,
$16
)
ON CONFLICT (unit_id, academic_year, data_source) DO UPDATE SET
school_name = EXCLUDED.school_name,
tuition_in_state = COALESCE(EXCLUDED.tuition_in_state, tuition_history.tuition_in_state),
tuition_out_state = COALESCE(EXCLUDED.tuition_out_state, tuition_history.tuition_out_state),
mandatory_fees = COALESCE(EXCLUDED.mandatory_fees, tuition_history.mandatory_fees),
tuition_and_fees_in = COALESCE(EXCLUDED.tuition_and_fees_in, tuition_history.tuition_and_fees_in),
tuition_and_fees_out = COALESCE(EXCLUDED.tuition_and_fees_out, tuition_history.tuition_and_fees_out),
room_board_on_campus = COALESCE(EXCLUDED.room_board_on_campus, tuition_history.room_board_on_campus),
room_board_off_campus = COALESCE(EXCLUDED.room_board_off_campus, tuition_history.room_board_off_campus),
books_supplies = COALESCE(EXCLUDED.books_supplies, tuition_history.books_supplies),
other_expenses = COALESCE(EXCLUDED.other_expenses, tuition_history.other_expenses),
transportation = COALESCE(EXCLUDED.transportation, tuition_history.transportation),
coa_on_campus = COALESCE(EXCLUDED.coa_on_campus, tuition_history.coa_on_campus),
coa_off_campus = COALESCE(EXCLUDED.coa_off_campus, tuition_history.coa_off_campus),
raw_data = EXCLUDED.raw_data`,
[
unitId,
campusName,
resolvedYear,
parsed.tuition_in_state || null,
parsed.tuition_out_state || null,
parsed.mandatory_fees || null,
parsed.tuition_and_fees_in || null,
parsed.tuition_and_fees_out || null,
parsed.room_board_on_campus || null,
parsed.room_board_off_campus || null,
parsed.books_supplies || null,
parsed.other_expenses || null,
parsed.transportation || null,
parsed.coa_on_campus || null,
parsed.coa_off_campus || null,
JSON.stringify({ source_url: campus.costsUrl, parsed }),
]
);
campusesWithData++;
console.log(`[crawl-csu] ${campusName}: ${countCostFields(parsed)} cost fields saved`);
} catch (campusErr) {
errors.push({ campus: campusName, error: campusErr.message });
console.error(`[crawl-csu] ${campusName}: Error:`, campusErr.message);
}
// Polite delay between campuses
await sleep(FETCH_DELAY_MS);
}
// 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`,
[
campusesCrawled,
campusesWithData,
durationMs,
JSON.stringify({ errors: errors.slice(0, 20) }),
crawlLogId,
]
);
await logAction({
agent: AGENT_NAME,
actionType: 'crawl',
platform: 'csu_system',
content: `Crawled ${campusesCrawled} CSU campuses, ${campusesWithData} with data`,
responseData: { campuses_crawled: campusesCrawled, campuses_with_data: campusesWithData, errors: errors.length },
status: errors.length > 0 ? 'partial' : 'success',
errorMessage: errors.length > 0 ? `${errors.length} campus errors` : null,
});
console.log(`[crawl-csu] Complete: ${campusesWithData}/${campusesCrawled} campuses with data in ${durationMs}ms`);
return {
campuses_crawled: campusesCrawled,
campuses_with_data: campusesWithData,
errors,
crawl_log_id: crawlLogId,
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: 'csu_system',
content: 'CSU crawl failed',
status: 'error',
errorMessage: err.message,
}).catch(() => {});
console.error('[crawl-csu] Fatal error:', err);
throw err;
}
};