# YouTube Search Scraper - Videos, Views & Channels (`neverempty/youtube-search-scraper`) Actor

For content research, trend tracking and AI datasets: search YouTube and get one row per video with title, channel, views as text and as a number, length, thumbnail and description. YouTube never says a search found nothing - it returns unrelated videos - so every row carries what was searched.

- **URL**: https://apify.com/neverempty/youtube-search-scraper.md
- **Developed by:** [NeverEmpty](https://apify.com/neverempty) (community)
- **Categories:** Social media, Videos, Developer tools
- **Stats:** 1 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.83 / 1,000 video returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

## YouTube Search Scraper - videos, views and channels

**For competitor and content research, trend spotting, building a video dataset, and feeding an AI pipeline**: search YouTube and get one row per video — title, channel, view count, length, thumbnail and description.

**The part that makes this one different is uncomfortable to say out loud: YouTube never tells you a search found nothing. It quietly returns unrelated videos instead.** Searching for `zzzqqqxxnotarealquery12345` returns Japanese gaming clips and a live stream, with no "no results" message anywhere. A scraper that hands those to you as "search results" is not lying on purpose — it simply cannot tell. This one can, and says so.

**It also tells you when YouTube changed your query.** Search for `qwertyuiopasdfghjkl zxcvbnm 9988` and YouTube decides you meant `qwertyuiop asdfghjkl zxcvbnm 9988` and returns results for *that*. Every row carries what was actually searched.

**Every number arrives twice: as YouTube writes it and as a number.** View count `155,353 views` and `155353`; length `4:53` and `293` seconds; the publish date exactly as shown (`6 months ago`) **plus** `publishedApproxDaysAgo` (`182.6`) — named "approx" because a relative phrase is not a date and pretending otherwise invents a fact. A live stream shows `1,234 watching` rather than a view count, so its `viewCount` is left empty rather than filled with the wrong number.

**Sorting actually works, and that was measured:** the default first result had **155,353** views; with `view_count` the first result had **4,298,465**.

No API key, no login. Export as JSON, CSV or Excel.

### What every row carries

- **The video**: id, title, URL, description snippet, thumbnail
- **The channel**: name, channel id, channel URL, and whether it is verified
- **The numbers**: view count as YouTube writes it (`155,353 views`) **and as a number** (`155353`),
  length as text (`4:53`) **and in seconds** (`293`)
- **What it is**: `isShort` (60 seconds or less), `isLive`
- **Where it came from**: the query, the page, and the rank on that page
- **How well it matches**: `matchedTermCount` / `queryTermCount` and `looksUnrelated` - how many of your
  search terms actually appear in the title, description or channel name

***

### The three things this Actor refuses to fake

**1. It never presents unrelated videos as results.** When every returned video contains none of your
search terms, you get a free row saying so, together with YouTube's own estimate of how many videos
match. For a real query that estimate is in the millions; for a nonsense query it was **44**.

**2. It tells you when YouTube changed your query.** Search for `qwertyuiopasdfghjkl zxcvbnm 9988` and
YouTube decides you meant `qwertyuiop asdfghjkl zxcvbnm 9988` and returns results for *that*. Every row
carries both `query` (what you asked) and `searchedFor` (what YouTube used), and a free row spells it out.

**3. It does not invent a publish date.** YouTube only shows relative times. You get `publishedText`
("6 months ago") exactly as shown, plus `publishedApproxDaysAgo` (182.6) - named "approx" because that
is what it is. No fabricated timestamps.

Live streams show `1,234 watching` rather than a view count, so their `viewCount` is empty rather than
filled with a wrong number, and they are not silently dropped by a minimum-views filter.

***

### Example output (one row, shortened)

```json
{
  "query": "web scraping tutorial",
  "searchedFor": null,
  "rank": 1,
  "videoId": "hHQlcnubuFI",
  "title": "Learn Web Scraping in 5 Minutes (NO PRIOR KNOWLEDGE)",
  "url": "https://www.youtube.com/watch?v=hHQlcnubuFI",
  "channelName": "CodeHead",
  "channelId": "UCFVteOob_YXJHPaGTqlDV2Q",
  "channelUrl": "https://www.youtube.com/@codehead01",
  "viewCountText": "155,353 views",
  "viewCount": 155353,
  "lengthText": "4:53",
  "lengthSeconds": 293,
  "publishedText": "6 months ago",
  "publishedApproxDaysAgo": 182.6,
  "isShort": false,
  "isLive": false,
  "channelVerified": false,
  "matchedTermCount": 2,
  "queryTermCount": 3,
  "looksUnrelated": false,
  "thumbnailUrl": "https://i.ytimg.com/vi/hHQlcnubuFI/hq720.jpg"
}
```

***

### Input

| Field | Key | What it does |
|---|---|---|
| **Search queries** | `queries` | One or more searches; each row says which query produced it |
| **Results per query** | `maxResultsPerQuery` | Pages are followed automatically (~16-20 per page) |
| **Maximum records** | `maxRecords` | The most rows this run may return and charge for |
| **Sort by** | `sortBy` | relevance / upload\_date / view\_count / rating |
| **Drop unrelated** | `dropUnrelated` | Remove videos containing none of your search terms |
| **Exclude / only Shorts** | `excludeShorts`, `onlyShorts` | 60 seconds or less counts as a Short |
| **Minimum views** | `minViews` | Live streams have no view count and are not compared |
| **Maximum length** | `maxLengthSeconds` | 0 = no limit; videos with unknown length are kept |
| **Published within** | `publishedWithinDays` | Compares the approximation, see above |
| **Keywords / match / exclude** | `keywords`, `keywordMatch`, `excludeKeywords` | Text filters over title, description, channel |
| **Only new since last run** | `monitoringMode` | For a schedule watching a query |
| **Forget what was returned** | `resetMonitoringState` | Clears that memory once |
| **Maximum requests** | `maxRequests` | Hard ceiling on requests sent to YouTube |
| **Use a proxy** | `useProxy` | Off by default - a plain connection works |

***

### Frequently asked

**How many results can I get per query?** YouTube pages the results and this Actor follows the pages.
Page 1 and page 2 were measured to share **zero** videos, so paging genuinely advances rather than
repeating the first page.

**Does sorting actually work?** Yes - measured: the default first result had 155,353 views, and with
`view_count` the first result had 4,298,465.

**Does it work with non-English queries?** Yes. A Japanese query was measured returning correct
Japanese results with view counts and lengths intact.

**Do I need a proxy?** No. A plain connection returns results. The proxy option exists for the case
where YouTube answers with a bot check, and that case is reported as its own free row rather than as
an empty result.

**Can I get only Shorts?** Barely. Measured on 8 September 2026: YouTube returns Shorts in a separate shelf that this Actor does not read, so a search filtered to Shorts usually comes back empty. The filter exists for the occasional short video that does appear in the normal results.

**Why is `channelId` sometimes empty?** It is read only from the video's own owner field. Search pages
also contain ids belonging to suggested channels; taking the first id on the page would attribute a
video to the wrong channel, so this Actor leaves the field empty instead of guessing.

***

### Pricing

Pay per event: you are charged for each **video returned**. Rows that explain a problem - a rewritten
query, a search whose results contain none of your terms, a bot check, an unreadable answer, a filter
that matched nothing, a result cut by your maximum - are never charged.

# Actor input Schema

## `queries` (type: `array`):

What to search for on YouTube. Each query is run separately and every row says which query produced it.

## `maxResultsPerQuery` (type: `integer`):

How many videos to collect for each query. YouTube returns roughly 16-20 per page and this Actor follows the pages for you.

## `maxRecords` (type: `integer`):

The most rows this run may return and charge for. When the limit cuts the result, rows are taken evenly from every query, so no query disappears completely, and a free row says what was left out.

## `sortBy` (type: `string`):

relevance is YouTube's default. upload\_date, view\_count and rating use YouTube's own sort filters.

## `dropUnrelated` (type: `boolean`):

YouTube never returns an empty result: when nothing matches, it quietly serves unrelated videos. Every row already carries how many of your terms it contains; turn this on to remove the ones that contain none.

## `excludeShorts` (type: `boolean`):

Remove videos that are 60 seconds or less. Videos whose length YouTube does not show (live streams) are kept. In practice few Shorts appear in search results at all.

## `onlyShorts` (type: `boolean`):

Keep only videos that are 60 seconds or less. Note (measured 2026-09-08): YouTube search results rarely contain Shorts - they are returned in a separate shelf this Actor does not read - so this filter often leaves nothing.

## `minViews` (type: `integer`):

Live streams show "watching" instead of a view count, so their view count is empty and they are not compared against this.

## `maxLengthSeconds` (type: `integer`):

0 means no limit. Videos whose length YouTube does not show (live streams) are kept rather than dropped.

## `publishedWithinDays` (type: `integer`):

0 means no limit. YouTube only shows relative times ("6 months ago"), so this compares an approximation - the field is named publishedApproxDaysAgo for that reason, and the original wording is kept in publishedText.

## `keywords` (type: `array`):

Keep only videos whose title, description or channel name contain these words.

## `keywordMatch` (type: `string`):

any = at least one keyword must appear. all = every keyword must appear.

## `excludeKeywords` (type: `array`):

Drop videos containing any of these words.

## `monitoringMode` (type: `boolean`):

Remembers which video ids this Actor has already returned and skips them next time. Filtering happens before this, so a video you filtered out is not held back for later.

## `resetMonitoringState` (type: `boolean`):

Clears the memory of what was already returned, once, so the next run returns everything again.

## `maxRequests` (type: `integer`):

A hard ceiling on how many requests this run may send to YouTube, across all queries and pages.

## `useProxy` (type: `boolean`):

Off by default: a plain connection works and is faster and cheaper. Turn this on if you see bot-check rows in the output.

## Actor input object example

```json
{
  "queries": [
    "web scraping tutorial"
  ],
  "maxResultsPerQuery": 50,
  "maxRecords": 200,
  "sortBy": "relevance",
  "dropUnrelated": false,
  "excludeShorts": false,
  "onlyShorts": false,
  "minViews": 0,
  "maxLengthSeconds": 0,
  "publishedWithinDays": 0,
  "keywordMatch": "any",
  "monitoringMode": false,
  "resetMonitoringState": false,
  "maxRequests": 40,
  "useProxy": false
}
```

# Actor output Schema

## `results` (type: `string`):

One row per video: the query that produced it and its rank on the page, the video id, title and URL, the channel name, id and URL, the description snippet, the view count both as YouTube writes it and as a number, the length as text and in seconds, whether it is a Short or a live stream, whether the channel is verified, the thumbnail URL, and the relative publish time together with an approximate number of days. Every row also carries how many of your search terms appear in its title, description or channel name - because YouTube never reports an empty result and quietly returns unrelated videos instead. A query that YouTube silently rewrote, a search whose results contain none of your terms, a bot check, an unreadable answer, filters that matched nothing and rows left out by your maximum each come back as their own row and are not 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 = {
    "queries": [
        "web scraping tutorial"
    ],
    "maxResultsPerQuery": 50,
    "maxRecords": 200,
    "sortBy": "relevance",
    "maxRequests": 40
};

// Run the Actor and wait for it to finish
const run = await client.actor("neverempty/youtube-search-scraper").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 = {
    "queries": ["web scraping tutorial"],
    "maxResultsPerQuery": 50,
    "maxRecords": 200,
    "sortBy": "relevance",
    "maxRequests": 40,
}

# Run the Actor and wait for it to finish
run = client.actor("neverempty/youtube-search-scraper").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 '{
  "queries": [
    "web scraping tutorial"
  ],
  "maxResultsPerQuery": 50,
  "maxRecords": 200,
  "sortBy": "relevance",
  "maxRequests": 40
}' |
apify call neverempty/youtube-search-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,neverempty/youtube-search-scraper"
        }
    }
}
```

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/pwGI4oBqfroSRmZOd/builds/kRy4ifiGh4ZBMpp5M/openapi.json
