# Google SERP Scraper API, Keyword Rank Checker, Position Tracker (`snow_leo_data/google-serp-scraper`) Actor

Real destination URLs on 40 of 40 captured Google pages, where a plain fetch read 0 of 29. Google search results API: organic positions, AI Overview sources, related searches, optional People also ask, and a SERP rank tracker showing what moved between runs. Country and language are inputs.

- **URL**: https://apify.com/snow\_leo\_data/google-serp-scraper.md
- **Developed by:** [Snow Leo Data](https://apify.com/snow_leo_data) (community)
- **Categories:** SEO tools, AI, MCP servers
- **Stats:** 1 total users, 0 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.99 / 1,000 result rows

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

## Google Search Results Scraper (SERP) + Rank Tracker

Scrape Google search results by keyword and get back the **real destination
URL** of every result — `https://en.wikipedia.org/wiki/Web_scraping`, not
Google's `/goto?url=CAESSgHrOzAV…` redirect marker. Organic positions, related
queries, AI Overview sources and People-also-ask questions arrive in one table,
and a second run tells you what moved.

No browser, no API key, no external service. Pure HTTP through Apify Proxy.

#### Why the URL matters more than it sounds

Google today serves two different pages for the same query:

| | Lightweight page | JavaScript page |
|---|---|---|
| Size | 47–250 KB | 280 KB – 1.4 MB |
| Result link | `/url?q=https://real-address` | `/goto?url=CAESSgHrOzAV…` |
| Related queries | yes | yes |
| AI Overview sources | yes | yes |
| People also ask | no | yes |

The marker in the second column is encrypted. Measured on 35 such pages: the
full address could be recovered for only 3 results out of 7 — the rest exist
on the page as a truncated breadcrumb (`www.geeksforgeeks.org › blogs ›
what-is-web-s…`) and nothing else.

Which page you get is not decided by the query. It is decided by the proxy
session. Measured on 17.09.2026 over 81 captured pages:

| Request | Lightweight page |
|---|---|
| Without `gbv=1` | **0 of 29** |
| With `gbv=1`, no pinned session | **6 of 28** |
| With `gbv=1` and a fresh pinned session | **40 of 40** |

This Actor asks for the lightweight page with a fresh session, checks what came
back, and retries with another session if Google handed it markers instead of
addresses. Retries are not charged to you — you pay per delivered page.

#### What one page costs us, and why the price is what it is

A real cloud run, 1024 MB, 6 pages over 3 queries, measured end to end:
`0.00345` compute units, 0 Google-SERP-proxy requests, **$0.0016 of platform usage for the whole
run** — that is **$0.267 per 1,000 search result pages in platform cost**; the Actor itself is priced at **$1.99 per 1,000 rows**. The reference Actor
in this category charges $4.50 per 1,000 pages.

That number is only possible because of one measurement: **Google accepts
Apify's ordinary datacenter proxy for the lightweight page.** 24 requests out of 24, across 12 different queries, came back as readable results with no
challenge at all. The residential proxy, by contrast, hit Google's "unusual
traffic" page on the first try, both times. The paid Google SERP proxy is kept
as an automatic fallback, not as the normal path.

#### What you get per row

Twenty-two columns. The `type` column says which block a row came from.

| type | what it is | per page |
|---|---|---|
| `organic` | position, title, real URL, displayed link, domain, description, date | 6–10 |
| `related_search` | a "People also search for" keyword and its Google URL | 8–11 |
| `ai_overview_source` | a page Google's AI Overview cites, plus the overview text | 2–12, on 25 pages of 40 |
| `people_also_ask` | a question from the People-also-ask box | 4–5, opt-in |

Every row also carries the query, the page number, the `start` offset, country,
language, device, which version of Google's page it came from, the exact search
URL, and the timestamp.

#### Rank tracking, which is what this is really for

Turn on `trackPositions` and schedule the run. The Actor remembers where each
URL stood for each query and returns only what moved:

- `change_type` — `NEW`, `UP`, `DOWN`, `UNCHANGED` or `GONE`
- `previous_position` — where it stood last time
- `position_change` — `+6` means it climbed six places

`GONE` rows are the half that other scrapers leave out: a URL that dropped out
of the results entirely does not appear in today's SERP, so a plain scraper
simply stops mentioning it. Here it comes back as its own row with the position
it used to hold.

Unchanged positions are not returned by default, so a daily run does not bill
you for the same table every morning. Switch on `emitUnchanged` when you want
the full picture anyway.

#### How deep it goes

Google ignores the `num` parameter today. Measured: `num=20`, `num=30` and
`num=100` returned byte-identical pages with the same 6–10 results. Depth is
therefore the only lever, and `maxPagesPerQuery` walks `start=0`, `start=10`,
`start=20` and so on.

The ceiling is Google's own and depends on the query. Measured on `web
scraping`: results still arrived at `start=90`, and by `start=200` Google
answered "did not match any documents". The Actor stops as soon as the page
stops offering a next one, so you are not billed for empty pages beyond the
ceiling.

#### Filters that cannot silently empty your dataset

`domainContains`, `excludeDomains` and `titleContains` apply to organic rows.
A row is **never** dropped because a field is empty — absence of data is not a
mismatch. And if the filters do remove everything, the run says so in words
rather than handing you an empty table.

#### Every empty result is explained by name

A successful run with an empty dataset is the worst kind of failure: it looks
fine and you paid for nothing. So the run report always carries an
`empty_reason`, and the log says the same thing in a sentence:

- `no-queries` — nothing was typed in.
- `no-blocks-selected` — every result block was switched off.
- `filters-need-organic-results` — a domain or title filter with organic
  results switched off.
- `one-attempt-cannot-guarantee-links` — `requireResolvedLinks` with
  `maxAttempts: 1`.
- `charge-limit-reached` — your spending limit for the run was already used up.
- `google-returned-no-results` — Google itself said "did not match any
  documents".
- `filtered-out` — results were collected, your filters removed all of them.
- `links-not-resolved` — every attempt came back with encrypted markers.
- `nothing-changed` — rank tracking working as asked: no position moved.
- `blocked-by-google` — a challenge page on every attempt.
- `source-returned-nothing` — the one case worth reporting: the page could not
  be read as search results at all.

The first four are decided by your input alone, so they are answered **before a
single request is made** and cost you nothing.

#### Limitations, named honestly

- **No paid results (ads).** Through this data source Google returns the ad
  container empty: measured on `best running shoes` and `buy laptop online`,
  zero `/aclk` links and zero "Sponsored" labels on every capture. The Actor
  does not invent them and does not charge for them. If ads are what you need,
  another Actor in this category attempts them.
- **No total result count.** Google's own `result-stats` element came back as
  "About 0 results" through this route, so the field would be a lie. It is not
  in the output.
- **People also ask costs extra.** That block does not exist on the lightweight
  page at all — 0 questions across 40 captures — and the JavaScript page is not
  served to the datacenter proxy: without `gbv=1` it answers with a 92 KB redirect notice carrying no results. So the block is fetched through Apify's
  paid Google SERP proxy, is off by default, and is billed as its own event.
- **No Maps, Shopping, Images or News tabs.** This Actor reads the main web
  results tab only.
- **No start URLs.** You give keywords; the Actor builds the Google URL. Paste
  a ready-made Google URL and it will not be used.
- **`num` does nothing.** Not our choice — see above.
- **Roughly one result in twenty arrives with an encrypted link.** Measured:
  200 of 209 rows over 24 pages carried a real address. A retry with a fresh
  session clears it; with `requireResolvedLinks` on, anything left over is
  dropped rather than handed to you as a row with an empty `url`.

#### FAQ

**Do I need a proxy?** Yes, and Apify Proxy is enough. A direct request from
the Apify cloud gets Google's cookie-consent page and no results. The default
setting is the shared datacenter pool, which measured 24 readable pages out of
24\.

**Will Google block this?** It challenged the residential pool immediately and
the datacenter pool not once in 24 requests. If a challenge does appear, the
run switches to Apify's Google SERP proxy by itself, marks the page in the
report and carries on.

**Why is there a retry counter in the report?** Because honesty about cost is
part of the product. `requests_per_page` tells you how many HTTP requests a
delivered page actually took. In the verification run it was `1.0`.

**Can I search from another country?** Set `countryCode` (Google's `gl`) and
`languageCode` (`hl`). They change which results rank, not just the wording.

**Does mobile ranking differ?** Yes, and `device: mobile` asks Google for it
with a mobile user agent.

**How many results per page?** Six to ten. Google decides, and the `num`
parameter no longer changes it.

**What is the `rendering` column?** Which of Google's two pages a row came
from: `basic` is the lightweight page with real URLs, `rich` is the JavaScript
page, `history` marks a `GONE` row that comes from memory rather than from
today's search.

**Can I get only the columns I need?** `compactOutput` gives nine: position,
type, title, url, domain, query, page, change\_type, position\_change.
`excludeEmptyFields` drops empty columns entirely.

**How do I stop it from spending too much?** Set a spending limit on the run.
The Actor reads it at the start and stops at exactly that many pages instead of
crawling on unpaid.

**Is the output stable enough to schedule?** The parser works on the structure
of the page — container, link with a heading, caption, description — and not on
Google's shuffling class names. The verification suite runs against 8 real
captured pages, including one where Google encrypted every link, one where it
found nothing, and one challenge page.

#### Input in one look

```json
{
  "queries": ["best running shoes", "running shoes review"],
  "countryCode": "us",
  "languageCode": "en",
  "maxPagesPerQuery": 2,
  "trackPositions": true,
  "includePeopleAlsoAsk": false,
  "excludeDomains": ["pinterest.com"]
}
```

#### Output in one look

```json
{
  "position": 1,
  "type": "organic",
  "title": "The 15 Best Running Shoes of 2026, Tested by Runner's World",
  "url": "https://www.runnersworld.com/gear/a19663621/best-running-shoes/",
  "displayed_link": "www.runnersworld.com › Running Gear › Running Shoes",
  "domain": "runnersworld.com",
  "description": "For new runners we recommend a shoe like the Brooks Ghost…",
  "date": "Aug 3, 2026",
  "query": "best running shoes",
  "page": 1,
  "start": 0,
  "country_code": "us",
  "language_code": "en",
  "device": "desktop",
  "rendering": "basic",
  "change_type": "UP",
  "previous_position": 4,
  "position_change": 3
}
```

# Actor input Schema

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

Keywords exactly as you would type them into Google. One run takes as many as you like; Google operators such as site:, intitle: and "quoted phrase" work.

## `countryCode` (type: `string`):

Two-letter country code Google should search from (the gl parameter): us, gb, de, fr, in, au, br, ca. It changes which results rank, not only the language.

## `languageCode` (type: `string`):

Two-letter language code (the hl parameter): en, de, fr, es, pt, ru, ja. Affects snippets, related queries and date wording.

## `maxPagesPerQuery` (type: `integer`):

How deep to page. One page is roughly 10 organic results — measured 6 to 10 across 40 live pages. Google ignores the num parameter today, so depth is the only way to get more. Measured ceiling: results run out between start=100 and start=200, after which Google answers "did not match any documents".

## `device` (type: `string`):

Desktop or mobile ranking. Mobile results differ, which matters when you track positions for a mobile-first site.

## `safeSearch` (type: `boolean`):

Ask Google to filter explicit results (the safe=active parameter).

## `dateRange` (type: `string`):

Restrict results by publication date (Google's own tbs=qdr filter).

## `includeOrganic` (type: `boolean`):

Position, title, real destination URL, displayed link, description, domain and publication date.

## `includeRelatedQueries` (type: `boolean`):

The "People also search for" block: 8 to 11 keyword ideas per page, each with its own Google search URL.

## `includeAiOverview` (type: `boolean`):

The pages Google's AI Overview cites, plus the overview text itself. Measured: present on 25 of 40 live pages, 2 to 12 sources each.

## `includePeopleAlsoAsk` (type: `boolean`):

Questions from the "People also ask" box. Off by default because it costs one extra page load per query: measured on 40 lightweight pages, the block was on none of them, and it only appears on Google's JavaScript version.

## `trackPositions` (type: `boolean`):

Remember where each URL ranked for each query and return only what moved. Every row gets change\_type (NEW / UP / DOWN / UNCHANGED / GONE), previous\_position and position\_change. URLs that dropped out of the results come back as GONE rows.

## `emitUnchanged` (type: `boolean`):

With position tracking on, return rows that did not move as well. Off means you are not charged twice for the same position.

## `domainContains` (type: `array`):

Keep organic rows whose domain contains any of these strings. Empty means keep all. Filters apply to organic rows only, and a row is never dropped for a field Google left empty.

## `excludeDomains` (type: `array`):

Drop organic rows whose domain contains any of these strings — useful to remove your own site, or aggregators.

## `titleContains` (type: `array`):

Keep organic rows whose title contains any of these strings.

## `maxItems` (type: `integer`):

Hard cap on rows written to the dataset. It limits the OUTPUT, not the crawl: pages are still walked so filters have something to work on.

## `maxAttempts` (type: `integer`):

Google serves two different pages for the same query, and only one of them carries real destination URLs; which one you get is decided by the proxy session. Measured 17.09.2026: 40 of 40 pages came back readable, 3 of them with Google's encrypted link markers, which a fresh session fixes. Attempts are not charged to you — you pay per delivered page.

## `requireResolvedLinks` (type: `boolean`):

Drop organic rows when Google returned its encrypted /goto marker instead of an address, rather than handing you a row with an empty url. Title, displayed link and domain would still be there without it.

## `serpProxyFallback` (type: `boolean`):

If Google challenges the datacenter address, retry through Apify's Google SERP proxy. Measured: 24 of 24 datacenter requests went through without a challenge, so this is a safety net, not the normal path.

## `compactOutput` (type: `boolean`):

Nine columns instead of twenty-two: position, type, title, url, domain, query, page, change\_type, position\_change. Built for AI agents and for rank-tracking spreadsheets.

## `excludeEmptyFields` (type: `boolean`):

Leave empty columns out of each row entirely, instead of returning them as null.

## `proxyConfiguration` (type: `object`):

Apify Proxy is required: Google answers a consent page to a direct request from the cloud. The default is the shared datacenter pool, which measured 24 successful pages out of 24. The Google SERP group is used automatically as a fallback when a datacenter address is challenged.

## Actor input object example

```json
{
  "queries": [
    "apify web scraping"
  ],
  "countryCode": "us",
  "languageCode": "en",
  "maxPagesPerQuery": 1,
  "device": "desktop",
  "safeSearch": false,
  "dateRange": "any",
  "includeOrganic": true,
  "includeRelatedQueries": true,
  "includeAiOverview": true,
  "includePeopleAlsoAsk": false,
  "trackPositions": false,
  "emitUnchanged": false,
  "domainContains": [],
  "excludeDomains": [],
  "titleContains": [],
  "maxItems": 0,
  "maxAttempts": 3,
  "requireResolvedLinks": true,
  "serpProxyFallback": true,
  "compactOutput": false,
  "excludeEmptyFields": false,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

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

All collected rows

# 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": [
        "apify web scraping"
    ],
    "countryCode": "us",
    "languageCode": "en",
    "maxPagesPerQuery": 1,
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("snow_leo_data/google-serp-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": ["apify web scraping"],
    "countryCode": "us",
    "languageCode": "en",
    "maxPagesPerQuery": 1,
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("snow_leo_data/google-serp-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": [
    "apify web scraping"
  ],
  "countryCode": "us",
  "languageCode": "en",
  "maxPagesPerQuery": 1,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call snow_leo_data/google-serp-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,snow_leo_data/google-serp-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/Hvq3jqD0YHsXjcDCe/builds/1J77bvWNQIXtEMiC3/openapi.json
