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' });
}
});
|