← back to Norma

agents/datasource-agent/skills/scrape-frontpages.js

258 lines

/**
 * Scrape Frontpages Skill
 *
 * Scrapes newspaper RSS feeds for today's headlines, parses them,
 * and uses Gemini 2.0 Flash to analyze topics, sentiment, and advocacy relevance.
 * Results are stored in the newspaper_frontpages table.
 */

const fetch = require('node-fetch');
const { logAction } = require('../../shared/audit-logger');
const { ALL_NEWSPAPERS } = require('../lib/newspaper-registry');
const { parseRSS } = require('../lib/frontpage-parser');

const AGENT = 'datasource-agent';
const PLATFORM = 'newspapers';

const GEMINI_URL =
  'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GOOGLE_API_KEY}';

const DB_URL = process.env.DATABASE_URL || 'postgresql://dw_admin@127.0.0.1:5432/sdcc';

let _pool;
function getPool() {
  if (!_pool) {
    const { Pool } = require('pg');
    _pool = new Pool({ connectionString: DB_URL, max: 5 });
  }
  return _pool;
}

/**
 * Fetch and parse an RSS feed with timeout.
 *
 * @param {string} url - RSS feed URL
 * @returns {Promise<Array>} Parsed items
 */
async function fetchFeed(url) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 15000);
  try {
    const res = await fetch(url, {
      signal: controller.signal,
      headers: {
        'User-Agent': 'Norma-DataSource-Agent/1.0 (research; non-commercial)',
        'Accept': 'application/rss+xml, application/xml, text/xml, */*',
      },
    });
    clearTimeout(timeout);
    if (!res.ok) return [];
    const xml = await res.text();
    return parseRSS(xml);
  } catch (err) {
    clearTimeout(timeout);
    return [];
  }
}

/**
 * Analyze headlines with Gemini for advocacy relevance.
 *
 * @param {string} newspaperName - Name of the newspaper
 * @param {Array} headlines - Array of headline objects
 * @returns {Promise<{ topics: string[], sentiment: string, relevance_score: number, top_headline: string }>}
 */
async function geminiAnalyzeHeadlines(newspaperName, headlines) {
  if (!headlines || headlines.length === 0) {
    return { topics: [], sentiment: 'neutral', relevance_score: 0, top_headline: '' };
  }

  const headlineTexts = headlines.slice(0, 20).map((h, i) => `${i + 1}. ${h.title}`).join('\n');

  try {
    const prompt = `Analyze these headlines from ${newspaperName} for relevance to nonprofit advocacy, education policy, and economic inequality.

Headlines:
${headlineTexts}

Respond in EXACTLY this JSON format (no markdown, no code fences):
{"topics": ["topic1", "topic2"], "sentiment": "positive|negative|neutral|mixed", "relevance_score": 0-100, "top_headline": "most relevant headline text or first headline if none relevant", "reasoning": "brief explanation"}

Score 0 if no headlines relate to education/advocacy/economy. Score higher for direct nonprofit or civic advocacy mentions.`;

    const res = await fetch(GEMINI_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        contents: [{ parts: [{ text: prompt }] }],
        generationConfig: { temperature: 0.2, maxOutputTokens: 500 },
      }),
    });

    const data = await res.json();
    const text = data?.candidates?.[0]?.content?.parts?.[0]?.text || '';

    const jsonMatch = text.match(/\{[\s\S]*\}/);
    if (jsonMatch) {
      const parsed = JSON.parse(jsonMatch[0]);
      return {
        topics: Array.isArray(parsed.topics) ? parsed.topics : [],
        sentiment: parsed.sentiment || 'neutral',
        relevance_score: Math.min(100, Math.max(0, Number(parsed.relevance_score) || 0)),
        top_headline: (parsed.top_headline || headlines[0]?.title || '').substring(0, 500),
      };
    }

    return {
      topics: [],
      sentiment: 'neutral',
      relevance_score: 0,
      top_headline: headlines[0]?.title || '',
    };
  } catch (err) {
    console.error(`[${AGENT}] Gemini analysis error for ${newspaperName}:`, err.message);
    return {
      topics: [],
      sentiment: 'neutral',
      relevance_score: 0,
      top_headline: headlines[0]?.title || '',
    };
  }
}

/**
 * Insert/update frontpage data in the database.
 *
 * @param {Object} newspaper - Newspaper registry entry
 * @param {Array} headlines - Parsed headline items
 * @param {Object} analysis - Gemini analysis result
 * @returns {Promise<Object|null>}
 */
async function upsertFrontpage(newspaper, headlines, analysis) {
  const pool = getPool();
  try {
    const { rows } = await pool.query(
      `INSERT INTO newspaper_frontpages
        (newspaper_name, newspaper_url, country, region, frontpage_url, headlines, top_headline, scraped_date, analysis, relevance_score, match_topics, source_type)
       VALUES ($1, $2, $3, $4, $5, $6, $7, CURRENT_DATE, $8, $9, $10, $11)
       ON CONFLICT (newspaper_name, scraped_date) DO UPDATE SET
         headlines = EXCLUDED.headlines,
         top_headline = EXCLUDED.top_headline,
         analysis = EXCLUDED.analysis,
         relevance_score = EXCLUDED.relevance_score,
         match_topics = EXCLUDED.match_topics
       RETURNING id, newspaper_name, relevance_score`,
      [
        newspaper.name,
        newspaper.url,
        newspaper.country,
        newspaper.region || null,
        newspaper.url,
        JSON.stringify(headlines.slice(0, 30)),
        analysis.top_headline,
        JSON.stringify({ topics: analysis.topics, sentiment: analysis.sentiment }),
        analysis.relevance_score,
        analysis.topics,
        newspaper.source_type,
      ]
    );
    return rows[0] || null;
  } catch (err) {
    console.error(`[${AGENT}] DB upsert error for "${newspaper.name}":`, err.message);
    return null;
  }
}

/**
 * Main scrape-frontpages skill handler.
 *
 * @param {Object} body - Request body
 * @param {boolean} [body.analyze=true] - Whether to AI-analyze headlines
 * @param {number} [body.max_papers] - Limit number of papers to scrape
 * @param {string} [body.source_type] - Filter: 'domestic' or 'international'
 * @returns {Promise<Object>} Scrape results
 */
module.exports = async function scrapeFrontpages(body = {}) {
  const shouldAnalyze = body.analyze !== false;
  const sourceTypeFilter = body.source_type || null;

  let papers = ALL_NEWSPAPERS;
  if (sourceTypeFilter) {
    papers = papers.filter((p) => p.source_type === sourceTypeFilter);
  }
  if (body.max_papers) {
    papers = papers.slice(0, body.max_papers);
  }

  let scraped = 0;
  let relevant = 0;
  let failed = 0;
  const relevantPapers = [];

  for (const paper of papers) {
    console.log(`[${AGENT}] Scraping RSS: ${paper.name}...`);

    const headlines = await fetchFeed(paper.url);

    if (headlines.length === 0) {
      failed++;
      continue;
    }

    let analysis = {
      topics: [],
      sentiment: 'neutral',
      relevance_score: 0,
      top_headline: headlines[0]?.title || '',
    };

    if (shouldAnalyze) {
      analysis = await geminiAnalyzeHeadlines(paper.name, headlines);
      // Small delay to avoid Gemini rate limits
      await new Promise((r) => setTimeout(r, 250));
    }

    const result = await upsertFrontpage(paper, headlines, analysis);
    if (result) {
      scraped++;
      if (analysis.relevance_score >= 30) {
        relevant++;
        relevantPapers.push({
          name: paper.name,
          country: paper.country,
          score: analysis.relevance_score,
          top_headline: analysis.top_headline,
          topics: analysis.topics,
        });
      }
    }
  }

  // Sort by relevance
  relevantPapers.sort((a, b) => b.score - a.score);

  await logAction({
    agent: AGENT,
    actionType: 'scrape',
    platform: PLATFORM,
    content: `Scraped ${scraped} newspaper RSS feeds`,
    responseData: {
      total_papers: papers.length,
      scraped,
      relevant,
      failed,
      top_relevant: relevantPapers.slice(0, 5),
    },
    status: 'success',
  });

  return {
    scraped,
    relevant,
    failed,
    total_papers: papers.length,
    relevant_papers: relevantPapers.slice(0, 10),
    fetched_at: new Date().toISOString(),
  };
};