# SEO: Ranking Keywords & Internal Links (`apizilla/seo-ranking`) Actor

Find keywords Google shows but nobody clicks: striking distance, low CTR, cannibalization and content gaps from Search Console data. Get an internal link plan, content briefs and a did-it-work tracker.

- **URL**: https://apify.com/apizilla/seo-ranking.md
- **Developed by:** [APIzilla](https://apify.com/apizilla) (community)
- **Categories:** Automation
- **Stats:** 1 total users, 0 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $5.00 / 1,000 opportunities

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.

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

## SEO Ranking: Ranking Keywords & Internal Links

**Find the keywords Google already shows your site for, but nobody clicks. Get a ranked fix list, an internal link plan and content briefs. Run it every week and see what worked.**

Your site was shown 6,200 times for "swe bench benchmark problems" and got 0 clicks, because it ranks at position 38. This Actor finds those keywords from your **Google Search Console** data, tells you what to do for each one, and tells you which of your pages should link to the page you want to push.

It is built as an API: send the data, get JSON, CSV and a readable report back. You can call it from Make, n8n, Zapier, a cron job or a daily Apify schedule. It does **not** use Google Keyword Planner (see the FAQ).

### What you get

| Result | What it is |
| --- | --- |
| **Opportunities** (dataset) | One row per keyword group, with page, impressions, clicks, CTR, position, the extra clicks if it reached the top 3, a priority score from 0 to 100 and the action to take. |
| **Striking distance / no-click** | Keywords at position 8 to 50 with many impressions and almost no clicks. Action: optimize the page. |
| **Low CTR** | Keywords on page 1 whose CTR is far below normal for that position. Action: rewrite the title and meta description. |
| **Cannibalization** | Several of your pages compete for the same queries, with each page's share. Action: consolidate or differentiate. |
| **Content gaps** | Queries where no page on your site is a good match. Action: create a new guide. The planned URL is included. |
| **Internal link plan** | For each target page: which of your pages (ranked by their own traffic) should link to it, the **exact anchor text** and **where to put it** (an existing sentence to link, or a sentence to add). Links that already exist are skipped. Orphan pages (no internal links) are flagged and get links too. Table in the report, `internal-links.csv` and JSON. |
| **Content briefs** | Target and secondary keywords, title and meta description options, H2/H3 outline, questions to answer, word count target, internal links to add, schema suggestions. |
| **Guide drafts** (optional) | With your **own** OpenAI, Anthropic or Gemini key the Actor also writes a full draft from each brief. We never use a key of ours. |
| **Did it work?** | Run again later: the Actor compares with the last run and reports fixed, improved, regressed and new opportunities, pages you edited and links you added, with clicks per day before and now. |
| **Report** | `report.html` and `report.md` in the key-value store: one readable page with everything above. |

### How to use it

#### 1. Easy way: a Search Console CSV export (2 minutes)

1. In Search Console open **Performance**, set 3 months, click **Export** and download the CSV or ZIP.
2. Open the Actor, enter your **Site URL**, upload the file in **Search Console CSV or ZIP files** (or paste the text in **Or paste the CSV**), click **Start**.

A plain export of the *Queries* tab works, but the Search Console web export has **no page per query**. For the best result (exact pages and cannibalization) use a table with a **Query** and a **Page** column: the API (below), Looker Studio, or a Google Sheet with the Search Analytics add-on. With queries only, each query is matched to the closest page found in the crawl.

#### 2. Automatic way: the Search Console API (for daily or weekly schedules)

Add `googleServiceAccountJson` (a Google Cloud service account key) and add the service account's email as a user of your property in Search Console (**Settings > Users and permissions**, "Restricted" is enough). Or use OAuth: `googleClientId`, `googleClientSecret`, `googleRefreshToken`. The Actor reads query + page data (28 days by default, up to 480) with the `searchAnalytics.query` endpoint, with paging, and picks the right property for your site (domain properties first). Secrets are stored encrypted and never saved in the results.

Then create an Apify **schedule** (for example every Monday). The same tracker name keeps one baseline and the report starts to show progress from the second run.

#### 3. As an API

```bash
curl -X POST "https://api.apify.com/v2/acts/apizilla~seo-ranking/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"siteUrl":"https://example.com","gscCsvUrls":["https://example.com/gsc-export.csv"],"maxPages":100}'
```

**Standby mode** (answer in one request, no run to poll): `POST https://apizilla--seo-ranking.apify.actor/analyze` with the same JSON and an `Authorization: Bearer <token>` header. The response has `summary`, `opportunities`, `progress`, `internalLinks`, `briefs` and `orphanPages`.

### How it decides (no black box)

- **Expected CTR by position** is a generic curve (about 28% at position 1, 10% at 3, 1.8% at 10, under 1% after position 15). It is an assumption, not Google data. Branded queries and rich results change real CTR. Use **Brand terms to ignore** to skip your own brand.
- **Extra clicks** = impressions x expected CTR at the target position (3 by default) minus current clicks. **Realistic extra clicks** multiply this by how likely the move is (closer to page 1 = more likely). The **priority score** is a log scale of the realistic extra clicks.
- **Query clusters**: queries that differ by word order, plural or small words are grouped (stemming, no AI), so you get one row per topic.
- **Content gap** = no crawled page matches the query well (weighted word overlap with title, H1, headings and text), and the query ranks past position 8 or on a weak page.
- **Internal links** come from pages that already get traffic (from your own data), match the topic, and do not link to the target yet. Anchor text comes from the query and existing text on the page.
- **Crawl**: sitemap first, then links. It respects robots.txt, reads HTML pages only, 3 at a time with a short delay, and identifies itself as `SeoRankingBot`. Pages with Search Console impressions are read first.
- Google needs **about 3 to 4 days** to register a page change. Schedule runs at least 4 days apart.

### Input (short)

| Field | Meaning |
| --- | --- |
| `siteUrl` | Required. `https://example.com`, or `sc-domain:example.com` |
| `gscCsvUrls` / `gscCsvText` | CSV, ZIP or a public Google Sheet link, or pasted text |
| `googleServiceAccountJson` + `gscProperty` | API access (property optional) |
| `brandTerms` | Words to skip (your brand) |
| `opportunityTypes` | Pick which of the four types to find |
| `minImpressions`, `minPosition`, `maxPosition`, `targetPosition` | Thresholds (100, 8, 50, 3) |
| `crawlSite`, `maxPages` | Crawl on/off and page limit (200) |
| `maxBriefs`, `maxLinksPerTarget`, `maxLinkTargets` | How many briefs and links to produce |
| `llmProvider`, `llmApiKey`, `llmModel`, `maxDrafts` | Your own AI key for guide drafts |
| `trackChanges`, `trackerName`, `resetBaseline` | Progress tracking between runs |

### Pricing (pay per event)

You pay for what you get. A **maximum cost per run** is always respected: the Actor stops adding results when it is reached and trims the optional parts first (drafts, then briefs, then links). The crawl never takes more than 40% of your maximum.

| Event | Price | Per 1,000 |
| --- | --- | --- |
| Actor start (once per run) | $0.003 | |
| Opportunity | $0.005 | $5.00 |
| Page crawled | $0.001 | $1.00 |
| Internal link | $0.001 | $1.00 |
| Content brief | $0.010 | $10.00 |
| Guide draft (your own AI key) | $0.030 | $30.00 |
| Progress update | $0.002 | $2.00 |

Example: 34 opportunities, 30 pages crawled, 4 links and 5 briefs cost about **$0.26**. A guide draft also costs you the AI tokens on your own key (a few cents for a small model).

### What was tested, and how (honest status)

**Live (real services):**

- Runs on the Apify platform with a pasted CSV and with an uploaded-file style signed link; named tracker storage kept between two runs (the second run reported fixed and improved keywords); the Standby API; dataset validation by the platform; pay-per-event charging and the maximum cost.
- A real crawl of a small public site (30 pages of danluu.com, sitemap + robots.txt, polite).

**Mocked or synthetic (not yet proven live):**

- The Search Console numbers in the live runs are **synthetic** (made up, on real pages). Nobody's real Search Console property was used.
- The **Search Console API path was not tested against Google**. It is built from the public REST documentation and tested against a mock Google server that checks the request shape and the service account JWT signature. Please report any difference you see with a real property.
- The AI draft calls (OpenAI, Anthropic, Gemini) were tested against fake servers, not with real keys. Model defaults are the cheap current ones and can be changed with `llmModel`.
- "Did it work" page-edit and added-link detection was tested on a local test site. In the live runs the site was not edited, so only the position and click changes were seen.
- Titles, outlines and anchors are a rule-based scaffold built from your own queries and pages, not a copy of what ranks. Check the live search results before you write.

### FAQ

**Why not Keyword Planner?** It gives search volume estimates and needs a Google Ads account. This Actor uses your own Search Console data, which is real impressions and clicks for your site, free, and the only data that shows "shown but not clicked".

**Do you change my site?** No. It only reads public pages and your Search Console data. You publish the guides and add the links.

**Which sites work?** Any site with a sitemap or normal links. Pages built only with JavaScript are not read (the crawl uses plain HTML), so those pages may show as missing.

**Is my data kept?** Results and your tracker baseline stay in your own Apify storage. API keys are secret input fields.

**Limits.** Search Console shows anonymized queries only in part; rows below its privacy threshold are missing. Crawl up to 2,000 pages per run.

# Actor input Schema

## `siteUrl` (type: `string`):

The site to analyse, for example https://example.com (a domain like example.com or sc-domain:example.com also works). The crawl stays on this host.

## `gscCsvUrls` (type: `array`):

Easy way: upload the Search Console export (Performance > Export > Download CSV or ZIP) or paste links to it. A ZIP with Queries.csv and Pages.csv works as it is. A table with Query + Page columns (from the API, Looker Studio or Sheets) gives the best results (exact pages and cannibalization). A Google Sheets link works when the sheet is public or published. The link must open without logging in.

## `gscCsvText` (type: `string`):

Paste the CSV text here instead of a file (first line = header with Query, Clicks, Impressions, CTR, Position, and optionally Page). Useful for API calls and small tests.

## `googleServiceAccountJson` (type: `string`):

Automatic way: paste the whole JSON key of a Google Cloud service account. In Search Console open Settings > Users and permissions and add the service account email as a user (Restricted is enough). Enable the 'Google Search Console API' in the Cloud project. Stored encrypted.

## `gscProperty` (type: `string`):

Only with the API. The property exactly as in Search Console: sc-domain:example.com for a domain property or https://example.com/ for a URL-prefix property. Leave empty to pick the property of the Site URL automatically.

## `googleClientId` (type: `string`):

Alternative to the service account: OAuth client ID, client secret and refresh token of a Google account that can see the property (scope webmasters.readonly).

## `googleClientSecret` (type: `string`):

OAuth client secret (stored encrypted).

## `googleRefreshToken` (type: `string`):

OAuth refresh token (stored encrypted).

## `days` (type: `integer`):

API: how many days to read (Search Console keeps 16 months). CSV: tell the Actor how many days your export covers (default 28), used to turn clicks into clicks per day for the progress report.

## `endDateLagDays` (type: `integer`):

Search Console data is final after about 2 to 3 days. The API window ends this many days before today.

## `searchType` (type: `string`):

Which Google search results to read.

## `country` (type: `string`):

Optional 3-letter country code (for example USA, GBR, DEU, PRT) to read only one country.

## `maxGscRows` (type: `integer`):

Stops reading after this many query+page rows (25,000 per API call).

## `brandTerms` (type: `array`):

Queries that contain one of these words are skipped (people who search your brand already know you, and their CTR is very different). Example: your company name.

## `opportunityTypes` (type: `array`):

Striking distance: position 8 to 50 with many impressions (no-click keywords are flagged). Low CTR: page-1 keywords whose CTR is far below normal for the position (rewrite title/meta). Cannibalization: several of your pages compete for the same queries. Content gap: no page of yours fits the query (write a new guide).

## `minImpressions` (type: `integer`):

An opportunity needs at least this many impressions in the period (all related queries together).

## `minPosition` (type: `number`):

Striking distance starts at this average position. Page-1 queries above it are checked for low CTR instead.

## `maxPosition` (type: `number`):

Striking distance ends at this average position. Queries ranking further away only count as content gaps.

## `targetPosition` (type: `number`):

Extra clicks are estimated if the query reached this position (default 3 = top 3).

## `ctrUnderperformRatio` (type: `number`):

A page-1 query is a CTR problem when its CTR is below this share of the normal CTR for its position (0.5 = less than half).

## `maxOpportunities` (type: `integer`):

Only the best opportunities by priority score are saved (and charged).

## `crawlSite` (type: `boolean`):

Read your pages (sitemap first) to match queries to pages, decide 'optimize' or 'create new guide', plan internal links and find orphan pages. Turn off to analyse the Search Console data only.

## `maxPages` (type: `integer`):

Pages with Search Console impressions are crawled first, then the home page, then the sitemap. Never more than 40% of your maximum cost for the run is spent on the crawl.

## `sitemapUrls` (type: `array`):

Optional. Leave empty to find the sitemap in robots.txt or at /sitemap.xml.

## `excludeUrlPatterns` (type: `array`):

Regular expressions. A URL that matches one is not crawled. Example: /tag/ or ?replytocom=

## `respectRobotsTxt` (type: `boolean`):

Do not crawl URLs that robots.txt blocks for all crawlers.

## `crawlConcurrency` (type: `integer`):

Pages read at the same time (a short delay between requests to the same site is always kept).

## `maxBriefs` (type: `integer`):

Content briefs (target keyword, secondary keywords, title and meta options, outline, questions, word count, links, schema) for the best opportunities. 0 = none. Each brief is one 'content brief' event.

## `maxLinksPerTarget` (type: `integer`):

For each page to improve (or new guide) suggest up to this many pages that should link to it, with anchor text and where to put the link. 0 = no link plan. Each suggestion is one 'internal link suggestion' event.

## `maxLinkTargets` (type: `integer`):

The link plan covers the best opportunities up to this many different target pages.

## `includeOrphanLinks` (type: `boolean`):

Suggest 2 links for each of up to 10 orphan pages (pages with no internal links pointing to them).

## `newGuidePath` (type: `string`):

Planned URLs of new guides look like https://yoursite.com/<folder>/<keyword-slug>.

## `llmProvider` (type: `string`):

Optional. With your own API key the Actor also writes a full draft of each guide from its brief. Without a key you only get the brief. The Actor never uses a key of ours. The AI provider bills you directly for the tokens; the Actor only charges its 'guide draft' event.

## `llmApiKey` (type: `string`):

API key of the provider above. Stored encrypted, sent only to that provider, never saved in the results.

## `llmModel` (type: `string`):

Model ID of the provider. Leave empty for a small, cheap default (OpenAI gpt-6-luna, Claude claude-haiku-5-5, Gemini gemini-3.5-flash-lite). Model names change: set this if the default is retired.

## `maxDrafts` (type: `integer`):

How many drafts to write (best opportunities first). Each draft is one 'guide draft' event plus your own AI cost.

## `trackChanges` (type: `boolean`):

Save a baseline (in a named storage per tracker) and compare the next run with it: new, improved, regressed and fixed opportunities, pages you edited, links you added. Google needs about 3 to 4 days to register changes, so run every 4 to 7 days.

## `trackerName` (type: `string`):

Runs with the same name share one baseline. Default: the site's domain.

## `resetBaseline` (type: `boolean`):

Ignore the saved baseline and save a new one in this run.

## Actor input object example

```json
{
  "siteUrl": "https://danluu.com",
  "gscCsvText": "Query,Page,Clicks,Impressions,CTR,Position\nhow to hire a programmer,https://danluu.com/hiring-lemons/,0,5200,0%,41.3\nprogrammer interview questions,https://danluu.com/algorithms-interviews/,3,3100,0.1%,27.8\nwhy are software projects late,https://danluu.com/slow-projects/,2,1400,0.14%,18.2\ndan luu,https://danluu.com/,950,1200,79.2%,1.1",
  "days": 28,
  "endDateLagDays": 3,
  "searchType": "web",
  "maxGscRows": 50000,
  "opportunityTypes": [
    "striking_distance",
    "ctr_underperformer",
    "cannibalization",
    "content_gap"
  ],
  "minImpressions": 100,
  "minPosition": 8,
  "maxPosition": 50,
  "targetPosition": 3,
  "ctrUnderperformRatio": 0.5,
  "maxOpportunities": 100,
  "crawlSite": true,
  "maxPages": 200,
  "respectRobotsTxt": true,
  "crawlConcurrency": 3,
  "maxBriefs": 10,
  "maxLinksPerTarget": 5,
  "maxLinkTargets": 25,
  "includeOrphanLinks": true,
  "newGuidePath": "/guides/",
  "llmProvider": "none",
  "maxDrafts": 3,
  "trackChanges": true,
  "resetBaseline": false
}
```

# Actor output Schema

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

One row per opportunity with metrics, recommended action, priority score, brief and internal link suggestions

## `summary` (type: `string`):

Counts, data source, crawl facts, charged events and notes

## `reportHtml` (type: `string`):

Readable report with progress, opportunities, briefs and the link plan

## `internalLinksCsv` (type: `string`):

Which page should link to which page, with anchor text and placement

## `briefs` (type: `string`):

Briefs for the best opportunities

# 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 = {
    "siteUrl": "https://danluu.com",
    "gscCsvText": `Query,Page,Clicks,Impressions,CTR,Position
how to hire a programmer,https://danluu.com/hiring-lemons/,0,5200,0%,41.3
programmer interview questions,https://danluu.com/algorithms-interviews/,3,3100,0.1%,27.8
why are software projects late,https://danluu.com/slow-projects/,2,1400,0.14%,18.2
dan luu,https://danluu.com/,950,1200,79.2%,1.1`
};

// Run the Actor and wait for it to finish
const run = await client.actor("apizilla/seo-ranking").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 = {
    "siteUrl": "https://danluu.com",
    "gscCsvText": """Query,Page,Clicks,Impressions,CTR,Position
how to hire a programmer,https://danluu.com/hiring-lemons/,0,5200,0%,41.3
programmer interview questions,https://danluu.com/algorithms-interviews/,3,3100,0.1%,27.8
why are software projects late,https://danluu.com/slow-projects/,2,1400,0.14%,18.2
dan luu,https://danluu.com/,950,1200,79.2%,1.1""",
}

# Run the Actor and wait for it to finish
run = client.actor("apizilla/seo-ranking").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 '{
  "siteUrl": "https://danluu.com",
  "gscCsvText": "Query,Page,Clicks,Impressions,CTR,Position\\nhow to hire a programmer,https://danluu.com/hiring-lemons/,0,5200,0%,41.3\\nprogrammer interview questions,https://danluu.com/algorithms-interviews/,3,3100,0.1%,27.8\\nwhy are software projects late,https://danluu.com/slow-projects/,2,1400,0.14%,18.2\\ndan luu,https://danluu.com/,950,1200,79.2%,1.1"
}' |
apify call apizilla/seo-ranking --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,apizilla/seo-ranking"
        }
    }
}
```

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/wcGq79QU9WhFXEVXx/builds/iOsmXAVQ8x8AjZueW/openapi.json
