← back to Cypress Awards

backend/src/scraper/courtListenerScraper.js

305 lines

// CourtListener Legal Case Scraper
// Searches for cy pres award cases using CourtListener/RECAP API

const axios = require('axios');
const { query } = require('../models/database');
require('dotenv').config();

class CourtListenerScraper {
  constructor(apiToken) {
    this.apiToken = apiToken || process.env.COURTLISTENER_API_TOKEN;
    this.baseUrl = 'https://www.courtlistener.com/api/rest/v3';
    this.delay = 1000; // 1 second between requests to be respectful
  }

  // Search for cy pres cases
  async searchCyPresCases(options = {}) {
    const {
      query: searchQuery = 'cy pres OR "cy-pres" OR "cypres"',
      dateAfter = '2010-01-01',
      orderBy = 'score desc',
      limit = 20,
      offset = 0
    } = options;

    console.log(`\nSearching CourtListener for: ${searchQuery}`);
    console.log(`Date range: ${dateAfter} to present\n`);

    try {
      const response = await axios.get(`${this.baseUrl}/search/`, {
        params: {
          type: 'o', // o = opinions (case law)
          q: searchQuery,
          filed_after: dateAfter,
          order_by: orderBy,
          format: 'json'
        },
        headers: this.apiToken ? {
          'Authorization': `Token ${this.apiToken}`
        } : {},
        timeout: 30000
      });

      const results = response.data.results || [];
      const count = response.data.count || 0;

      console.log(`✓ Found ${count} total cases`);
      console.log(`✓ Retrieved ${results.length} cases in this batch\n`);

      return {
        count,
        results,
        next: response.data.next
      };

    } catch (error) {
      console.error('Error searching CourtListener:', error.message);
      if (error.response) {
        console.error('Response status:', error.response.status);
        console.error('Response data:', JSON.stringify(error.response.data, null, 2));
      }
      return null;
    }
  }

  // Get detailed case information
  async getCaseDetails(caseId) {
    console.log(`Fetching details for case ID: ${caseId}`);

    try {
      const response = await axios.get(`${this.baseUrl}/opinions/${caseId}/`, {
        params: {
          format: 'json'
        },
        headers: this.apiToken ? {
          'Authorization': `Token ${this.apiToken}`
        } : {},
        timeout: 30000
      });

      return response.data;
    } catch (error) {
      console.error(`Error fetching case ${caseId}:`, error.message);
      return null;
    }
  }

  // Extract cy pres information from case text
  extractCyPresInfo(caseText, caseData) {
    const lowerText = caseText.toLowerCase();

    // Look for cy pres award amounts
    const amountPatterns = [
      /\$[\d,]+(?:\.\d{2})?(?:\s*million|\s*m)?/gi,
      /cy[\s-]*pres.*?(?:award|distribution|fund).*?\$[\d,]+/gi
    ];

    const amounts = [];
    amountPatterns.forEach(pattern => {
      const matches = caseText.match(pattern);
      if (matches) {
        amounts.push(...matches);
      }
    });

    // Look for recipient organizations
    const recipients = [];
    const recipientPatterns = [
      /(?:awarded to|distributed to|granted to)\s+([A-Z][A-Za-z\s&,]+(?:Foundation|Organization|Society|Institute|Association|Fund|Center|Council|Alliance))/g,
      /(?:beneficiary|beneficiaries):\s*([A-Z][A-Za-z\s&,]+)/g
    ];

    recipientPatterns.forEach(pattern => {
      let match;
      while ((match = pattern.exec(caseText)) !== null) {
        recipients.push(match[1].trim());
      }
    });

    // Extract settlement/class action info
    const isClassAction = lowerText.includes('class action') || lowerText.includes('class-action');
    const isSettlement = lowerText.includes('settlement') || lowerText.includes('settle');

    return {
      amounts,
      recipients: [...new Set(recipients)], // Remove duplicates
      isClassAction,
      isSettlement,
      containsCyPres: lowerText.includes('cy pres') || lowerText.includes('cy-pres') || lowerText.includes('cypres')
    };
  }

  // Save case to database
  async saveCase(caseData, cyPresInfo) {
    const sql = `
      INSERT INTO legal_cases
      (case_name, case_number, court, filed_date, docket_url, opinion_url,
       judge, cy_pres_amount, recipients, is_class_action, case_summary, full_text)
      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
      ON CONFLICT (case_number, court)
      DO UPDATE SET
        case_name = EXCLUDED.case_name,
        opinion_url = EXCLUDED.opinion_url,
        cy_pres_amount = EXCLUDED.cy_pres_amount,
        recipients = EXCLUDED.recipients,
        updated_at = CURRENT_TIMESTAMP
      RETURNING *
    `;

    const values = [
      caseData.case_name || caseData.caseName,
      caseData.docket_number || caseData.docketNumber || 'UNKNOWN',
      caseData.court || 'UNKNOWN',
      caseData.date_filed || caseData.dateFiled || new Date().toISOString(),
      caseData.docket_url || null,
      caseData.absolute_url || caseData.download_url || null,
      caseData.judge || null,
      cyPresInfo.amounts.length > 0 ? cyPresInfo.amounts.join(', ') : null,
      JSON.stringify(cyPresInfo.recipients),
      cyPresInfo.isClassAction,
      caseData.snippet || null,
      caseData.plain_text || null
    ];

    try {
      const result = await query(sql, values);
      console.log(`✓ Saved case: ${caseData.case_name}`);
      return result.rows[0];
    } catch (error) {
      console.error(`Error saving case ${caseData.case_name}:`, error.message);
      return null;
    }
  }

  // Main scraping function
  async scrapeAllCyPresCases(options = {}) {
    console.log('🚀 Starting CourtListener Cy Pres Case Scraper\n');

    const {
      maxCases = 1000,
      dateAfter = '2010-01-01',
      batchSize = 20
    } = options;

    let totalProcessed = 0;
    let totalSaved = 0;
    let offset = 0;

    while (totalProcessed < maxCases) {
      // Search for cases
      const searchResults = await this.searchCyPresCases({
        dateAfter,
        limit: batchSize,
        offset
      });

      if (!searchResults || searchResults.results.length === 0) {
        console.log('No more results found.');
        break;
      }

      // Process each case
      for (const caseResult of searchResults.results) {
        totalProcessed++;

        console.log(`\nProcessing case ${totalProcessed}/${Math.min(maxCases, searchResults.count)}`);
        console.log(`  Case: ${caseResult.caseName || caseResult.case_name}`);
        console.log(`  Court: ${caseResult.court}`);
        console.log(`  Date: ${caseResult.dateFiled || caseResult.date_filed}`);

        // Extract cy pres info
        const cyPresInfo = this.extractCyPresInfo(
          caseResult.text || caseResult.plain_text || '',
          caseResult
        );

        if (cyPresInfo.containsCyPres) {
          console.log(`  ✓ Contains cy pres language`);
          if (cyPresInfo.recipients.length > 0) {
            console.log(`  ✓ Found ${cyPresInfo.recipients.length} potential recipients`);
          }
          if (cyPresInfo.amounts.length > 0) {
            console.log(`  ✓ Found ${cyPresInfo.amounts.length} monetary amounts`);
          }

          // Save to database
          const saved = await this.saveCase(caseResult, cyPresInfo);
          if (saved) {
            totalSaved++;
          }
        } else {
          console.log(`  ⊘ No cy pres content found (false positive)`);
        }

        // Delay to be respectful
        await new Promise(resolve => setTimeout(resolve, this.delay));

        if (totalProcessed >= maxCases) {
          break;
        }
      }

      offset += batchSize;
      console.log(`\n=== Progress: ${totalProcessed} processed, ${totalSaved} saved ===\n`);
    }

    console.log('\n=== Scraping Complete ===');
    console.log(`Total cases processed: ${totalProcessed}`);
    console.log(`Cases with cy pres content saved: ${totalSaved}`);
    console.log(`Success rate: ${((totalSaved / totalProcessed) * 100).toFixed(1)}%`);

    return {
      totalProcessed,
      totalSaved
    };
  }
}

// Main execution
if (require.main === module) {
  const apiToken = process.env.COURTLISTENER_API_TOKEN;

  if (!apiToken) {
    console.log('⚠️  No COURTLISTENER_API_TOKEN found in .env');
    console.log('   You can still use the API but with rate limits.');
    console.log('   Get a free token at: https://www.courtlistener.com/sign-in/\n');
  }

  const scraper = new CourtListenerScraper(apiToken);

  const args = process.argv.slice(2);
  const isTest = args.includes('--test');

  if (isTest) {
    console.log('Running in TEST mode (20 cases)...\n');
    scraper.scrapeAllCyPresCases({
      maxCases: 20,
      dateAfter: '2020-01-01'
    })
      .then(() => {
        console.log('\nTest complete. Exiting...');
        process.exit(0);
      })
      .catch(error => {
        console.error('Error:', error);
        process.exit(1);
      });
  } else {
    console.log('Running in PRODUCTION mode (1000 cases)...\n');
    scraper.scrapeAllCyPresCases({
      maxCases: 1000,
      dateAfter: '2010-01-01'
    })
      .then(() => {
        console.log('\nProduction scraping complete. Exiting...');
        process.exit(0);
      })
      .catch(error => {
        console.error('Error:', error);
        process.exit(1);
      });
  }
}

module.exports = CourtListenerScraper;