Return

All tests / src/pages/api news.ts

82.97% Statements 39/47
73.07% Branches 19/26
100% Functions 4/4
82.6% Lines 38/46

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123    1x 1x 1x 1x                               1x     1x                     1x 2x   2x 2x 2x 2x 2x   2x       2x             2x 2x       2x 2x     2x 2x   2x     2x 2x 2x                 2x   2x                     3x   3x       3x 3x     3x       3x 1x     2x 2x 2x 2x              
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next';
import jsdom from 'jsdom';
import allowCors from '../../helpers/cors';
import { CACHE, fetchWithTimeout } from '@/helpers/http';
import { isValidCityName } from '../../helpers/city';
 
interface NewsItem {
    title: string;
    description: string;
    link: string;
    published: string;
}
 
interface NewsData {
    city: string;
    news: NewsItem[];
}
 
type NewsResponse = NewsData | { error: string };
 
const MAX_NEWS_ITEMS = 10;
 
/** Google News RSS for one city runs well under 100 KB; 2 MB is a generous ceiling, not a target. */
const MAX_FEED_BYTES = 2 * 1024 * 1024;
 
/**
 * @description Get the latest news for a city from the Google News RSS feed.
 * Replaces the old Google SERP scraper (hardcoded CSS selectors) that was
 * blocked by bot detection and silently returned an empty list (SDD-001).
 *
 * @param city {string} - City as sent by the UI, e.g. "limerick+ireland"
 * @returns Promise<NewsData>
 * @example getCityNews('limerick+ireland')
 */
const getCityNews = async (city: string): Promise<NewsData> => {
    const query = city.replace(/\+/g, ' ').trim();
 
    const url = new URL('https://news.google.com/rss/search');
    url.searchParams.set('q', query);
    url.searchParams.set('hl', 'en-US');
    url.searchParams.set('gl', 'US');
    url.searchParams.set('ceid', 'US:en');
 
    const response = await fetchWithTimeout(url.toString(), {
        headers: { accept: 'application/rss+xml, application/xml, text/xml' },
        redirect: 'follow',
    });
    Iif (!response.ok) {
        throw new Error(`News RSS HTTP error! status: ${response.status}`);
    }
 
    // Cap the body before it reaches JSDOM. `response.text()` was unbounded and fed straight into an
    // XML parse, so a large or slow feed turned each request into a multi-second CPU-and-memory burn —
    // a cheap path to function timeouts once someone noticed the endpoint was uncached.
    const declaredLength = Number(response.headers.get('content-length') ?? 0);
    Iif (declaredLength > MAX_FEED_BYTES) {
        throw new Error(`News RSS response too large: ${declaredLength} bytes`);
    }
 
    const raw = await response.text();
    Iif (raw.length > MAX_FEED_BYTES) {
        throw new Error(`News RSS response too large: ${raw.length} bytes`);
    }
    const { JSDOM } = jsdom;
    const document = new JSDOM(raw, { contentType: 'text/xml' }).window.document;
 
    const news = Array.from(document.querySelectorAll('item'))
        .slice(0, MAX_NEWS_ITEMS)
        .map((item) => {
            const pubDate = item.querySelector('pubDate')?.textContent?.trim() ?? '';
            const parsedDate = pubDate ? new Date(pubDate) : null;
            return {
                title: item.querySelector('title')?.textContent?.trim() ?? '',
                // The RSS <description> just repeats the headline as HTML; the outlet name is more useful
                description: item.querySelector('source')?.textContent?.trim() ?? '',
                link: item.querySelector('link')?.textContent?.trim() ?? '',
                published:
                    parsedDate && !Number.isNaN(parsedDate.getTime()) ? parsedDate.toISOString().slice(0, 10) : pubDate,
            };
        })
        .filter((item) => item.title && item.link);
 
    return { city, news };
};
 
/**
 *
 * @description Get news for a city
 * @param req {NextApiRequest}
 * @param res {NextApiResponse}
 * @returns Promise<void>
 * @example http://localhost:3000/api/news?city=London
 */
export default allowCors(async function handler(req: NextApiRequest, res: NextApiResponse<NewsResponse>) {
    // Only allow GET requests
    Iif (req.method !== 'GET') {
        return res.status(405).json({ error: 'Method not allowed' });
    }
 
    const { query } = req;
    const { city } = query;
 
    // Validate city parameter
    Iif (!city || typeof city !== 'string') {
        return res.status(400).json({ error: 'City parameter is required and must be a string' });
    }
 
    if (!isValidCityName(city)) {
        return res.status(400).json({ error: 'Invalid city name format' });
    }
 
    try {
        const data = await getCityNews(city);
        res.setHeader('Cache-Control', CACHE.news);
        res.status(200).json(data);
    } catch (error) {
        console.error('News API Error:', error);
        res.setHeader('Cache-Control', CACHE.error);
        res.status(500).json({ error: 'Internal server error' });
    }
});