← back to Norma

agents/moveon-agent/lib/moveon.js

305 lines

/**
 * MoveOn.org Helper Functions
 *
 * HTTP-based petition discovery, detail extraction, and draft preparation.
 * Uses cheerio for HTML parsing of MoveOn.org pages.
 */

const fetch = require('node-fetch');
const cheerio = require('cheerio');

const MOVEON_BASE = 'https://sign.moveon.org';
const MOVEON_SEARCH = 'https://front.moveon.org/petitions/';

const USER_AGENT =
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';

/**
 * Search MoveOn.org for petitions matching a query.
 *
 * @param {string} query - Search terms (e.g. 'civic advocacy')
 * @param {number} [maxResults=10] - Maximum number of results to return
 * @returns {Promise<Array<{ title: string, url: string, snippet: string, signatures: number|null }>>}
 */
async function searchPetitions(query, maxResults = 10) {
  const results = [];

  try {
    // MoveOn uses sign.moveon.org for petitions
    // Try searching via their public petition listing
    const searchUrl = `${MOVEON_BASE}/find/?q=${encodeURIComponent(query)}`;

    const response = await fetch(searchUrl, {
      headers: {
        'User-Agent': USER_AGENT,
        Accept: 'text/html,application/xhtml+xml',
      },
      timeout: 15000,
      redirect: 'follow',
    });

    if (!response.ok) {
      console.warn(
        `[moveon] Search returned ${response.status} for query: "${query}"`
      );
      // Fallback: try Google site search
      return await _googleSiteSearch(query, maxResults);
    }

    const html = await response.text();
    const $ = cheerio.load(html);

    // Parse petition listings from the search results page
    // MoveOn petition cards typically have title links and signature counts
    $('a[href*="/sign/"]').each((i, el) => {
      if (results.length >= maxResults) return false;

      const $el = $(el);
      const title = $el.text().trim();
      const href = $el.attr('href');

      if (!title || !href || title.length < 5) return;

      const url = href.startsWith('http')
        ? href
        : `${MOVEON_BASE}${href}`;

      // Try to find signature count near the link
      const parent = $el.closest('.petition-card, .petition-item, article, li, div');
      const sigText = parent.text().match(/([\d,]+)\s*(?:signatures?|supporters?|signed)/i);
      const signatures = sigText ? parseInt(sigText[1].replace(/,/g, ''), 10) : null;

      // Extract snippet from nearby text
      const snippet = parent
        .find('p, .description, .snippet')
        .first()
        .text()
        .trim()
        .substring(0, 200);

      results.push({
        title: title.substring(0, 200),
        url,
        snippet: snippet || '',
        signatures,
      });
    });

    // If no results from direct parsing, try a broader selector
    if (results.length === 0) {
      $('h2, h3, h4').each((i, el) => {
        if (results.length >= maxResults) return false;

        const $el = $(el);
        const link = $el.find('a').first();
        const title = (link.text() || $el.text()).trim();
        const href = link.attr('href') || '';

        if (!title || title.length < 5) return;
        if (!href.includes('sign') && !href.includes('petition')) return;

        const url = href.startsWith('http')
          ? href
          : `${MOVEON_BASE}${href}`;

        results.push({
          title: title.substring(0, 200),
          url,
          snippet: '',
          signatures: null,
        });
      });
    }

    console.log(
      `[moveon] Search for "${query}" returned ${results.length} results`
    );
    return results;
  } catch (err) {
    console.error(`[moveon] searchPetitions error:`, err.message);
    // Fallback to Google site search
    return await _googleSiteSearch(query, maxResults);
  }
}

/**
 * Fallback: use a simple approach to find MoveOn petitions.
 * Constructs likely URLs and checks them.
 *
 * @param {string} query
 * @param {number} maxResults
 * @returns {Promise<Array>}
 */
async function _googleSiteSearch(query, maxResults) {
  // Return empty — we don't want to scrape Google
  // This is a stub for when MoveOn's own search doesn't work
  console.log(
    `[moveon] Fallback search for "${query}" — returning empty (no Google scraping)`
  );
  return [];
}

/**
 * Fetch a petition page and extract details.
 *
 * @param {string} url - Full URL to a MoveOn petition
 * @returns {Promise<{ title: string, body: string, target: string, signatures: number, goal: number|null, url: string, creator: string|null, category: string|null }>}
 */
async function getPetitionDetails(url) {
  try {
    const response = await fetch(url, {
      headers: {
        'User-Agent': USER_AGENT,
        Accept: 'text/html,application/xhtml+xml',
      },
      timeout: 15000,
      redirect: 'follow',
    });

    if (!response.ok) {
      throw new Error(`Petition page returned ${response.status}: ${url}`);
    }

    const html = await response.text();
    const $ = cheerio.load(html);

    // Extract petition title
    const title =
      $('h1').first().text().trim() ||
      $('meta[property="og:title"]').attr('content') ||
      'Unknown Petition';

    // Extract petition body
    const body =
      $('.petition-text, .petition-body, .petition-description, [class*="petition"]')
        .first()
        .text()
        .trim() ||
      $('meta[property="og:description"]').attr('content') ||
      '';

    // Extract target
    const target =
      $('.petition-target, .to-target, [class*="target"]')
        .first()
        .text()
        .trim()
        .replace(/^To:\s*/i, '') ||
      '';

    // Extract signature count
    let signatures = 0;
    const sigElements = $(
      '.signature-count, .signatures, [class*="signature"], [class*="supporter"]'
    );
    sigElements.each((i, el) => {
      const text = $(el).text();
      const match = text.match(/([\d,]+)/);
      if (match) {
        const num = parseInt(match[1].replace(/,/g, ''), 10);
        if (num > signatures) signatures = num;
      }
    });

    // Fallback: search all text for signature pattern
    if (signatures === 0) {
      const fullText = $('body').text();
      const sigMatch = fullText.match(
        /([\d,]+)\s*(?:signatures?|supporters?|people?\s+(?:have\s+)?signed)/i
      );
      if (sigMatch) {
        signatures = parseInt(sigMatch[1].replace(/,/g, ''), 10);
      }
    }

    // Extract goal
    let goal = null;
    const goalMatch = $('body')
      .text()
      .match(/goal[:\s]*([\d,]+)/i);
    if (goalMatch) {
      goal = parseInt(goalMatch[1].replace(/,/g, ''), 10);
    }

    // Extract creator
    const creator =
      $('.petition-author, .created-by, [class*="author"]')
        .first()
        .text()
        .trim()
        .replace(/^(?:by|created\s+by|started\s+by)\s*/i, '') || null;

    // Extract category from meta tags or page structure
    const category =
      $('meta[name="category"]').attr('content') ||
      $('[class*="category"]').first().text().trim() ||
      null;

    console.log(
      `[moveon] Fetched petition details: "${title}" (${signatures} signatures)`
    );

    return {
      title,
      body: body.substring(0, 5000),
      target,
      signatures,
      goal,
      url,
      creator,
      category,
    };
  } catch (err) {
    console.error(`[moveon] getPetitionDetails error for ${url}:`, err.message);
    throw err;
  }
}

/**
 * Prepare a petition draft formatted for MoveOn submission.
 * Returns structured data matching MoveOn form fields.
 *
 * @param {Object} data
 * @param {string} data.title - Petition title
 * @param {string} data.body - Petition body text
 * @param {string} data.target - Who the petition is addressed to
 * @param {string} [data.category='Education'] - Petition category
 * @param {string} [data.creatorName] - Creator name
 * @param {string} [data.creatorEmail] - Creator email
 * @returns {{ formFields: Object, previewUrl: string, instructions: string }}
 */
function preparePetitionDraft(data) {
  const formFields = {
    title: (data.title || '').substring(0, 120),
    statement: data.body || '',
    target: data.target || 'U.S. Department of Education',
    category: data.category || 'Education',
    creator_name: data.creatorName || '',
    creator_email: data.creatorEmail || '',
  };

  // Build the MoveOn petition creation URL with pre-filled params
  const params = new URLSearchParams();
  if (formFields.title) params.set('title', formFields.title);
  if (formFields.target) params.set('target', formFields.target);

  const previewUrl = `${MOVEON_BASE}/create/?${params.toString()}`;

  const instructions = [
    '1. Go to the MoveOn petition creation page: ' + previewUrl,
    '2. Fill in the petition title: "' + formFields.title + '"',
    '3. Set the target: "' + formFields.target + '"',
    '4. Paste the petition body text',
    '5. Select category: "' + formFields.category + '"',
    '6. Review and submit',
  ].join('\n');

  return {
    formFields,
    previewUrl,
    instructions,
  };
}

module.exports = { searchPetitions, getPetitionDetails, preparePetitionDraft };