← back to Cypress Awards

backend/src/scraper/cypresFinder.js

319 lines

const axios = require('axios');
const cheerio = require('cheerio');
const puppeteer = require('puppeteer');
const robotsParser = require('robots-parser');

class CyPresFinder {
  constructor(options = {}) {
    this.delay = options.delay || 2000;
    this.userAgent = options.userAgent || 'CyPresAwards Bot/1.0';
    this.timeout = options.timeout || 30000;
  }

  // Common cy pres URL patterns
  getCyPresUrlPatterns(baseUrl) {
    const domain = new URL(baseUrl).origin;
    return [
      `${domain}/cy-pres`,
      `${domain}/cy-pres/`,
      `${domain}/cypres`,
      `${domain}/cypres/`,
      `${domain}/cy-pres-awards`,
      `${domain}/legal/cy-pres`,
      `${domain}/awards/cy-pres`,
      `${domain}/support/cy-pres`,
      `${domain}/donate/cy-pres`,
      `${domain}/about/cy-pres`,
    ];
  }

  // Check robots.txt before scraping
  async checkRobotsTxt(url) {
    try {
      const domain = new URL(url).origin;
      const robotsUrl = `${domain}/robots.txt`;
      const response = await axios.get(robotsUrl, { timeout: 5000 });
      const robots = robotsParser(robotsUrl, response.data);
      return robots.isAllowed(url, this.userAgent);
    } catch (error) {
      // If robots.txt doesn't exist, assume allowed
      return true;
    }
  }

  // Try to find cy pres page by checking common patterns
  async findCyPresPage(baseUrl) {
    const patterns = this.getCyPresUrlPatterns(baseUrl);

    for (const url of patterns) {
      try {
        await this.sleep(this.delay);

        const response = await axios.get(url, {
          headers: { 'User-Agent': this.userAgent },
          timeout: this.timeout,
          validateStatus: (status) => status === 200,
        });

        if (response.status === 200) {
          // Check if page actually contains cy pres content
          const $ = cheerio.load(response.data);
          const text = $('body').text().toLowerCase();

          if (this.containsCyPresKeywords(text)) {
            console.log(`Found cy pres page: ${url}`);
            return { url, html: response.data };
          }
        }
      } catch (error) {
        // Page doesn't exist, continue to next pattern
        continue;
      }
    }

    // If direct URLs don't work, try searching the site
    return await this.searchSiteForCyPres(baseUrl);
  }

  // Search entire site for cy pres mentions
  async searchSiteForCyPres(baseUrl) {
    try {
      await this.sleep(this.delay);

      const response = await axios.get(baseUrl, {
        headers: { 'User-Agent': this.userAgent },
        timeout: this.timeout,
      });

      const $ = cheerio.load(response.data);

      // Look for links containing cy pres keywords
      const links = [];
      $('a[href]').each((i, elem) => {
        const href = $(elem).attr('href');
        const text = $(elem).text().toLowerCase();

        if (href && (
          href.includes('cy-pres') ||
          href.includes('cypres') ||
          text.includes('cy pres') ||
          text.includes('cy-pres')
        )) {
          let fullUrl = href;
          if (!href.startsWith('http')) {
            const domain = new URL(baseUrl).origin;
            fullUrl = href.startsWith('/') ? `${domain}${href}` : `${domain}/${href}`;
          }
          links.push(fullUrl);
        }
      });

      // Try each found link
      for (const link of links) {
        try {
          await this.sleep(this.delay);
          const linkResponse = await axios.get(link, {
            headers: { 'User-Agent': this.userAgent },
            timeout: this.timeout,
          });

          const linkHtml = linkResponse.data;
          const $link = cheerio.load(linkHtml);
          const linkText = $link('body').text().toLowerCase();

          if (this.containsCyPresKeywords(linkText)) {
            console.log(`Found cy pres page via search: ${link}`);
            return { url: link, html: linkHtml };
          }
        } catch (error) {
          continue;
        }
      }

      return null;
    } catch (error) {
      console.error(`Error searching site ${baseUrl}:`, error.message);
      return null;
    }
  }

  // Check if text contains cy pres-related keywords
  containsCyPresKeywords(text) {
    const keywords = [
      'cy pres',
      'cy-pres',
      'cypres',
      'class action settlement',
      'unclaimed settlement funds',
      'residual funds',
      'settlement distribution',
      'class action award'
    ];

    let matchCount = 0;
    for (const keyword of keywords) {
      if (text.includes(keyword)) {
        matchCount++;
      }
    }

    // Must have at least 2 keyword matches to be considered a cy pres page
    return matchCount >= 2;
  }

  // Extract organization details from page
  async extractOrgDetails(html, url) {
    const $ = cheerio.load(html);

    // Extract mission statement
    let mission = '';
    const missionSelectors = [
      'meta[name="description"]',
      '.mission',
      '#mission',
      '[class*="mission"]',
      '.about',
      '#about'
    ];

    for (const selector of missionSelectors) {
      const element = $(selector).first();
      if (element.length) {
        mission = selector.startsWith('meta')
          ? element.attr('content')
          : element.text().trim();
        if (mission && mission.length > 50) {
          break;
        }
      }
    }

    // Extract logo
    let logo = '';
    const logoSelectors = [
      'img[alt*="logo" i]',
      'img[class*="logo" i]',
      'img[id*="logo" i]',
      '.logo img',
      '#logo img',
      'header img:first'
    ];

    for (const selector of logoSelectors) {
      const element = $(selector).first();
      if (element.length) {
        logo = element.attr('src');
        if (logo && !logo.startsWith('http')) {
          const domain = new URL(url).origin;
          logo = logo.startsWith('/') ? `${domain}${logo}` : `${domain}/${logo}`;
        }
        if (logo) break;
      }
    }

    // Extract organization name
    let name = '';
    const nameSelectors = [
      'meta[property="og:site_name"]',
      'meta[name="application-name"]',
      'title',
      'h1:first',
      '.site-title',
      '#site-title'
    ];

    for (const selector of nameSelectors) {
      const element = $(selector).first();
      if (element.length) {
        name = selector.startsWith('meta')
          ? element.attr('content')
          : element.text().trim();
        if (name) break;
      }
    }

    return {
      name,
      mission: mission.substring(0, 1000), // Limit length
      logo
    };
  }

  // Extract cy pres statement text
  extractCyPresStatement(html) {
    const $ = cheerio.load(html);

    // Remove script and style elements
    $('script, style, nav, footer, header').remove();

    // Get main content
    const mainSelectors = [
      'main',
      '[role="main"]',
      '.main-content',
      '#main-content',
      'article',
      '.content',
      '#content'
    ];

    let statementText = '';
    for (const selector of mainSelectors) {
      const element = $(selector).first();
      if (element.length) {
        statementText = element.text().trim();
        if (statementText.length > 200) {
          break;
        }
      }
    }

    // If no main content found, get body text
    if (!statementText || statementText.length < 200) {
      statementText = $('body').text().trim();
    }

    // Clean up whitespace
    statementText = statementText.replace(/\s+/g, ' ').trim();

    // Extract page title
    const pageTitle = $('title').text().trim() || $('h1').first().text().trim();

    return {
      statement_text: statementText.substring(0, 5000), // Limit length
      page_title: pageTitle.substring(0, 500),
      html_content: html
    };
  }

  // Sleep helper
  sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  // Use Puppeteer for JavaScript-heavy sites
  async scrapeDynamicSite(url) {
    let browser;
    try {
      browser = await puppeteer.launch({
        headless: 'new',
        args: ['--no-sandbox', '--disable-setuid-sandbox']
      });

      const page = await browser.newPage();
      await page.setUserAgent(this.userAgent);
      await page.goto(url, { waitUntil: 'networkidle2', timeout: this.timeout });

      const html = await page.content();
      await browser.close();

      return html;
    } catch (error) {
      if (browser) await browser.close();
      throw error;
    }
  }
}

module.exports = CyPresFinder;