Build a content pipeline

Feed in, records out. This pipeline reads an RSS feed or sitemap, extracts and enriches each new article, stores it, survives being killed halfway, and tells you what the run cost.

Every piece of this pipeline has appeared in an earlier lesson. The robots check and the per-host pacing came from lesson 1, the language gate and enrichment from lesson 2, the fingerprinting from lesson 3. What is new is the part that only shows up when a pipeline runs unattended, every hour, for months: it gets killed halfway through, a call fails at 3 a.m., a feed republishes an old article with a new tracking parameter, and someone asks what last month's credits were spent on. A pipeline that handles those is a product. One that doesn't is a script you babysit.

Here is the shape:

RSS feed / sitemap ──► discover ──► dedupe ──► extract ──► detect language ──► enrich ──► store
                         (10)        (free)      (25)            (2)            (20 + 10)

The numbers are credits per item, from each API's catalog page. They will matter in the last section.

Discover: feeds first, sitemaps second

The cheapest way to find new pages is to let the site tell you. Most publishers and many documentation sites expose an RSS or Atom feed listing their recent items. RSS to JSON turns one into structured JSON for 10 credits, however many items it holds.

Try it liveRSS to JSON10 credits
POSThttps://api.apiverve.com/v1/rsstojson
curl -X POST "https://api.apiverve.com/v1/rsstojson" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://blog.cloudflare.com/rss/"}'
Sign in or create a free account to run this with your own key.

feed is an array of items with title, link, pubDate and description. articles is the count; maxReached says whether the list was cut.

Each item in feed carries title, link, pubDate, description and website. When we called that feed it held 20 items and maxReached was false. If maxReached is ever true, the feed had more items than were returned, so poll more often or you may miss items between runs.

When a site has no feed, its sitemap is the next best source. A sitemap is a plain XML list of URLs, usually at /sitemap.xml or named in robots.txt, often with a <lastmod> date per URL. Fetch it yourself: it is a single file meant for crawlers, and the lastmod filter is what makes it usable. A large site's sitemap can list a hundred thousand URLs. You want the dozen that changed since your last run.

sources.js
import { verve } from './verve.js';

export async function fromFeed(feedUrl) {
  const { data } = await verve('rsstojson', { url: feedUrl });
  return (data.feed ?? []).map((item) => ({
    url: item.link,
    title: item.title,
    published: item.pubDate ? new Date(item.pubDate).toISOString() : null,
  }));
}

export async function fromSitemap(sitemapUrl, since) {
  const res = await fetch(sitemapUrl, { signal: AbortSignal.timeout(15000) });
  if (!res.ok) throw new Error(`sitemap HTTP ${res.status}`);
  const xml = await res.text();
  const items = [];
  for (const [, block] of xml.matchAll(/<url>([\s\S]*?)<\/url>/g)) {
    const loc = block.match(/<loc>\s*([^<\s]+)\s*<\/loc>/)?.[1];
    const lastmod = block.match(/<lastmod>\s*([^<\s]+)\s*<\/lastmod>/)?.[1];
    if (!loc) continue;
    if (since && lastmod && new Date(lastmod) <= since) continue;
    items.push({ url: loc.replace(/&amp;/g, '&'), title: null, published: lastmod ?? null });
  }
  return items;
}

A sitemap whose root is <sitemapindex> lists other sitemaps rather than pages; follow its <loc> entries the same way. And lastmod is the publisher's claim, not a guarantee. Some sites stamp every URL with today's date on each deploy, which makes the filter useless. If the first run with a since date still returns everything, that is what happened, and the content fingerprint in the next section is your backstop.

Idempotency: the same item, processed once

The single most important property of a scheduled pipeline is that running it twice does the same as running it once. Without it, every retry, overlapping cron run or manual re-run pays for the same extraction again and writes duplicate rows.

Idempotency starts with a stable identity for each item, and URLs are worse at that than they look. The same article can arrive as https://Example.com/post?utm_source=rss, https://example.com/post and https://example.com/post#comments. Canonicalise before you hash: lowercase the host, drop the fragment, strip tracking parameters, sort what remains.

identity.js
import { createHash } from 'node:crypto';

const TRACKING = /^(utm_\w+|fbclid|gclid|mc_cid|mc_eid|ref|ref_src)$/i;

export function canonical(raw) {
  const u = new URL(raw);
  u.hash = '';
  u.hostname = u.hostname.toLowerCase();
  for (const key of [...u.searchParams.keys()]) if (TRACKING.test(key)) u.searchParams.delete(key);
  u.searchParams.sort();
  if (u.pathname.length > 1 && u.pathname.endsWith('/')) u.pathname = u.pathname.slice(0, -1);
  return u.toString();
}

export const itemId = (url) => createHash('sha256').update(canonical(url)).digest('hex').slice(0, 20);
export const contentHash = (text) => createHash('sha256').update(text, 'utf8').digest('hex');

The second half of idempotency is checkpointing by stage. Each item gets its own record, and each stage writes its result into that record before the next stage starts. If the process dies after extraction but before enrichment, the next run sees the extracted text on disk and resumes at enrichment. It never pays 25 credits to extract the same page twice.

The content hash closes the last gap. When the same URL comes back with a newer lastmod, re-extract it, but compare the hash of the new text with the stored one. If they match, the page did not really change, and you skip the 30 credits of enrichment.

Check yourself

Your pipeline crashed after extracting 40 of 100 articles. On the re-run, what should happen to those 40?

Answer the check above to continue.

Run every example on your own key. Free plan, no card.

Scaling up?

Volume pricing, custom SLAs, and dedicated support for high-traffic teams.

Contact sales