# Daily RSS and Atom Monitor: Only Items You Have Not Seen (`lotebo-lab/feed-change-watcher`) Actor

Schedule it on your RSS and Atom feeds. The first run returns everything the feeds carry now, as a baseline. Every run after that returns only entries no earlier run returned, plus a summary row. Failed feeds and feeds robots.txt blocks are not charged.

- **URL**: https://apify.com/lotebo-lab/feed-change-watcher.md
- **Developed by:** [Tarcio Elyakin Agra Diniz](https://apify.com/lotebo-lab) (community)
- **Categories:** Developer tools, Business
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $50.00 / 1,000 source checkeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## RSS Feed Monitor: New Items Only, From Changelogs and Vendor Feeds

You follow a dozen changelog, release, status and supplier feeds, and every week you reread the same entries to work out which ones are actually new.

Give this Actor a list of RSS or Atom feed URLs and every run returns **only the new items**: the entries it has never handed you before, one row per item. It remembers what it already sent, so the first run gives you the baseline and every run after that is short.

### Run it on a schedule (this is the point)

What this Actor sells is the difference between two runs, so one run on its own is half of it. **The first run on a feed returns the feed as it stands today**: every item the feed carries at that moment, marked `first_run: true` in the `REPORT`. It is the run after that which does the job you came for, returning only what appeared since the previous run, which is usually a short list and sometimes an empty one.

So set it to run again by itself. On the Apify platform you schedule the Actor directly, with no task to create first: in Schedules, "Click on the Add dropdown and select whether you want to schedule an Actor or task", pick this Actor, and write the interval as a cron expression with six positions. There is one prerequisite: "To schedule an Actor, you need to have run it at least once before". So press Start once, let that run be the baseline, then schedule it. The platform's floor is that "The minimum interval between runs is 10 seconds"; how often you actually poll is your call and your publisher's. The steps are in the Apify documentation: https://docs.apify.com/platform/schedules

Schedules are not a paid extra. The Apify account limits page lists "Maximum number of schedules per user: 100", and the number is the same in every plan column, including Free: https://docs.apify.com/account/limits

One thing to know about the memory. The list of item ids already returned is not stored inside the Actor: it is a named key-value store record in **your own account**, keyed by feed URL. It survives between runs and it is yours to read or delete. If that store is deleted, or if you change the feed URL, the next run is a first run again for that feed and hands you everything it carries.

### Who runs it, and when

- **Engineering and integration teams** watching vendor changelogs and release feeds, to catch the version that breaks an integration;
- **operations and support**, keeping a record of incidents on the status feeds of the services they depend on;
- **procurement, legal and policy people**, following supplier blogs, standards bodies and regulators whose posts change how they work;
- **anyone who has to be able to say what changed last week** and does not want to read every feed daily.

The usual moment is a weekly or nightly schedule on the Apify platform: the run writes the new items to a dataset, and you connect that dataset to whatever you already read.

### What comes out, field by field

The dataset has two kinds of row, told apart by the `rowType` field: `feed-item` for a new item, and `summary` for the single run summary row that every successful run writes, new items or none. Filter on `rowType` to keep only the items.

One `feed-item` row per new item. Besides `rowType`, an item row has exactly these seven fields, and nothing else; the same names are declared in `.actor/dataset_schema.json` and checked by `tests/test_schemas.py`.

| field | type | what it holds |
|---|---|---|
| `source_url` | string | the feed this item came from, as given in the input |
| `source_title` | string | the title the feed publishes for itself, empty when it publishes none |
| `item_id` | string | the identity of the item: the feed's `guid` or `id`, the link when there is none, and a `sha1:` hash of feed URL, title and date when there is neither. This is the value the Actor remembers between runs |
| `item_title` | string | the item title, as plain text |
| `item_link` | string | the item URL on the publisher's site, empty when the feed gives none |
| `item_published_at` | string | the publication date in UTC, `YYYY-MM-DDTHH:MM:SSZ`. A date the parser cannot read is kept as the feed wrote it |
| `item_summary` | string | the short description the feed itself publishes (`description` in RSS, `summary` in Atom), as plain text, cut at 600 characters |

The `summary` row carries the counts of the run: `runStartedAt`, `finishedAt`, `sourcesGiven`, `sourcesRead`, `sourcesFailed`, `sourcesSkipped`, `sourcesSkippedByRobots`, `newItems`, `chargedEvents`, `chargeLimitReached`, `chargeFailures` and a one-sentence `message`. It exists so a quiet run does not look like a crash: an empty dataset and a broken Actor read the same, a dataset with one summary row saying "no new items" does not.

Every run also writes a `REPORT` record in the key-value store with the same counts plus `robotsFetches` and a `perSource` list (`source_url`, `source_title`, `status`, `error`, `items_in_feed`, `new_items`, `first_run`).

`items_in_feed` next to `new_items` is the part that matters when the answer is "nothing new": it shows the feed was really read and was really quiet, instead of leaving you to guess whether the run failed.

#### When a feed is not read

`status` in `perSource` is one of four values, and the last three mean this run says nothing about whether that feed changed:

| status | what happened | charged |
|---|---|---|
| `ok` | fetched, parsed and compared | yes, one `source-checked` |
| `failed` | the reason is in `error` | no |
| `skipped_by_robots` | the host's `robots.txt` disallows this Actor's user agent, so nothing was fetched | no |
| `skipped` | the run had already hit its pay-per-event charge limit | no |

A feed is `failed` when any of these happens: the host answers an HTTP error status (4xx or 5xx), the request times out or the connection fails, the body is empty, the body is not valid XML, or the XML root element is not a feed. The failure is written to the log and to the report, the other feeds in the list keep going, and nothing is charged for it.

One limit worth knowing: **the Actor does not check the `Content-Type` header.** It tries to parse whatever the URL returns. A page that is valid XML but is not a feed is rejected by the root-element check, and an HTML page is normally rejected as invalid XML, but the decision is made by the parser, not by the content type the server declares.

#### Real output

The row below is line 21 of `logs/corrida-local-2026-09-20-run2.log` in this repository: an end-to-end run of `src/main.py`, the second run of a pair, with the feeds served from `tests/fixtures/run2`. Between the two runs one post was added to the blog feed and nothing changed in the status feed. The blog feed carried four items at that moment, and this is the only row the run returned:

```json
{"item_id": "post-release-2-2", "item_link": "http://127.0.0.1:8099/posts/release-2-2", "item_published_at": "2026-09-20T09:15:00Z", "item_summary": "Exported timestamps are now written in UTC instead of the browser timezone.", "item_title": "Release 2.2 fixes the timezone of the export", "source_title": "Example Machines Blog", "source_url": "http://127.0.0.1:8099/blog.xml"}
```

The `REPORT` record of that same run, lines 23 to 53 of the same log:

```json
{
  "runStartedAt": "2026-09-20T06:00:00Z",
  "sourcesGiven": 2,
  "sourcesRead": 2,
  "sourcesFailed": 0,
  "newItems": 1,
  "perSource": [
    {"source_url": "http://127.0.0.1:8099/blog.xml", "source_title": "Example Machines Blog", "status": "ok", "error": "", "items_in_feed": 4, "new_items": 1, "first_run": false},
    {"source_url": "http://127.0.0.1:8099/status.atom", "source_title": "Example Machines Status", "status": "ok", "error": "", "items_in_feed": 2, "new_items": 0, "first_run": false}
  ],
  "chargedEvents": 0,
  "chargeLimitReached": false,
  "chargeFailures": 0
}
```

The first run of the same pair, in `logs/corrida-local-2026-09-20-run1.log`, returned five rows: everything the two feeds carried. That is the baseline run.

Both logs were recorded before the `rowType` field and the summary row were added. The same pair of runs today returns the same item rows, each with `"rowType": "feed-item"`, plus one `summary` row per run.

### Input

The example below is the input this Actor is prefilled with, so you can press Start and read a real result before pointing it at your own feeds.

```json
{
  "feedUrls": ["https://news.ycombinator.com/rss"],
  "maxSources": 20,
  "requestDelaySeconds": 2,
  "requestTimeoutSeconds": 20
}
```

| field | type | required | default | range |
|---|---|---|---|---|
| `feedUrls` | array of feed URLs | yes | `[]` | RSS 2.0, RSS 1.0 or Atom |
| `maxSources` | integer | no | 20 | 1 to 500 feeds read in this run |
| `requestDelaySeconds` | integer | no | 2 | 0 to 60 seconds between two feed requests |
| `requestTimeoutSeconds` | integer | no | 20 | 3 to 120 seconds before a feed is reported as failed |

Notes on the input, as the code handles it:

- a URL without `http://` or `https://` gets `https://` added, and the same URL given twice is read once;
- entries may also be objects with a `url` key, which is what the Apify "Link list" style input produces;
- `maxSources` cuts the list for this run: feeds beyond that number are simply not read, and the run says so in the log;
- the first request does not wait; the delay applies between requests.

The list of item ids already returned is kept per feed URL in a named key-value store that belongs to your account and survives between runs. Up to 5000 ids are kept per feed, oldest dropped first. Change a feed URL and the next run treats it as a first run and returns everything again.

### What this Actor does not do

- **It does not open the item link**, so it never returns the full article text. You get the summary the feed publishes, and a feed that publishes only a title gives you only a title.
- **It does not watch pages that have no feed**, and it does not build a feed out of an HTML page.
- **It does not detect an edit** to an item it already sent. A changed item is not a new item here.
- **It does not send e-mail, Slack or any other notification.** It writes a dataset, and you connect that to whatever you already use.
- **It does not filter by keyword, tag or author**, and it does not rank, translate or summarise anything.
- **It does not run on a schedule by itself.** You set the schedule on the Apify platform.
- **It does not deduplicate across feeds**: the same post published in two feeds comes back once per feed.
- **It does not get past a login, a paywall or a captcha**, and it does not run JavaScript.
- **It does not return who wrote an item.** The parser has an allow list of seven fields (`ALLOWED_FIELDS` in `src/feed_parser.py`) and nothing else reaches the dataset: author elements, `dc:creator`, `managingEditor`, `webMaster`, `contributor`, `name`, `uri` and `email` are never read, and any value that still contains an "@" after cleaning is dropped.

### Manners, `robots.txt` and your responsibility

- **A feed is fetched only when you list it.** This Actor makes one HTTP GET per feed URL in your input, follows redirects, reads at most 10 MB, and never crawls, follows links or discovers feeds on its own.
- **It identifies itself** on every request as `FeedChangeWatcher/0.1 (Apify Actor; +https://apify.com/lotebo-lab/feed-change-watcher)`, and keeps `requestDelaySeconds` between requests so the servers you follow are not hit hard.
- **It reads `robots.txt` before it reads a feed.** One `robots.txt` per host, fetched once per run and reused, asked with the same user agent the feed request sends. An explicit `Disallow` that matches that user agent skips the feed: nothing is fetched, nothing is charged, and the feed appears as `skipped_by_robots` in the report. A `robots.txt` that cannot be read, because of a 404, a server error or a timeout, states no restriction, so the feed is fetched and the log says so.
- **`robots.txt` is not the whole answer.** You are still responsible for the terms of each site you list: a feed can be open in `robots.txt` and closed to automated clients by the site's terms or by your agreement with it.
- **You are responsible for having the right to read every feed you list.** Check the terms of the sites that publish them, and check whether your own agreement with them allows automated access.

### Price

Pay per event, two events, exactly as declared in `.actor/actor.json`:

| event | price | when it is charged |
|---|---|---|
| `source-checked` | US$ 0.05 | once per feed that was fetched, parsed and compared. A feed that failed to load, that `robots.txt` disallowed, or that was skipped after the charge limit, is not charged |
| `change-report` | US$ 0.50 | once per run, and only when the run found at least one new item |

**A run that finds nothing new costs only the feeds it read.** Watching a changelog means most runs have nothing to report, and you are not charged for the report on those runs: ten feeds with no new item cost ten `source-checked` events and nothing else. Ten feeds with at least one new item cost ten `source-checked` events plus one `change-report`. The summary row is written to the dataset either way, free. The first run of a feed is charged like any other read, and it returns everything the feed carries. Apify charges its own Actor start event and the platform usage of the run on top of this; those are not set by this Actor.

### About this Actor

The code, the tests and the run logs quoted here are in this repository. Parsing uses the Python standard library, not `feedparser`, so there is no extra dependency to break, and a failing feed never stops the run: it is reported and the other feeds keep going. The Actor is written in Python and was built with the help of AI.

### Example tasks

Each page below is a published example task of this Actor. It shows the input used and the fields the run returns. The same page is served as Markdown by adding `.md` to the URL.

- [Track new security advisories across vendor feeds](https://apify.com/lotebo-lab/feed-change-watcher/examples/watch-vendor-security-advisory-feeds-for-new-entries): polls every vendor advisory feed you follow and returns only the entries published since your last run, normalised into one format.
- [See only new tender notices from many agency feeds](https://apify.com/lotebo-lab/feed-change-watcher/examples/read-many-tender-feeds-and-see-only-new-notices): reads the feed of every agency you follow and returns the notices that appeared since the previous run, in one table.
- [Monitor regulator news feeds without rereading old items](https://apify.com/lotebo-lab/feed-change-watcher/examples/monitor-regulator-news-feeds-without-rereading-old-items): checks the news feed of each agency you watch on a polite delay and returns only the items published since the last run.
- [How do I get only new items from an RSS feed?](https://apify.com/lotebo-lab/feed-change-watcher/examples/get-only-new-items-from-an-rss-feed): give one or more RSS or Atom feeds and each run returns only the items no earlier run returned, normalised into one format, plus how many items each source had.
- [What is new across several release feeds today?](https://apify.com/lotebo-lab/feed-change-watcher/examples/watch-several-release-feeds-in-one-run): reads a list of feeds in one run and returns only the items that no earlier run reported, grouped by the feed they came from.
- [Send only new feed items to n8n or Make, never duplicates](https://apify.com/lotebo-lab/feed-change-watcher/examples/send-only-new-feed-items-to-n8n-or-make): returns only the items published since the last run, as flat JSON an automation step consumes without extra parsing.
- [Watch release feeds and get only new versions](https://apify.com/lotebo-lab/feed-change-watcher/examples/watch-release-feeds-for-new-versions): one row per version you have not seen before, grouped by the feed it came from.
- [Which of my feeds stopped working this run?](https://apify.com/lotebo-lab/feed-change-watcher/examples/check-which-of-my-feeds-stopped-working): reads the whole list and reports how many feeds answered, how many failed and how many robots.txt refused to serve.

# Actor input Schema

## `feedUrls` (type: `array`):

The RSS or Atom feeds to watch, one URL per line. https:// is added when you leave it out. Each feed read is one charged event.

## `maxSources` (type: `integer`):

How many feeds from the list are read in this run. Feeds beyond this number are left for the next run.

## `requestDelaySeconds` (type: `integer`):

Seconds to wait between two feed requests. Keep it at 1 or more to stay polite with the servers you read.

## `requestTimeoutSeconds` (type: `integer`):

Seconds to wait for a feed to answer before it is reported as failed in the run report.

## Actor input object example

```json
{
  "feedUrls": [
    "https://news.ycombinator.com/rss"
  ],
  "maxSources": 20,
  "requestDelaySeconds": 2,
  "requestTimeoutSeconds": 20
}
```

# Actor output Schema

## `newItems` (type: `string`):

One item per feed entry seen for the first time: feed URL, feed title, item id, title, link, publication date and the summary the feed publishes.

## `report` (type: `string`):

Feeds given, feeds read, feeds that failed, new items per source and how many events were charged.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "feedUrls": [
        "https://news.ycombinator.com/rss"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("lotebo-lab/feed-change-watcher").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "feedUrls": ["https://news.ycombinator.com/rss"] }

# Run the Actor and wait for it to finish
run = client.actor("lotebo-lab/feed-change-watcher").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "feedUrls": [
    "https://news.ycombinator.com/rss"
  ]
}' |
apify call lotebo-lab/feed-change-watcher --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,lotebo-lab/feed-change-watcher"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/dfv4IcpbgK4GTfEYe/builds/A0VO1cstJe5DrbKv2/openapi.json
