/** * CourtListener API Extractor * * Extracts federal court cases related to AI regulation preemption * * @author Jason Pellerin AI Solutionist */ import { log } from 'apify'; import { COURTLISTENER_API, COURT_CASE_KEYWORDS, FEDERAL_COURTS } from '../utils/constants.js'; import type { Input, ExtractionResult, CourtCase, CourtListenerSearchResponse, CourtListenerResult, } from '../utils/types.js'; import { calculatePreemptionRisk, generateRagChunks } from '../analysis/risk-scorer.js'; const DELAY_MS = 500; // CourtListener rate limit: 5000/hour = ~1.4/second /** * Extract court cases from CourtListener */ export async function extractCourtCases(input: Input): Promise> { log.info('⚖️ Extracting court cases from CourtListener...'); const startTime = Date.now(); const results: CourtCase[] = []; const errors: string[] = []; const warnings: string[] = []; let requestCount = 0; let rateLimitHits = 0; // Build court filter based on input const targetCourts = buildCourtFilter(input.courtTypes); try { // Search opinions (decided cases) const opinionsResult = await searchCourtListener( 'opinions', COURT_CASE_KEYWORDS, input, targetCourts ); requestCount += opinionsResult.requests; for (const opinion of opinionsResult.data) { const courtCase = transformToCourtCase(opinion, input, 'court_opinion'); if (courtCase && courtCase.preemptionRisk.score >= input.riskScoreThreshold) { results.push(courtCase); } } log.info(`Found ${opinionsResult.data.length} relevant opinions`); // Search dockets (pending cases) await delay(DELAY_MS); const docketsResult = await searchCourtListener( 'dockets', COURT_CASE_KEYWORDS, input, targetCourts ); requestCount += docketsResult.requests; for (const docket of docketsResult.data) { const courtCase = transformToCourtCase(docket, input, 'court_case'); if (courtCase && courtCase.preemptionRisk.score >= input.riskScoreThreshold) { results.push(courtCase); } } log.info(`Found ${docketsResult.data.length} relevant dockets`); } catch (error) { const msg = `CourtListener extraction failed: ${error}`; log.error(msg); errors.push(msg); } // Deduplicate by case ID const deduplicated = deduplicateCourtCases(results); return { success: errors.length === 0, data: deduplicated, totalFound: results.length, extracted: deduplicated.length, errors, warnings, metrics: { durationMs: Date.now() - startTime, requestCount, rateLimitHits, retries: 0, deduplicatedCount: results.length - deduplicated.length, }, }; } /** * Search CourtListener API * Uses the REST API v4 with proper endpoint formatting */ async function searchCourtListener( searchType: 'opinions' | 'dockets', keywords: string[], input: Input, courts: string[] ): Promise<{ data: CourtListenerResult[]; requests: number }> { const results: CourtListenerResult[] = []; let requests = 0; // Use simpler, broader search terms const searchTerms = [ 'preemption', 'algorithm', 'artificial intelligence', 'automated', 'discrimination', ]; // Build query - simpler format const query = searchTerms.slice(0, 3).join(' '); // Calculate date range let filedAfter = input.dateRangeStart; if (!filedAfter) { const twelveMonthsAgo = new Date(); twelveMonthsAgo.setMonth(twelveMonthsAgo.getMonth() - 12); filedAfter = twelveMonthsAgo.toISOString().split('T')[0]; } // Use the search endpoint directly // Format: https://www.courtlistener.com/api/rest/v4/search/?q=query&type=o const typeParam = searchType === 'opinions' ? 'o' : 'r'; // 'o' for opinions, 'r' for recap/dockets const params = new URLSearchParams({ q: query, type: typeParam, order_by: 'dateFiled desc', filed_after: filedAfter, }); // Add court filter if specified if (courts.length > 0 && !courts.includes('all')) { params.append('court', courts.join(',')); } const url = `https://www.courtlistener.com/api/rest/v4/search/?${params}`; log.info(`Fetching ${searchType} from CourtListener: ${url}`); try { const headers: Record = { 'Accept': 'application/json', 'User-Agent': 'HyperCognate-Federal-Preemption-Tracker/1.0', }; // Add API token if provided if (input.courtListenerApiToken) { headers['Authorization'] = `Token ${input.courtListenerApiToken}`; } const response = await fetch(url, { headers }); requests++; if (response.status === 429) { log.warning('CourtListener rate limit hit - consider adding API token'); return { data: [], requests }; } if (!response.ok) { const errorText = await response.text(); log.warning(`CourtListener API error ${response.status}: ${errorText.slice(0, 200)}`); return { data: [], requests }; } const data = await response.json() as CourtListenerSearchResponse; log.info(`CourtListener returned ${data.count || 0} total results`); // Handle results if (data.results && Array.isArray(data.results)) { const maxItems = Math.min(input.maxResults, data.results.length); results.push(...data.results.slice(0, maxItems)); } else { log.warning(`No results array in CourtListener ${searchType} response`); } } catch (error) { log.error(`CourtListener search failed: ${error}`); } return { data: results, requests }; } /** * Transform CourtListener result to CourtCase */ function transformToCourtCase( result: CourtListenerResult, input: Input, eventType: 'court_case' | 'court_opinion' ): CourtCase | null { const id = `cl-${eventType}-${result.id}`; const affectedStates = detectAffectedStatesFromCase(result, input.targetStates); // Extract parties from case name const parties = parseParties(result.case_name); const baseCase: Partial = { id, eventType, title: result.case_name, summary: result.snippet || result.case_name, source: 'courtlistener', sourceUrl: `https://www.courtlistener.com${result.absolute_url}`, sourceId: String(result.id), datePublished: result.date_filed, dateDiscovered: new Date().toISOString(), federalAuthority: 'federal_courts', affectedStates, affectedProvisions: detectProvisionsFromCase(result), caseNumber: result.docket_number || String(result.id), caseName: result.case_name, court: result.court, courtCode: result.court_id, judges: result.author_str ? [result.author_str] : undefined, parties, status: inferCaseStatus(result, eventType), rawData: result, extractedAt: new Date().toISOString(), }; // Calculate risk const risk = calculatePreemptionRisk(baseCase as CourtCase, input); const ragChunks = generateRagChunks(baseCase as CourtCase); return { ...baseCase, preemptionRisk: risk.risk, complianceImpact: risk.impact, ragChunks, citations: extractCaseCitations(result), relatedEvents: [], } as CourtCase; } // ═══════════════════════════════════════════════════════════════════════════ // Helper Functions // ═══════════════════════════════════════════════════════════════════════════ function buildCourtFilter(courtTypes: string[]): string[] { const courts: string[] = []; for (const type of courtTypes) { switch (type) { case 'scotus': courts.push('scotus'); break; case 'circuit': // Add all circuit courts courts.push('cadc', 'ca1', 'ca2', 'ca3', 'ca4', 'ca5', 'ca6', 'ca7', 'ca8', 'ca9', 'ca10', 'ca11', 'cafc'); break; case 'district': // We'll rely on search rather than listing all districts break; case 'all': // Don't filter by court return []; } } return courts; } function detectAffectedStatesFromCase(result: CourtListenerResult, targetStates: string[]): string[] { const text = `${result.case_name} ${result.snippet || ''}`.toLowerCase(); const affected: string[] = []; // Check for state mentions const statePatterns: Record = { 'CO': ['colorado', 'colo.'], 'CA': ['california', 'calif.'], 'CT': ['connecticut', 'conn.'], 'IL': ['illinois', 'ill.'], 'TX': ['texas', 'tex.'], 'NY': ['new york', 'n.y.'], 'VA': ['virginia', 'va.'], 'WA': ['washington state', 'wash.'], }; for (const [state, patterns] of Object.entries(statePatterns)) { if (patterns.some(p => text.includes(p))) { if (targetStates.includes(state) || targetStates.includes('ALL')) { affected.push(state); } } } // Check for AI/preemption keywords that suggest broad applicability const isAICase = /artificial intelligence|ai\b|algorithmic|automated decision/i.test(text); const isPreemption = /preempt|state law|federal.*state|supremacy/i.test(text); if (affected.length === 0 && isAICase && isPreemption) { return targetStates.filter(s => s !== 'ALL'); } return affected; } function detectProvisionsFromCase(result: CourtListenerResult): string[] { const text = `${result.case_name} ${result.snippet || ''}`; const provisions: string[] = []; if (/SB.?24.?205|Colorado AI Act/i.test(text)) { provisions.push('Colorado SB 24-205'); } if (/SB.?1047|frontier AI/i.test(text)) { provisions.push('California SB 1047'); } if (/algorithmic discrimination|disparate impact/i.test(text)) { provisions.push('Algorithmic Discrimination Provisions'); } return provisions; } function parseParties(caseName: string): CourtCase['parties'] { const parts = caseName.split(/\s+v\.?\s+/i); return { plaintiff: parts[0]?.trim() || 'Unknown', defendant: parts[1]?.trim() || 'Unknown', }; } function inferCaseStatus(result: CourtListenerResult, eventType: 'court_case' | 'court_opinion'): import('../utils/types.js').CaseStatus { if (eventType === 'court_opinion') { return 'decided'; } // For dockets, try to infer status if (result.date_argued) { return 'submitted'; } return 'pending'; } function extractCaseCitations(result: CourtListenerResult): import('../utils/types.js').Citation[] { const citations: import('../utils/types.js').Citation[] = []; if (result.citations && Array.isArray(result.citations)) { for (const cite of result.citations) { citations.push({ text: cite, citation: cite, type: 'case', url: `https://www.courtlistener.com${result.absolute_url}`, verified: true, }); } } return citations; } function deduplicateCourtCases(cases: CourtCase[]): CourtCase[] { const seen = new Map(); for (const courtCase of cases) { // Prefer opinions over dockets for the same case const key = courtCase.caseName.toLowerCase().replace(/\s+/g, ''); const existing = seen.get(key); if (!existing || (courtCase.eventType === 'court_opinion' && existing.eventType === 'court_case')) { seen.set(key, courtCase); } } return Array.from(seen.values()); } function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); }