← 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(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/ /g, ' ')
.replace(/'/g, "'")
.replace(///g, '/')
.replace(/—/g, '—')
.replace(/–/g, '–')
.replace(/’/g, '\u2019')
.replace(/‘/g, '\u2018')
.replace(/”/g, '\u201D')
.replace(/“/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.';
}