Aggregate RSS feeds into a reading sheet
Pull Northwind reading from many sources into one sheet — dedupe, mark read.
Published Sep 9, 2025
Northwind’s team follows a dozen industry blogs and news sites, and keeping up means opening a dozen tabs. Articles get read twice or missed entirely, and there is no shared sense of what is worth a look this week. A reading list scattered across browser bookmarks is not a reading list.
This script pulls them all into one place. It fetches each RSS feed, reads the recent items, skips anything already logged, and appends new articles to a single Google Sheet. The team gets one tab — every source, deduplicated, with a column they can tick once they have read something.
What you’ll need
- A Google Sheet — the reading list. Give it headers in row 1:
Added,Link,Title,Source. Optionally add aReadcolumn to tick off articles by hand. - The RSS or Atom feed URLs you want to follow. Most blogs expose one at
/rss,/feedor/atom.xml.
The script
// The spreadsheet that holds the reading list.
const READING_SHEET_ID = '1abcReadingId';
// RSS/Atom feed URLs to aggregate.
const FEEDS = [
'https://blog.example.com/rss',
'https://news.example.org/feed',
];
// How many recent items to read from each feed per run.
const ITEMS_PER_FEED = 20;
/**
* Fetches every feed, drops items already in the sheet, and appends
* any new articles to the reading list.
*/
function aggregateFeeds() {
const sheet = SpreadsheetApp.openById(READING_SHEET_ID).getSheets()[0];
// 1. Build a set of links already logged, so we never add a duplicate.
const known = new Set(
sheet.getRange('B2:B').getValues().flat().filter(Boolean)
);
let added = 0;
for (const feedUrl of FEEDS) {
// 2. Fetch the feed XML. muteHttpExceptions stops one bad feed
// from aborting the whole run.
const response = UrlFetchApp.fetch(feedUrl, { muteHttpExceptions: true });
if (response.getResponseCode() !== 200) {
Logger.log('Skipping feed (HTTP ' + response.getResponseCode() + '): ' + feedUrl);
continue;
}
const xml = response.getContentText();
// 3. Pull out the most recent <item> blocks.
const items = [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)]
.slice(0, ITEMS_PER_FEED);
for (const [, item] of items) {
// 4. Extract the title and link from each item.
const title = (item.match(/<title[^>]*>([\s\S]*?)<\/title>/) || [, ''])[1];
const link = (item.match(/<link[^>]*>([\s\S]*?)<\/link>/) || [, ''])[1];
// 5. Skip items with no link, or ones already in the sheet.
if (!link || known.has(link)) continue;
// 6. Append the new article and remember the link for this run.
sheet.appendRow([new Date(), link, title, feedUrl]);
known.add(link);
added++;
}
}
Logger.log('Added ' + added + ' new article(s).');
}
How it works
aggregateFeedsopens the reading sheet and reads column B — every link already logged — into aSetfor fast duplicate checks.- It loops over each URL in
FEEDS, fetching the feed withmuteHttpExceptionsso one unreachable source cannot abort the run. - If a fetch returns anything other than HTTP 200, it logs the feed and skips to the next one.
- It extracts the recent
<item>blocks from the XML with a regex and caps each feed atITEMS_PER_FEEDso an old, long feed does not flood the sheet. - For each item it pulls the title and link with small regexes, falling back to an empty string when a tag is missing.
- It skips any item with no link or whose link is already in the
knownset. - New articles are appended as a row — added date, link, title, source — and
the link is added to
knownstraight away, so the same article appearing in two feeds is only logged once per run.
Example run
Starting with an empty reading sheet, after one run:
| Added | Link | Title | Source |
|---|---|---|---|
| 2026-05-25 | blog.example.com/astro-tips | Five Astro build tips | blog.example.com/rss |
| 2026-05-25 | news.example.org/ga-update | Apps Script quota change | news.example.org/feed |
Run it again an hour later and only genuinely new articles appear — the two above are skipped because their links are already in column B.
Trigger it
Run this on a schedule so the reading list stays current on its own:
- In the Apps Script editor, open Triggers (the clock icon).
- Click Add trigger.
- Choose the
aggregateFeedsfunction, a Time-driven source, and a Day timer (or an hourly timer for fast-moving feeds). - Save and approve the authorisation prompt.
Watch out for
- Regex is a fragile XML parser. It works for tidy, predictable feeds but
trips over
<![CDATA[ ]]>blocks, attributes on<link>tags and Atom feeds, which use<entry>instead of<item>. For varied feeds, useXmlServiceto parse properly. - Titles often arrive HTML-encoded (
&,'). This script writes them raw — decode them if the sheet needs to read cleanly. - Deduplication is by exact link match. A feed that adds tracking parameters or changes a URL slightly will look like a new article.
appendRowruns one write per article. A first run over many busy feeds is slow; for large volumes, collect rows and write them in onesetValuescall.UrlFetchApphas a daily call quota. One call per feed per run is light, but a longFEEDSlist on a frequent trigger adds up.- The
Readcolumn is for humans — the script never reads or writes it, so ticking an article off is safe and survives the next run.
Related
Sync calendar bookings with Calendly
Bridge Google Calendar and Calendly — Northwind bookings on either side appear on both.
Updated Jan 7, 2026
Connect to an air-quality and weather feed
Build a Northwind environmental dashboard — current London AQI plus 5-day forecast.
Updated Dec 30, 2025
Build a podcast and media stats tracker
Pull Northwind's podcast download numbers across platforms into a single sheet.
Updated Dec 10, 2025
Track real-estate listings for new matches
Monitor property feeds for Northwind office hunts — alert when a match appears.
Updated Nov 28, 2025
Translate columns with a translation API
Localise Northwind text in bulk without manual work — via Google Translate or DeepL.
Updated Nov 24, 2025