--- name: youtube-channel-feed-scraper description: Read the latest uploads from YouTube channels and playlists via the Apify Actor arman-bd/youtube-channel-feed-scraper. Returns one record per video with video ID, title, full description, publish and update timestamps, canonical URL, thumbnail, view count, star rating and the owning channel's ID and title, with optional date and title-keyword filtering. Use when a task needs new-upload alerting, a multi-creator digest, brand monitoring on video titles, or upload-cadence research. Not for search, historical back-catalogues beyond the newest 15 uploads, comments, subscriber counts or transcripts. --- # YouTube Channel Feed Scraper Apify Actor `arman-bd/youtube-channel-feed-scraper`. Give it channels, handles or playlists and get one dataset record per video from each feed. It runs without credentials. The window is small and always current, which makes this a monitoring tool rather than an archive tool. ## When to use it - New-upload alerting: schedule it and diff on `videoId` to catch uploads within minutes. - A digest or newsletter across many creators, narrowed by title keyword. - Brand monitoring: watch for your product appearing in an upload title. - Upload-cadence and early view-velocity research across a set of channels. - Replacing a polling pipeline that keeps running out of quota elsewhere. ## When not to use it - Anything older than a channel's newest 15 uploads. That window is the source's, not the Actor's, and no input widens it. - Search, discovery or trending. You bring the channel list. - Comments, likes, subscriber counts, transcripts, captions or video files. Only `viewCount` and a star `rating` are published in the feed. - Exact live view counts. The feed's counters refresh on their own schedule and lag the video page. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/youtube-channel-feed-scraper').call({ channels: ['@mkbhd', 'UCXuqSBlHAE6Xw-yeJA0Tunw'], sinceDate: '2026-07-01', keywordFilter: ['review'], maxVideosPerChannel: 5, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const { value: summary } = await client .keyValueStore(run.defaultKeyValueStoreId) .getRecord('RUN_SUMMARY'); ``` One-shot over HTTP, when you want the rows back in the same request: ```bash curl -X POST "https://api.apify.com/v2/acts/arman-bd~youtube-channel-feed-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"channels":["@mkbhd"],"maxVideosPerChannel":5}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/youtube-channel-feed-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `channels` | string[] | yes | | Channel IDs starting `UC`, `@handles`, playlist IDs starting `PL`, `UU`, `LL`, `FL`, `OL` or `RD`, legacy vanity or user names, or any site URL wrapping one of those, including a feed URL. Mixed forms are fine. Deduplicated after parsing. A value that parses to none of these is logged and dropped, not failed. | | `maxVideosPerChannel` | integer | no | `0` | Cap on videos saved per channel, applied **after** the other filters. `0` means no cap. The feed never holds more than 15 uploads anyway. | | `sinceDate` | string | no | `""` | Keep only videos published on or after this. `YYYY-MM-DD` or a full ISO timestamp. An unparseable value aborts the run. | | `keywordFilter` | string[] | no | `[]` | Keep only videos whose **title** contains at least one of these terms, case-insensitive. The description is not searched. Empty keeps everything. | **None of the filters save any work.** One channel is one request that returns the whole feed, and `sinceDate`, `keywordFilter` and `maxVideosPerChannel` are all applied to the parsed result afterwards. They exist to keep the dataset small, not to make the run cheaper or faster. The only real cost lever is the channel list itself, and the one form that costs extra is a handle or vanity name: each distinct one needs a page fetch to resolve into a channel ID before its feed can be read, cached for the rest of the run. Resolve your handles to `UC…` IDs once and store them if you are scheduling this frequently. ## Output One record per video that survived the filters. | Field | Type | Notes | |---|---|---| | `channelId` | string \| null | Read per video, not from the feed header, so a playlist spanning several creators attributes each video correctly. | | `channelTitle` | string \| null | Same, per video. | | `videoId` | string | The join and dedup key. | | `title` | string \| null | Entities decoded to real text. | | `description` | string \| null | Full description as published in the feed, newlines preserved. | | `publishedAt` | string \| null | Upload time. ISO 8601 with a numeric offset, for example `2026-07-27T15:38:21+00:00`, not a trailing Z. | | `updatedAt` | string \| null | Last time the feed entry changed. Not an edit history. | | `videoUrl` | string | Canonical link as reported, so a short stays on its shorts path rather than being rewritten. | | `thumbnailUrl` | string \| null | The high-quality default thumbnail. | | `viewCount` | number \| null | Views at the moment the feed was read. Lags the video page. | | `rating` | object \| null | `{ average, count }` from the star rating, or `null` when the feed carries none. | | `scrapedAt` | string | Run timestamp, ISO 8601. | A real record, description trimmed: ```json { "channelId": "UCBJycsmduvYEL83R_U4JriQ", "channelTitle": "Marques Brownlee", "videoId": "_xjxwl1zLMc", "title": "Framework 13 Pro: The Modular Laptop is Real!", "description": "The modular laptop for Linux users. Color me impressed.\n\nMKBHD Merch: …", "publishedAt": "2026-07-27T15:38:21+00:00", "updatedAt": "2026-08-06T05:26:11+00:00", "videoUrl": "https://www.youtube.com/watch?v=_xjxwl1zLMc", "thumbnailUrl": "https://i4.ytimg.com/vi/_xjxwl1zLMc/hqdefault.jpg", "viewCount": 2983132, "rating": { "average": 5, "count": 95523 }, "scrapedAt": "2026-08-06T11:47:09.151Z" } ``` ## RUN_SUMMARY Written to the run's key-value store under the key `RUN_SUMMARY`. **Read it**, and compare `channelsRequested` against the length of the list you sent. ```json { "channelsRequested": 3, "channelsFailed": 1, "failures": [{ "source": "@nosuchchannelhere", "error": "not found (404) …" }], "videosSaved": 28, "filters": { "maxVideosPerChannel": 0, "sinceDate": "2026-07-01", "keywordFilter": ["review"] }, "finishedAt": "2026-08-06T11:47:09.160Z" } ``` `channelsRequested` counts the entries that parsed into something usable, after deduplication. An input string the parser did not recognise is dropped with a log warning and appears in neither this count nor `failures`, so a shorter `channelsRequested` than your input list means malformed entries, not failures. `channelsFailed` above zero means the run is partial, with one `{ source, error }` each. `videosSaved` of `0` with no failures means the filters matched nothing, which is the normal outcome of a narrow `sinceDate` on a quiet week. ## Behaviour to plan around - **Fifteen uploads per feed is a hard ceiling.** `maxVideosPerChannel` can only lower it. For continuous coverage, schedule the run more often than the channel publishes, and accumulate into your own dataset. A channel that posted 20 videos since your last run has already lost five from the window. - **A channel with an empty feed is recorded as a failure.** A brand-new or dormant channel comes back as `feed contained no videos` in `failures`, not as a successful run with zero rows. Do not treat every entry in `failures` as a broken input. - **Unrecognised input vanishes silently.** A misspelled handle that still parses will fail with a not-found; one that does not parse at all is only mentioned in the run log. Check `channelsRequested` matches what you sent. - **`keywordFilter` reads the title only.** A product named in the description but not the title is filtered out. Pull the feed unfiltered and search `description` yourself if that matters. - **Filters combine with AND, and the cap comes last.** A video must be new enough and match a keyword; only then does `maxVideosPerChannel` trim. Feeds arrive newest first, so a cap keeps the newest survivors. - **`channelId` is per video, not per feed.** The feed header's own channel identifier is unreliable, so the per-entry value and the canonical channel link are used instead. Grouping by `channelId` is therefore safe even on mixed playlists. - **`publishedAt` carries a numeric offset.** Parse it as ISO 8601 rather than string-comparing it against a `Z`-suffixed timestamp. - **Handles cost an extra page fetch each.** Resolution happens once per distinct handle per run and is cached. `UC…` IDs and playlist IDs go straight to the feed. - **One bad channel never aborts the run.** Failures are collected and the rest continue; the Actor only throws when every source failed. A not-found is permanent and is not retried, while transient errors get four attempts with exponential backoff and jitter. - **`viewCount` and `rating` can both be `null`.** Not every feed entry carries the media statistics block. Treat a missing count as unknown, never as zero. ## Recipes **New-upload alerting.** Schedule this every 15 minutes across a watchlist. ```json { "channels": ["@mkbhd", "@LinusTechTips", "UCXuqSBlHAE6Xw-yeJA0Tunw"], "maxVideosPerChannel": 0 } ``` Keep the set of `videoId` values you have already seen and alert only on new ones. Do not filter by `sinceDate` here: an upload backdated or published while your scheduler slipped would be missed. **Keyword digest for a newsletter.** ```json { "channels": ["@LinusTechTips", "https://www.youtube.com/@mkbhd"], "sinceDate": "2026-07-01", "keywordFilter": ["review", "unboxing"], "maxVideosPerChannel": 5 } ``` Group by `channelTitle`, sort by `publishedAt` descending, and use `thumbnailUrl` and the first paragraph of `description` for each item. **Playlist digest across creators.** ```json { "channels": ["PLj8e95eaxiB9goOAvINIy4Vt3mlWQJxys"] } ``` Group by `channelId` to see which creators the playlist actually draws from. The per-video attribution is correct even though every row came from one feed. **Upload-cadence comparison.** Run daily and accumulate. ```json { "channels": ["UCBJycsmduvYEL83R_U4JriQ", "UCXuqSBlHAE6Xw-yeJA0Tunw"], "maxVideosPerChannel": 0 } ``` Deduplicate on `videoId` across runs, then take the gaps between consecutive `publishedAt` values per `channelId` for cadence, and `viewCount` against age for early velocity.