# Podcast and Newsletter Feed Enclosure Delivery Gate (`kingii98/podcast-and-newsletter-feed-enclosure-delivery-gate`) Actor

Reads your RSS, Atom or JSON Feed, then sends one HEAD and one ranged GET to each recent enclosure. It reports range support, content type, declared length against reported length, the redirect chain, TLS use, and every item whose GUID or enclosure URL ch

- **URL**: https://apify.com/kingii98/podcast-and-newsletter-feed-enclosure-delivery-gate.md
- **Developed by:** [kingii98](https://apify.com/kingii98) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $20.00 / 1,000 run starteds

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?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Podcast and Newsletter Feed Enclosure Delivery Gate

Prove, before you publish, that each new episode file plays for every client, and
that no item identity changed by accident.

This Actor reads your RSS, Atom or JSON Feed, takes the most recent items, and
sends **one HEAD and one ranged GET** to each enclosure. It reports what the
media host really does: whether the server honours a `Range` header with `206`,
which content type it sends, whether the length in the feed agrees with the size
the server reports, how long the redirect chain is, and whether every hop uses
TLS. It also keeps a small baseline, so it can tell you when the GUID or the
enclosure URL of an item changed — the change that makes every subscriber
download the episode again.

The full media file is never downloaded. The Actor reads 64 KB by default.

### Who this is for

The publisher or the operations person of a podcast or a media newsletter, who
must know that a new episode is deliverable before it goes out. Run it after
each publication, and again after a fix.

### What it does not do

- No browser, no proxy, no paid API, no LLM.
- No full media download. Only HEAD and one bounded ranged GET.
- No write to your feed or your media host.

### Checks

| Check | Pass condition | Why it matters |
| --- | --- | --- |
| `range_support` | The ranged GET answers `206` with a `Content-Range` header. | Without range support a player cannot seek, and it cannot resume a broken download. |
| `content_type` | The media type served equals the type declared in the feed. | A file served as `text/html` or as the wrong type is refused by strict clients. |
| `declared_length_match` | The size reported by the server is inside `lengthTolerancePercent` of the `length` in the feed. | A wrong length breaks progress bars and some download clients. |
| `redirect_depth` | The chain ends inside `maxRedirects` hops. | A client with a lower hop limit cannot reach the file. |
| `https_only` | The enclosure URL and every hop use HTTPS. | A plain HTTP hop is blocked by App Transport Security and by mixed content rules. |
| `guid_stability` | No item in the checked window changed its GUID or its enclosure URL against the baseline. | A changed identity re-downloads the episode for every subscriber. |

A check that cannot be decided is reported as `skip`, not as a failure. An
example is `content_type` for a feed that declares no enclosure type.

`declared_length_match` uses a tolerance of 1 percent by default, because
dynamic ad insertion moves the real size by a small amount. Set
`lengthTolerancePercent` to `0` for a strict comparison.

### Input

Every field has a default, so a run with an empty input `{}` works.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `feedUrls` | array | `["https://feeds.twit.tv/twit.xml"]` | 1 to 10 public RSS, Atom or JSON Feed URLs. |
| `itemsToCheck` | integer | `5` | How many recent items of each feed to probe. Maximum 50. |
| `checks` | array | all six | The checks that decide the gate. |
| `maxRedirects` | integer | `5` | Hop limit for one enclosure. The feed fetch keeps its own fixed limit of 5 hops. |
| `probeBytes` | integer | `65536` | Size of the ranged read. |
| `lengthTolerancePercent` | integer | `1` | Allowed difference between the declared length and the reported size. |
| `maxEnclosures` | integer | `100` | Hard cap on probed enclosures for one run. |
| `timeoutSeconds` | integer | `20` | Timeout for one HTTP request. |
| `maxFeedBytes` | integer | `10485760` | Hard cap on the bytes read from one feed. |
| `concurrency` | integer | `4` | How many probes run at the same time. |
| `baselineStoreName` | string | `feed-enclosure-baseline` | Named key-value store that holds the item identity history. |
| `updateBaseline` | boolean | `true` | Write this run into the baseline. Turn it off for a dry run. |
| `userAgent` | string | `FeedEnclosureGate/0.1 (+https://apify.com)` | Sent with every request. |

#### Safety

Private, loopback, link-local and reserved addresses are refused, for the feed
URL, for the enclosure URL and for **every redirect hop**. URLs that hold
credentials are refused. Feed XML is parsed with `defusedxml`, so an entity
expansion attack cannot reach the parser. Every read is bounded.

### Output

The dataset holds four record types, told apart by `recordType`.

**`feed`** — one for each feed URL: `feedStatus` (`OK`, `TARGET_REFUSED`,
`UNREACHABLE`, `UNPARSABLE`), `httpStatus`, `finalUrl`, `feedFormat`,
`itemsInFeed`, `itemsChecked`, `enclosuresFound`, `enclosuresProbed`,
`enclosuresBilled`, `identityChanges`, `guidStability`, `note`.

**`enclosure`** — one for each probed enclosure: `enclosureUrl`, `finalUrl`,
`redirectChain`, `redirectDepth`, `headStatus`, `rangeStatus`, `rangeSupported`,
`acceptRanges`, `contentType`, `declaredType`, `declaredLength`,
`reportedLength`, `contentRange`, `receivedBytes`, `tls`, `checks`,
`failedChecks`, `checkDetails`, `verdict` (`pass`, `fail` or `error`), and
`billed`, which is `true` only when a request left the Actor for this enclosure.

**`identity-change`** — one for each item whose GUID or enclosure URL changed
against the baseline: `itemKey`, `itemTitle`, `changedFields`, `previousGuid`,
`currentGuid`, `previousEnclosureUrl`, `currentEnclosureUrl`, `gatesPublish`.

**`summary`** — one for each run: `feedsRequested`, `feedsParsed`,
`feedsBlocked`, `enclosuresProbed`, `enclosuresPass`, `enclosuresFail`,
`enclosuresError`, `identityChanges`, `identityChangesGating`,
`enclosuresBilled`, `failedCheckCounts`, `checksEnabled`, and the gate verdict
`publishGatePass` (also given as `publish_gate_pass`).

`publishGatePass` is `true` only when every feed parsed, every probed enclosure
passed every enabled check, and no identity change was found by an **enabled**
check.

Only an enabled check decides the gate. An identity change is always reported,
but it fails the gate only when `checks` holds `guid_stability`. The record
field `gatesPublish` and the summary field `identityChangesGating` tell you
which changes count.

#### A failed gate is a successful run

A failed gate, an unreachable feed and a refused target are business verdicts.
They are written to the dataset and to the run status message, and the run ends
**SUCCEEDED**. A run only ends FAILED when the input itself cannot be read.

A feed URL that this Actor cannot use — a missing scheme, a scheme that is not
HTTP or HTTPS, credentials in the URL, a loopback or a private host, a port
outside 0-65535, a malformed IPv6 literal or a host name that the IDNA codec
refuses — gives one `feed` record with `feedStatus: "TARGET_REFUSED"`. The other feeds in the list
still run. An entry that is not a URL string at all is an input error.

### The baseline

The `guid_stability` check needs history. The Actor keeps, in the named
key-value store `baselineStoreName`, one entry for each feed that holds the GUID
and the enclosure URL of each item in the checked window. The item is followed
by its link, then by its title, because the GUID is the value under test.

The **first run of a feed writes the baseline and reports
`guidStability: "baseline-written"`.** From the second run on, a change is
reported. Use one store name for each show, so two shows do not share a history.

### Pricing (pay per event)

| Event | Unit | Counted |
| --- | --- | --- |
| `run-started` | One Actor run | Once, after the input is read and accepted. |
| `feed-parsed` | One feed fetched and parsed | Once for each feed that was fetched **and** parsed. A refused, unreachable or unparsable feed is not charged. |
| `enclosure-probed` | One enclosure checked with HEAD and one ranged GET | Once for each enclosure that got at least one request. An enclosure above `maxEnclosures`, and an enclosure URL that the target guard refused before any request, are not charged. |

A weekly run over one feed and five episodes charges one `run-started`, one
`feed-parsed` and five `enclosure-probed` events.

### Local use

```bash
uv sync
uv run pytest
uv run ruff check .
uv run python -m feed_enclosure_gate
```

### How to read a failure

- `range_support` fails on every episode — your media host or your CDN does not
  serve byte ranges. Seeking and resume are broken for every listener.
- `content_type` fails — the type in the feed and the type on the wire do not
  agree. Correct the feed, or correct the host.
- `declared_length_match` fails by a large amount — the feed was built from a
  different file than the one that is served.
- `redirect_depth` fails — the prefix chain of your analytics services is too
  long. Remove one hop.
- `guid_stability` fails — a publishing tool rewrote the item identity. Restore
  it before the next fetch of your subscribers, or every listener downloads the
  episode again.

# Actor input Schema

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

1 to 10 public RSS, Atom or JSON Feed URLs. Private, loopback and reserved addresses are refused, and so are URLs that hold credentials.

## `itemsToCheck` (type: `integer`):

How many recent items of each feed to probe. The items are taken in feed order, so the newest episodes come first.

## `checks` (type: `array`):

The checks that decide the publish gate. An empty list runs every check.

## `maxRedirects` (type: `integer`):

Hop limit for one enclosure. A chain that does not end inside this limit fails the redirect\_depth check, because a client with a lower limit cannot get the file.

## `probeBytes` (type: `integer`):

Size of the ranged read. The Actor asks for bytes 0 to this value minus one, and it never downloads the full media file.

## `lengthTolerancePercent` (type: `integer`):

How far the size reported by the server may differ from the length declared in the feed. Dynamic ad insertion moves this value by a small amount, so 0 is strict and 1 is the usual setting.

## `maxEnclosures` (type: `integer`):

Hard cap on the number of probed enclosures in one run. Enclosures above the cap are counted in the feed record and are not probed or charged.

## `timeoutSeconds` (type: `integer`):

Timeout for one HTTP request, both for a feed fetch and for an enclosure probe.

## `maxFeedBytes` (type: `integer`):

Hard cap on the bytes read from one feed document. A larger document is cut, and a cut document that does not parse is reported as unparsable.

## `concurrency` (type: `integer`):

How many enclosure probes run at the same time. Keep this low, so the media host is not put under load.

## `baselineStoreName` (type: `string`):

Name of the named key-value store that keeps the previous GUID and enclosure URL of each item. Use one name for each show, so the GUID stability check compares the correct history.

## `updateBaseline` (type: `boolean`):

Write the item identity of this run into the baseline store. Turn it off for a dry run that must not move the baseline.

## `userAgent` (type: `string`):

User-Agent header sent with every request, so your feed host and your media host can identify this Actor in their logs. ASCII characters only.

## Actor input object example

```json
{
  "feedUrls": [
    "https://feeds.twit.tv/twit.xml"
  ],
  "itemsToCheck": 5,
  "checks": [
    "range_support",
    "content_type",
    "declared_length_match",
    "redirect_depth",
    "https_only",
    "guid_stability"
  ],
  "maxRedirects": 5,
  "probeBytes": 65536,
  "lengthTolerancePercent": 1,
  "maxEnclosures": 100,
  "timeoutSeconds": 20,
  "maxFeedBytes": 10485760,
  "concurrency": 4,
  "baselineStoreName": "feed-enclosure-baseline",
  "updateBaseline": true,
  "userAgent": "FeedEnclosureGate/0.1 (+https://apify.com)"
}
```

# Actor output Schema

## `dataset` (type: `string`):

No description

# 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://feeds.twit.tv/twit.xml"
    ],
    "itemsToCheck": 5,
    "checks": [
        "range_support",
        "content_type",
        "declared_length_match",
        "redirect_depth",
        "https_only",
        "guid_stability"
    ],
    "maxRedirects": 5,
    "probeBytes": 65536,
    "lengthTolerancePercent": 1,
    "maxEnclosures": 100,
    "timeoutSeconds": 20,
    "maxFeedBytes": 10485760,
    "concurrency": 4,
    "baselineStoreName": "feed-enclosure-baseline",
    "updateBaseline": true,
    "userAgent": "FeedEnclosureGate/0.1 (+https://apify.com)"
};

// Run the Actor and wait for it to finish
const run = await client.actor("kingii98/podcast-and-newsletter-feed-enclosure-delivery-gate").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://feeds.twit.tv/twit.xml"],
    "itemsToCheck": 5,
    "checks": [
        "range_support",
        "content_type",
        "declared_length_match",
        "redirect_depth",
        "https_only",
        "guid_stability",
    ],
    "maxRedirects": 5,
    "probeBytes": 65536,
    "lengthTolerancePercent": 1,
    "maxEnclosures": 100,
    "timeoutSeconds": 20,
    "maxFeedBytes": 10485760,
    "concurrency": 4,
    "baselineStoreName": "feed-enclosure-baseline",
    "updateBaseline": True,
    "userAgent": "FeedEnclosureGate/0.1 (+https://apify.com)",
}

# Run the Actor and wait for it to finish
run = client.actor("kingii98/podcast-and-newsletter-feed-enclosure-delivery-gate").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://feeds.twit.tv/twit.xml"
  ],
  "itemsToCheck": 5,
  "checks": [
    "range_support",
    "content_type",
    "declared_length_match",
    "redirect_depth",
    "https_only",
    "guid_stability"
  ],
  "maxRedirects": 5,
  "probeBytes": 65536,
  "lengthTolerancePercent": 1,
  "maxEnclosures": 100,
  "timeoutSeconds": 20,
  "maxFeedBytes": 10485760,
  "concurrency": 4,
  "baselineStoreName": "feed-enclosure-baseline",
  "updateBaseline": true,
  "userAgent": "FeedEnclosureGate/0.1 (+https://apify.com)"
}' |
apify call kingii98/podcast-and-newsletter-feed-enclosure-delivery-gate --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,kingii98/podcast-and-newsletter-feed-enclosure-delivery-gate"
        }
    }
}
```

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/gdxow3E4tbwgd0nF7/builds/zW2qu2Sg3tYAL7EbC/openapi.json
