RSS Feed Engine
Poll an RSS 2.0 feed, turn each item into a JSON record, and keep a file-backed record of every item already seen, so no item is emitted twice, even after the job restarts.
Components: generate (the poll schedule) → http → mapping (fetch
error check) → xml (to_json) → mapping → unarchive → mapping →
dedupe with a file cache → file output.
Needs: a node with outbound HTTPS access to the feed. No credentials.
Complete job
The job below polls the NASA news-release feed once. The selector sends it
to a node labelled pipeline_role: feeds; set that label on your node, or
change the selector to a label it already carries. With no matching node, the
job is stored and never runs.
The feed URL appears twice in the job: in http.url and in the mapping's
root.feed. To switch feeds, change both together.
name: rss-feed-engine
type: pipeline
# One-shot job: stop after one failed execution instead of retrying.
restart_policy: never
selector:
match_labels:
pipeline_role: feeds
config:
input:
generate:
# One bounded poll. For a standing engine, drop `count` and set
# `interval` to the feed's polling period, e.g. "5m". Also
# remove `restart_policy: never` above: one transient fetch
# error would stop a long-running job for good. The default
# policy restarts it on failure.
count: 1
interval: 1s
mapping: root = {}
pipeline:
processors:
- http:
url: "https://www.nasa.gov/news-release/feed/"
verb: GET
retries: 3
# Stop here with the fetch error before XML parsing.
- mapping: |
root = if errored() {
throw("feed fetch failed: " + error().or("unknown"))
} else { content() }
- xml:
operator: to_json
- mapping: |
let items = this.rss.channel.item
root = if $items.type() == "array" { $items } else {
[$items]
}
- unarchive:
format: json_array
- mapping: |
let g = this.guid
root.guid = if $g.type() == "object" { $g."#text" } else {
$g
}
root.title = this.title
root.link = this.link
root.category = this.category
root.summary = this.description
# RSS 2.0 dates are RFC 822: numeric zone (+0000) or a
# name (GMT). Bind to a variable: inside .catch(...)
# `this` is not the item.
let pd = this.pubDate
root.published_at = $pd.ts_strptime(
"%a, %d %b %Y %H:%M:%S %z"
).catch($pd.ts_strptime(
"%a, %d %b %Y %H:%M:%S %Z"
)).ts_format("2006-01-02T15:04:05Z07:00", "UTC")
root.feed = "https://www.nasa.gov/news-release/feed/"
- dedupe:
cache: seen_guids
key: '${! json("guid").hash("sha256").encode("hex") }'
cache_resources:
- label: seen_guids
file:
directory: "/var/tmp/expanso-rss/state/seen-guids"
output:
file:
path: "/var/tmp/expanso-rss/out/rss-items.jsonl"
codec: lines
The two paths are on the node that runs the job, not the machine you deploy from, and the user running the agent must be able to write to them. Create the directories on that node first:
mkdir -p /var/tmp/expanso-rss/state/seen-guids \
/var/tmp/expanso-rss/out
Deploy and check it
expanso-cli job validate rss-feed-engine.yaml --offline
expanso-cli job deploy rss-feed-engine.yaml
expanso-cli job describe rss-feed-engine
Then, on the node, count what arrived and compare it with the feed:
wc -l < /var/tmp/expanso-rss/out/rss-items.jsonl
curl -sL https://www.nasa.gov/news-release/feed/ \
| grep -o '<item>' | wc -l
A live feed changes over time, so compare the two counts close together, and expect a difference if the feed published or dropped items in between. Run the job again with the same cache directory and the file gains lines only for items it has not seen before.
Each line is one item with the fields guid, title, link, category,
summary, published_at and feed. One record from the run on 2026-09-18
(UTC), with its long link, category and summary values left out; today's
feed will carry different items:
{
"guid": "https://www.nasa.gov/?post_type=press-release&p=1048343",
"title":
"NASA Awards Launch Services for StarBurst Gamma-Ray Detector",
"published_at": "2026-09-17T20:37:09Z",
"feed": "https://www.nasa.gov/news-release/feed/"
}
What the run proved
- Live feeds, 2026-09-18. One poll of the NASA feed produced 10 records and one poll of
the BBC Technology feed produced 21. In both cases the GUIDs matched an
independent fetch of the same feed taken before and after the run. BBC dates
use
GMTand NASA dates use+0000; the date mapping handles both. - Deduplication across restarts. Against a local test feed containing a duplicate GUID, the job emitted 4 distinct items. A second run with the same cache directory emitted 0.
- Where it ran. Each job above was deployed through Expanso Cloud to a
labelled node running
expanso-edgev2.1.21. Those runs used an earlier job without the fetch error check and withoutrestart_policy. - The published job, local-mode node only. The job exactly as published,
fetch error check and
restart_policy: neverincluded, was run on a local-mode node running v2.1.21. Against a local test feed it completed after 1 execution with 4 distinct items, and a second run with the same cache emitted 0. A401and a503response each failed the job after exactly 1 execution with 0 records, and the error named the fetch. A live poll of the NASA feed completed after 1 execution, and its GUIDs equalled an independent fetch. Without the check, a401also failed the job with 0 records, at thexmlstep instead: the check makes the failure name the fetch, it does not change whether the job fails.
Limits
- The runs used bounded polls (
count: 1against the live feeds,count: 3against the test feed). A standing engine dropscountand setsinterval, as the comment in the job says; that schedule was not part of the runs. Such a long-running job should not keeprestart_policy: never, or a single transient fetch error stops it for good. Choose a policy suited to a long-running job; the default restarts it on failure. - The output is a file on the node. To send items elsewhere, replace the
fileoutput with another output, such ashttp_client. - The mapping reads RSS 2.0 (
rss.channel.item). An Atom feed uses a different structure (feed.entry) and needs its own mapping. - The dedupe key is the item's
guid, which RSS 2.0 makes optional. Items without one are never emitted: hashing the missingguidfails, so every such item is dropped and the run is marked failed. If your feed omitsguid, key on another field such aslinkinstead; that variant was not run. - The published job, with
restart_policy: never, was proved on a local-mode node only; the Cloud runs used the earlier job without it. Withnever, a failed run stops after one execution instead of being re-run. See Proving your own run.
Related
- Build by Job: the task-to-components matrix
xmlprocessor anddedupeprocessor