← 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 };