← back to Norma

app/api/news/fetch-article/route.ts

103 lines

import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/require-role';

/**
 * GET /api/news/fetch-article?url=...
 * Fetch and extract readable text from a news article URL.
 */
export async function GET(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  const url = request.nextUrl.searchParams.get('url');
  if (!url) {
    return NextResponse.json({ error: 'url parameter is required' }, { status: 400 });
  }

  try {
    const res = await fetch(url, {
      headers: {
        'User-Agent': 'Mozilla/5.0 (compatible; Norma-ArticleReader/1.0)',
        'Accept': 'text/html,application/xhtml+xml',
      },
      signal: AbortSignal.timeout(15000),
    });

    if (!res.ok) {
      return NextResponse.json({ content: `Failed to fetch article (HTTP ${res.status})` }, { status: 200 });
    }

    const html = await res.text();

    // Extract readable text from HTML
    const content = extractReadableText(html);

    return NextResponse.json({ content, url });
  } catch (err) {
    console.error('[api/news/fetch-article] error:', (err as Error).message);
    return NextResponse.json({ content: 'Could not fetch article. The site may block automated access.' }, { status: 200 });
  }
}

/**
 * Simple HTML-to-text extractor focusing on article content.
 * Prioritizes <article>, <main>, then falls back to <body>.
 */
function extractReadableText(html: string): string {
  // Try to find article or main content
  let content = '';

  // Extract from <article> tag first
  const articleMatch = html.match(/<article[^>]*>([\s\S]*?)<\/article>/i);
  if (articleMatch) {
    content = articleMatch[1];
  } else {
    // Try <main>
    const mainMatch = html.match(/<main[^>]*>([\s\S]*?)<\/main>/i);
    if (mainMatch) {
      content = mainMatch[1];
    } else {
      // Fall back to body
      const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
      content = bodyMatch ? bodyMatch[1] : html;
    }
  }

  // Remove script, style, nav, footer, header, aside tags
  content = content.replace(/<(script|style|nav|footer|header|aside|noscript|iframe|svg)[^>]*>[\s\S]*?<\/\1>/gi, '');

  // Remove all HTML tags but preserve paragraph breaks
  content = content.replace(/<\/?(p|div|br|h[1-6]|li|blockquote|section)[^>]*>/gi, '\n');
  content = content.replace(/<[^>]+>/g, '');

  // Decode common HTML entities
  content = content
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/&nbsp;/g, ' ')
    .replace(/&#x27;/g, "'")
    .replace(/&#x2F;/g, '/')
    .replace(/&mdash;/g, '—')
    .replace(/&ndash;/g, '–')
    .replace(/&rsquo;/g, '\u2019')
    .replace(/&lsquo;/g, '\u2018')
    .replace(/&rdquo;/g, '\u201D')
    .replace(/&ldquo;/g, '\u201C');

  // Clean up whitespace
  content = content.replace(/[ \t]+/g, ' ');
  content = content.replace(/\n[ \t]+/g, '\n');
  content = content.replace(/\n{3,}/g, '\n\n');
  content = content.trim();

  // Limit to reasonable length
  if (content.length > 8000) {
    content = content.slice(0, 8000) + '\n\n[Article truncated — open original for full text]';
  }

  return content || 'Could not extract article content from this page.';
}