← Complete Projects

阅读中文版本

Build an RSS Feed Aggregation CLI

A feed reader that fetches multiple RSS and Atom feeds, normalises their items into one schema, de-duplicates by link, sorts by date, and renders a readable digest or JSON output.

beginner~5 hnodejsrssclixml

Project goal

Build a CLI that reads a list of feed URLs, fetches and parses them concurrently, normalises items into a single schema, de-duplicates entries, sorts newest-first, and prints a digest or JSON.

Final features

  • Fetch several feeds concurrently with a per-request timeout.
  • Parse both RSS and Atom into one normalised item schema.
  • De-duplicate items that appear in more than one feed.
  • Sort the merged stream newest-first with a stable tie-breaker.
  • Render output as a readable digest or raw JSON.

Architecture

A fetch layer retrieves feed documents with timeouts and bounded concurrency; a normaliser converts RSS and Atom into the same item shape; a merger de-duplicates and sorts; and a formatter renders text or JSON. Parsing is separated from fetching so fixtures can be parsed offline.

Prerequisites

  • Node.js 20 LTS or later
  • Familiarity with fetch and XML structure
  • Basic understanding of date handling in JavaScript

Dependencies

  • node >=20 — Global fetch, timers, and the CLI entry point
  • fast-xml-parser ^4.4.0 — Tolerant XML parsing for RSS and Atom
  • vitest ^2.1.0 — Parser and merger unit tests with fixtures

Directory structure

feed-reader/
├── src/
│   ├── fetchFeeds.js
│   ├── parseFeed.js
│   ├── merge.js
│   ├── format.js
│   └── cli.js
├── fixtures/
│   ├── sample.rss
│   └── sample.atom
├── test/
│   ├── parseFeed.test.js
│   └── merge.test.js
└── package.json

Phases

1. Feed fetching

Fetch a list of feed URLs with a timeout and bounded concurrency, returning successes and failures separately.

Files: src/fetchFeeds.js

export async function fetchFeeds(urls, { timeoutMs = 8000, concurrency = 4 } = {}) {
  const results = [];
  let index = 0;
  async function worker() {
    while (index < urls.length) {
      const url = urls[index++];
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), timeoutMs);
      try {
        const res = await fetch(url, { signal: controller.signal });
        results.push({ url, text: await res.text() });
      } catch (err) {
        results.push({ url, error: err.name });
      } finally {
        clearTimeout(timer);
      }
    }
  }
  await Promise.all(Array.from({ length: Math.min(concurrency, urls.length) }, worker));
  return results;
}

Expected behavior: A stub fetch that never resolves causes an abort timeout error for that URL while other URLs still complete.

2. RSS and Atom parsing

Parse both feed formats with one tolerant parser and normalise items into a shared schema.

Files: src/parseFeed.js

import { XMLParser } from 'fast-xml-parser';

const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' });

export function parseFeed(xml) {
  const doc = parser.parse(xml);
  if (doc.rss?.channel) return normalizeRss(doc.rss.channel);
  if (doc.feed) return normalizeAtom(doc.feed);
  return [];
}

const asArray = (v) => (Array.isArray(v) ? v : v == null ? [] : [v]);
const text = (v) => (v == null ? '' : typeof v === 'object' ? v['#text'] ?? '' : String(v));

function toIso(value) {
  const d = new Date(value);
  return Number.isNaN(d.getTime()) ? null : d.toISOString();
}

Expected behavior: Both fixtures parse into items with a title, link, and ISO date, and an item lacking a date gets null rather than an invalid string.

3. Merge and de-duplicate

Concatenate items from every feed, drop duplicates by link, and sort newest-first with a stable tie-breaker.

Files: src/merge.js

export function mergeItems(lists) {
  const seen = new Map();
  for (const list of lists) {
    for (const item of list) {
      const key = item.link || item.title;
      if (!seen.has(key)) seen.set(key, item);
    }
  }
  return [...seen.values()].sort((a, b) => {
    const da = a.publishedAt ?? '';
    const db = b.publishedAt ?? '';
    if (da !== db) return db.localeCompare(da);
    return a.title.localeCompare(b.title);
  });
}

Expected behavior: An item present in two feeds appears once, items are ordered by descending date, and undated items sort last in a deterministic order.

4. Output formatting

Render the merged stream as a human-readable digest, with a JSON mode that is stable for piping.

Files: src/format.js

export function formatDigest(items, { limit = 20 } = {}) {
  return items.slice(0, limit).map((item, i) => {
    const date = item.publishedAt ? item.publishedAt.slice(0, 10) : 'unknown';
    return `${String(i + 1).padStart(2)}. ${item.title} (${item.source}, ${date})\n    ${item.link}`;
  }).join('\n');
}

Expected behavior: The digest prints numbered entries with dates and source names, and the JSON mode returns the same items as an array with no console formatting.

5. CLI surface

Read feed URLs from arguments or a file, choose an output mode, and report per-feed fetch failures without hiding successful ones.

Files: src/cli.js

import { fetchFeeds } from './fetchFeeds.js';
import { parseFeed } from './parseFeed.js';
import { mergeItems } from './merge.js';
import { formatDigest } from './format.js';

const urls = process.argv.slice(2).filter((a) => !a.startsWith('--'));
const asJson = process.argv.includes('--json');
if (urls.length === 0) {
  console.error('usage: read <feed-url> [more...] [--json]');
  process.exit(1);
}
const fetched = await fetchFeeds(urls);
for (const f of fetched.filter((f) => f.error)) console.error('failed: ' + f.url + ' (' + f.error + ')');
const items = mergeItems(fetched.filter((f) => f.text).map((f) => parseFeed(f.text)));
console.log(asJson ? JSON.stringify(items, null, 2) : formatDigest(items));

Expected behavior: A mixed run prints a digest for the feeds that succeeded, logs each failure to stderr, and exits zero when at least one feed parsed.

6. Tests and hardening

Cover parsing, merging, and failure handling with fixture-based tests and a stubbed fetch, then fix failures.

Files: test/merge.test.js

import { mergeItems } from '../src/merge.js';

test('duplicate links collapse and newest sorts first', () => {
  const a = [{ link: 'https://x/1', title: 'One', publishedAt: '2026-01-01T00:00:00Z' }];
  const b = [
    { link: 'https://x/1', title: 'One (cross-post)', publishedAt: '2026-01-01T00:00:00Z' },
    { link: 'https://x/2', title: 'Two', publishedAt: '2026-02-01T00:00:00Z' },
  ];
  const merged = mergeItems([a, b]);
  expect(merged.map((m) => m.link)).toEqual(['https://x/2', 'https://x/1']);
});

Expected behavior: The merge suite passes, a timed-out feed is reported without aborting the run, and a malformed feed yields zero items instead of throwing.

Testing

Run npx vitest. Parser tests read the committed fixtures and assert normalised fields for both formats, including an item with no date. Merger tests feed overlapping item sets and assert de-duplication by link and newest-first ordering. Fetch tests replace the global fetch with a stub and assert timeouts surface as errors rather than hanging.

Troubleshooting

A single slow feed blocks the whole run

Give every fetch its own AbortController timeout and bounded concurrency worker; one hung request must not hold up the workers that already finished.

Atom link comes out as an object, not a URL

Atom links are attributes, not text; configure the parser to keep attributes and read the href field explicitly, rather than assuming a string.

The same article shows up twice

De-duplicate on a canonical key such as the link, falling back to the title only when the link is missing, because cross-posts share a link but not always a title.

Deployment

Run the CLI from a cron job to write a daily digest file, or call it as a library inside a larger tool. Because parsing is offline-testable, the fetch stage can be swapped for a cached feed store in air-gapped environments.

Known limitations

  • HTML inside item summaries is not sanitised; consumers rendering it to a web page must escape or sanitise first.
  • There is no persistent cache, so every run re-fetches every feed from the network.

Project overview

Feed aggregation looks trivial until you meet the real world: two flavours of XML, missing dates, duplicated cross-posts, and a feed that hangs. This project handles each of those explicitly so the digest is predictable.

Fetching, parsing, and merging are separate stages, which means the parser can be tested against committed fixture files without any network access at all.

The project is deliberately sized so that every stage ships something you can run: the first phase produces a working command or endpoint, and each later phase adds one capability without forcing a rewrite of what came before. That property — always runnable, never half-migrated — is what makes the tutorial usable as a weekend build rather than a sketch that only works once the final commit lands.

The finished tool carries no hidden services and no hosted dependencies. Everything it needs lives in the repository and a machine with the stated runtime, so a reader can clone, install, run the tests, and trust that the behaviour they see locally is the behaviour the tutorial promised. Where a real deployment needs an external system, that seam is called out explicitly rather than smuggled in through a default.

It is written for a working developer who wants the whole shape of a real tool in one readable pass rather than a tour of a framework. Each phase states the decision it makes and why, then shows the code that encodes it, so the reader can stop after any phase with something that runs and resume later without relearning the whole design. The prose is deliberately honest about trade-offs: where a simpler approach would break at scale, that ceiling is named instead of hidden behind a benchmark.

Normalising both feed formats into one item shape is the decision that makes everything downstream simple:

export function normalizeRss(channel) {
  const items = asArray(channel.item);
  return items.map((item) => ({
    title: text(item.title),
    link: text(item.link),
    publishedAt: toIso(text(item.pubDate)),
    summary: text(item.description),
    source: text(channel.title),
  }));
}

export function normalizeAtom(feed) {
  const entries = asArray(feed.entry);
  return entries.map((entry) => ({
    title: text(entry.title),
    link: text(entry.link?.['@_href'] ?? entry.link),
    publishedAt: toIso(text(entry.updated ?? entry.published)),
    summary: text(entry.summary),
    source: text(feed.title),
  }));
}

Once every item has title, link, date, summary, and source, the merger and formatter never need to know which format the item came from.

Setup

The only non-standard dependency is an XML parser; tolerant parsing is worth the package because real feeds contain malformed markup that a strict parser rejects.

Set up the workspace before writing any logic. The table below pins every dependency to a version so the behaviour you observe matches the tutorial; floating versions are the most common reason a reader’s output diverges from the text. Install exactly what the table lists, then confirm the toolchain reports the expected version before continuing.

Two configuration choices deserve attention up front. First, the project keeps its runtime entry and its test entry as separate scripts so that running tests never starts a server or writes to a data directory. Second, all tunables are read from one place — a small config module or environment variables — so that nothing depends on the working directory a command happens to be launched from.

Dependency Version Why it is here
node >=20 Global fetch, timers, and the CLI entry point
fast-xml-parser ^4.4.0 Tolerant XML parsing for RSS and Atom
vitest ^2.1.0 Parser and merger unit tests with fixtures

Create the project and install:

mkdir feed-reader && cd feed-reader
npm init -y
npm install fast-xml-parser@^4.4.0
npm install -D vitest@^2.1.0

Configure package.json with the CLI and test scripts:

{
  "name": "feed-reader",
  "type": "module",
  "scripts": {
    "read": "node src/cli.js",
    "test": "vitest run"
  }
}

Commit two small fixture feeds (one RSS, one Atom) so parser tests never depend on the network.

Phase 1: Feed fetching

Fetch a list of feed URLs with a timeout and bounded concurrency, returning successes and failures separately. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.

The first hard decision is where the boundary between parsing and doing sits. Input arrives as raw text — argv, environment variables, a file on disk — and this phase converts it exactly once into a typed object that the rest of the program trusts. Confining that conversion to a single module means every later layer receives clean, already-validated input and never re-checks a string, which removes a whole class of bugs where two modules quietly disagree about what a flag or a field means. The second decision is failure visibility: invalid input must produce an actionable message and a non-zero exit rather than a stack trace surfacing from three layers down. Getting these two boundaries right early is what lets the remaining phases stay small.

export async function fetchFeeds(urls, { timeoutMs = 8000, concurrency = 4 } = {}) {
  const results = [];
  let index = 0;
  async function worker() {
    while (index < urls.length) {
      const url = urls[index++];
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), timeoutMs);
      try {
        const res = await fetch(url, { signal: controller.signal });
        results.push({ url, text: await res.text() });
      } catch (err) {
        results.push({ url, error: err.name });
      } finally {
        clearTimeout(timer);
      }
    }
  }
  await Promise.all(Array.from({ length: Math.min(concurrency, urls.length) }, worker));
  return results;
}

Acceptance for this phase: A stub fetch that never resolves causes an abort timeout error for that URL while other URLs still complete.

Phase 2: RSS and Atom parsing

Parse both feed formats with one tolerant parser and normalise items into a shared schema. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.

This phase defines the data model that every other module speaks. The choice of shape matters more than it looks: flat records are easy to serialize and diff, nested structures model relationships but complicate equality checks, and identifiers must be stable across edits so that references never silently rebind. The code here stays pure — functions take data and return data, with no clocks, no disk, and no printing — because pure functions are the only kind that can be tested exhaustively without a fixture harness. When the model is right the surrounding code almost writes itself; when it is wrong every later phase pays interest.

import { XMLParser } from 'fast-xml-parser';

const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' });

export function parseFeed(xml) {
  const doc = parser.parse(xml);
  if (doc.rss?.channel) return normalizeRss(doc.rss.channel);
  if (doc.feed) return normalizeAtom(doc.feed);
  return [];
}

const asArray = (v) => (Array.isArray(v) ? v : v == null ? [] : [v]);
const text = (v) => (v == null ? '' : typeof v === 'object' ? v['#text'] ?? '' : String(v));

function toIso(value) {
  const d = new Date(value);
  return Number.isNaN(d.getTime()) ? null : d.toISOString();
}

Acceptance for this phase: Both fixtures parse into items with a title, link, and ISO date, and an item lacking a date gets null rather than an invalid string.

Phase 3: Merge and de-duplicate

Concatenate items from every feed, drop duplicates by link, and sort newest-first with a stable tie-breaker. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.

The core operation is where the project delivers its value, and the temptation is to cram every option into one function. Resist it. The main path should read as a short, obvious sequence — load, transform, emit — with the branching behaviour factored into small decisions that can be tested on their own. This phase also fixes the error contract: which failures are fatal, which are recoverable, and how a partial result is reported. A tool that can explain what it did and why it stopped is far easier to trust in a pipeline than one that only signals success or silence.

export function mergeItems(lists) {
  const seen = new Map();
  for (const list of lists) {
    for (const item of list) {
      const key = item.link || item.title;
      if (!seen.has(key)) seen.set(key, item);
    }
  }
  return [...seen.values()].sort((a, b) => {
    const da = a.publishedAt ?? '';
    const db = b.publishedAt ?? '';
    if (da !== db) return db.localeCompare(da);
    return a.title.localeCompare(b.title);
  });
}

Acceptance for this phase: An item present in two feeds appears once, items are ordered by descending date, and undated items sort last in a deterministic order.

Phase 4: Output formatting

Render the merged stream as a human-readable digest, with a JSON mode that is stable for piping. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.

Integration is where the project stops being a toy. Input may arrive in a format an external system produced, output may need to match a contract another system consumes, and the two rarely line up without adjustment. This phase owns the adapters — the small translation functions between the outside world’s shape and the internal model — and it owns the boundary decisions about what to do when the two disagree: coerce, reject, or warn and continue. Keeping adapters thin and explicit means a change to an external format touches one file, and the internal model stays stable as the ecosystem around it moves.

export function formatDigest(items, { limit = 20 } = {}) {
  return items.slice(0, limit).map((item, i) => {
    const date = item.publishedAt ? item.publishedAt.slice(0, 10) : 'unknown';
    return `${String(i + 1).padStart(2)}. ${item.title} (${item.source}, ${date})\n    ${item.link}`;
  }).join('\n');
}

Acceptance for this phase: The digest prints numbered entries with dates and source names, and the JSON mode returns the same items as an array with no console formatting.

Phase 5: CLI surface

Read feed URLs from arguments or a file, choose an output mode, and report per-feed fetch failures without hiding successful ones. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.

The user-facing surface determines whether the project feels finished. A command-line tool owes its caller predictable stdout, meaningful stderr, and honest exit codes; a service owes a stable request and response schema plus consistent status handling. This phase wires the internal modules to that surface and nothing else — it contains no business logic of its own, only translation between the outside contract and the internal calls. That separation is what lets the same core serve a CLI today and an HTTP endpoint tomorrow without duplication, and it is why the tests for this layer assert process-level behaviour rather than poking at internals.

import { fetchFeeds } from './fetchFeeds.js';
import { parseFeed } from './parseFeed.js';
import { mergeItems } from './merge.js';
import { formatDigest } from './format.js';

const urls = process.argv.slice(2).filter((a) => !a.startsWith('--'));
const asJson = process.argv.includes('--json');
if (urls.length === 0) {
  console.error('usage: read <feed-url> [more...] [--json]');
  process.exit(1);
}
const fetched = await fetchFeeds(urls);
for (const f of fetched.filter((f) => f.error)) console.error('failed: ' + f.url + ' (' + f.error + ')');
const items = mergeItems(fetched.filter((f) => f.text).map((f) => parseFeed(f.text)));
console.log(asJson ? JSON.stringify(items, null, 2) : formatDigest(items));

Acceptance for this phase: A mixed run prints a digest for the feeds that succeeded, logs each failure to stderr, and exits zero when at least one feed parsed.

Phase 6: Tests and hardening

Cover parsing, merging, and failure handling with fixture-based tests and a stubbed fetch, then fix failures. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.

The final phase turns a working program into a dependable one. Tests are the visible part, but the discipline behind them is deeper: every claim the earlier phases made is pinned by an executable check, and every failure mode the tutorial mentioned has a test that reproduces it before the fix. Edge cases — empty input, malformed records, concurrent invocations, interrupted writes — are enumerated and asserted rather than assumed away. The payoff is a suite that fails loudly and precisely when someone changes a decision, which is exactly the signal a maintainer wants the moment they touch the code.

import { mergeItems } from '../src/merge.js';

test('duplicate links collapse and newest sorts first', () => {
  const a = [{ link: 'https://x/1', title: 'One', publishedAt: '2026-01-01T00:00:00Z' }];
  const b = [
    { link: 'https://x/1', title: 'One (cross-post)', publishedAt: '2026-01-01T00:00:00Z' },
    { link: 'https://x/2', title: 'Two', publishedAt: '2026-02-01T00:00:00Z' },
  ];
  const merged = mergeItems([a, b]);
  expect(merged.map((m) => m.link)).toEqual(['https://x/2', 'https://x/1']);
});

Acceptance for this phase: The merge suite passes, a timed-out feed is reported without aborting the run, and a malformed feed yields zero items instead of throwing.

Design trade-offs

Minimal dependencies over framework convenience. A framework would hand over plumbing for free, but it would also own the interesting decisions this project exists to teach. Keeping the dependency surface small means the lifecycle is visible end to end, the install is instant, and the tool runs anywhere the stated runtime does. The cost is owning the edge cases yourself — which is precisely why each of them gets a test.

Explicit contracts over clever conventions. Where a convention would infer behaviour from a filename or a folder, the project asks for a named export or a declared field instead. It reads as slightly more verbose and is far easier to review: a reader sees the intent on the line that owns it rather than reconstructing it from directory layout. Debugging also gets cheaper, because the failure points at the declaration instead of the convention that silently produced it.

Deterministic behaviour over convenience defaults. Anything that could vary between machines — time, ordering, encoding, locale — is injected or pinned rather than read from the ambient environment. That choice costs a little ceremony and buys reproducible output, which is the property that makes the output diffable in review and the tests trustworthy across the team’s machines.

Testing strategy

Run npx vitest. Parser tests read the committed fixtures and assert normalised fields for both formats, including an item with no date. Merger tests feed overlapping item sets and assert de-duplication by link and newest-first ordering. Fetch tests replace the global fetch with a stub and assert timeouts surface as errors rather than hanging.

Tests are organised by layer so a failure points at the decision that broke. Pure unit tests cover the transformation logic without touching disk or network and run in milliseconds; integration tests exercise the real entry point against a temporary working directory and assert the observable contract; a small end-to-end tier proves the whole path produces the documented output. Keeping the fast tiers large and the slow tier thin is what lets the suite run on every save while still catching integration mistakes.

Every test asserts a specific promise from the phases above rather than merely covering lines. A passing suite therefore means the tool kept its word: filters compose, identifiers stay stable, failures exit non-zero with a readable message, and the output matches the format a downstream consumer expects. When someone changes a contract the matching test fails with an exact diff, turning a silent regression into a line in the pull request.

Running the suite in CI is the last line of defence, but the same command runs locally before every commit, which is what keeps the feedback loop short. A green run means the documented behaviour still holds; a red run names the exact promise that broke. Treat the suite as executable documentation and it stays current, because a stale assertion fails the moment the code and the text disagree.

De-duplication is tested with a cross-posted item present in two feeds under different titles, which is the case a naive merge gets wrong.

Extending the project

Full-text search, per-feed filters, and caching are each a stage around the existing pipeline. Adding a new format means one more normaliser; adding an output mode means one more formatter, with no change to fetch or merge.

The structure leaves obvious seams for growth. Each capability that could be added — a new output format, an extra filter, an interactive mode — lands in one layer because the boundaries between parsing, logic, and presentation are already drawn. Extensions that would break the current design are equally visible: the moment a feature needs cross-process coordination or a database, the storage interface is where the change belongs, and its tests tell you immediately whether the surrounding code survives the swap.