Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/web/src/content/docs-nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@ export const docsNav: DocsNavEntry[] = [
path: '/docs/research-providers',
label: 'Research providers',
description:
'Connect DataForSEO locally, control paid work and combine keyword, domain, competitor and page estimates with first-party evidence.',
'Connect optional providers locally, control paid work and combine keyword, domain, competitor and page estimates with first-party evidence.',
},
{
path: '/docs/semrush',
label: 'Semrush',
description:
'Connect the permanent Semrush Version 3 key and run bounded keyword, domain, ranking-page and competitor research.',
},
{
path: '/docs/indexnow',
Expand Down
12 changes: 8 additions & 4 deletions apps/web/src/content/docs/docs/research-providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ providers add keyword estimates, result snapshots, domain footprints, ranking
pages and competitor rows. Search Console still supplies owner-verified search
performance. Crawls still supply current page and technical evidence.

DataForSEO supports the current research reports. The report contracts are
provider neutral, so another adapter can support the same capability later
without changing how an agent reads the result. A report fails clearly when a
selected provider does not support its operation or market.
DataForSEO supports the broadest set of current research reports. Semrush
Version 3 supports bounded keyword, domain, ranking-page and search-competitor
research. The report contracts are provider neutral, so both adapters return
the same evidence shape where their capabilities overlap. A report fails
clearly when a selected provider does not support its operation or market.

Use the [Semrush guide](/docs/semrush) for the exact key, connection commands,
supported reports and API-unit behavior.

## Connect DataForSEO on this computer

Expand Down
122 changes: 122 additions & 0 deletions apps/web/src/content/docs/docs/semrush.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
---
title: Semrush research
description: Connect the permanent Semrush Version 3 API key and run bounded keyword, domain, ranking-page and competitor research.
---

Use Semrush estimates without replacing evidence from your own site. The
adapter supplies six existing research reports with provider-native values,
coverage, cache state and API-unit cost. It does not add a Semrush-specific MCP
tool or agent skill.

## Use the permanent Version 3 key

Open My profile, choose API Keys, and copy the row named **V3 API Key**. Semrush
generates one permanent Version 3 key for each account, so there is no button
to create another one.

This integration accepts only that Version 3 key. It does not accept Version 4
keys. Semrush keeps the versions, keys and supported endpoints separate; its
<a href="https://developer.semrush.com/api/v3/introduction/api-versions/" target="_blank" rel="noreferrer">API version guide</a>
explains the distinction.

Connect it through the masked terminal prompt:

```sh
seo providers semrush connect
seo providers semrush status --check
```

The connection check requests the remaining API-unit balance and costs no
units. The key is saved in the system keychain when available, with a private
local file fallback. It is never stored in a project profile, report, cache
entry or structured error.

The
<a href="https://developer.semrush.com/api/v3/get-started/api-units-balance/" target="_blank" rel="noreferrer">Semrush balance guide</a>
describes the free check and how report rows consume API units.

Remove the saved key with:

```sh
seo providers semrush disconnect
```

Agents and CI can supply the same Version 3 key without saving it:

```sh
SEO_SEMRUSH_API_KEY='your-version-3-key' \
seo providers semrush status --check --json
```

Keep the value in the platform secret manager. Do not put it in a repository,
script, report parameter, command-line flag or issue.

## Run the shared research reports

Choose Semrush through the normal report input. The same report ids, schemas
and evidence rules are used for every supported provider.

```sh
seo reports run keyword-metrics \
--params '{"keywords":["technical seo","seo audit"],"countryCode":"GB","languageCode":"en","searchEngine":"google","provider":"semrush"}' \
--json

seo reports run keyword-research \
--params '{"seeds":["technical seo"],"sources":["ideas"],"countryCode":"GB","languageCode":"en","searchEngine":"google","limit":10,"provider":"semrush"}' \
--json

seo reports run domain-overview \
--params '{"domain":"example.com","countryCode":"GB","languageCode":"en","searchEngine":"google","provider":"semrush"}' \
--json
```

The connected adapter supports:

| Report | Evidence supplied |
| --- | --- |
| `keyword-metrics` | Search volume, cost per click, paid competition, result count, intent and keyword difficulty when Semrush returns them. |
| `keyword-research` | Bounded ideas, related terms and question keywords from one to five seeds. |
| `domain-overview` | Provider-estimated organic keyword count, traffic and traffic cost for one domain. |
| `ranked-keywords` | A bounded set of observed terms, ranking URLs, positions and optional keyword metrics. |
| `ranking-pages` | Ranking pages and repeated URL patterns derived from bounded provider rows. |
| `serp-competitors` | Domains repeatedly observed for an explicit keyword set. |

Use `seo reports describe <report-id> --json` before scripting a report. It
returns the current input schema, reading order, caveats and related reports.

## Keep requests bounded

Every cache miss checks the free balance before requesting paid data. The
adapter sets the provider row limit before acquisition, rejects unbounded
inputs, and records estimated and returned API units in structured evidence.
If the balance cannot cover the maximum request, the paid call does not start.

Results are cached locally for seven days by default. A cache hit costs no API
units and makes no provider request. Pass `"refresh":true` only when the
decision needs a newer observation.

Semrush Version 3 research uses Google desktop data from a country-level
regional database. It does not support a city, postcode, Bing or mobile market
through this adapter. The requested language remains visible in the report,
but Semrush does not apply it as a separate language filter.

## Read the caveats before acting

Semrush metrics are external estimates. They are not Search Console
impressions, measured visits, a complete keyword inventory or a forecast.
Keyword difficulty is a provider metric, not a ranking probability.

Semrush marks its Version 3 keyword endpoints as deprecated. The reports keep
that warning in their evidence. The
<a href="https://developer.semrush.com/api/v3/seo/keyword-reports/" target="_blank" rel="noreferrer">Version 3 keyword reference</a>
says existing integrations remain available temporarily, so a successful
request does not promise permanent endpoint availability.

Run the main first-party report before paid research:

```sh
seo report --project example
```

Use a Semrush report when it answers a specific gap, then verify an important
term against Search Console and a current result page in the same market.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,9 @@
"security:check": "pnpm run security:audit && pnpm run security:secrets",
"security:secrets": "node scripts/security-secrets.mjs",
"skills:validate": "node scripts/validate-skills.mjs",
"test": "turbo run test && pnpm run build:package && node --test scripts/package.test.mjs scripts/dogfood-summary.test.mjs && node scripts/resource-harness.mjs && node scripts/provider-resource-harness.mjs && node scripts/pseo-resource-harness.mjs && node scripts/indexnow-resource-harness.mjs && node scripts/bing-resource-harness.mjs && pnpm run skills:validate",
"test:built": "turbo run test && node --test scripts/package.test.mjs scripts/dogfood-summary.test.mjs && node scripts/resource-harness.mjs && node scripts/provider-resource-harness.mjs && node scripts/pseo-resource-harness.mjs && node scripts/indexnow-resource-harness.mjs && node scripts/bing-resource-harness.mjs && pnpm run skills:validate",
"test": "turbo run test && pnpm run build:package && node --test scripts/package.test.mjs scripts/dogfood-summary.test.mjs scripts/semrush-live-acceptance.test.mjs && node scripts/resource-harness.mjs && node scripts/provider-resource-harness.mjs && node scripts/pseo-resource-harness.mjs && node scripts/indexnow-resource-harness.mjs && node scripts/bing-resource-harness.mjs && pnpm run skills:validate",
"test:built": "turbo run test && node --test scripts/package.test.mjs scripts/dogfood-summary.test.mjs scripts/semrush-live-acceptance.test.mjs && node scripts/resource-harness.mjs && node scripts/provider-resource-harness.mjs && node scripts/pseo-resource-harness.mjs && node scripts/indexnow-resource-harness.mjs && node scripts/bing-resource-harness.mjs && pnpm run skills:validate",
"test:semrush-live": "pnpm run build:package && node scripts/semrush-live-acceptance.mjs",
"test:resources": "pnpm run build:package && node scripts/resource-harness.mjs && node scripts/provider-resource-harness.mjs && node scripts/pseo-resource-harness.mjs && node scripts/indexnow-resource-harness.mjs && node scripts/bing-resource-harness.mjs",
"test:rank-resources": "pnpm run build:package && node scripts/rank-tracking-resource-harness.mjs",
"test:package-install": "pnpm --filter @seo/core build && pnpm --filter @seo/mcp build && pnpm run build:package && node --test scripts/installed-package.test.mjs",
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/providers/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { defineCommand } from 'citty'
import { bingProviderCommand } from './bing.js'
import { dataForSeoProviderCommand } from './dataforseo.js'
import { semrushProviderCommand } from './semrush.js'

export const providersCommand = defineCommand({
meta: { name: 'providers', description: 'Connect optional data providers' },
subCommands: {
bing: bingProviderCommand,
dataforseo: dataForSeoProviderCommand,
semrush: semrushProviderCommand,
},
})
154 changes: 154 additions & 0 deletions packages/cli/src/commands/providers/semrush.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import assert from 'node:assert/strict'
import { execFile } from 'node:child_process'
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'

const execFileAsync = promisify(execFile)
const cliPath = fileURLToPath(new URL('../../index.js', import.meta.url))

async function runSeo(
args: string[],
env: Record<string, string>,
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
try {
const result = await execFileAsync(process.execPath, [cliPath, ...args], {
env: {
...process.env,
...env,
CI: '1',
NO_UPDATE_NOTIFIER: '1',
},
timeout: 10_000,
})
return { exitCode: 0, stdout: result.stdout, stderr: result.stderr }
} catch (error) {
const result = error as {
code?: number
stdout?: string
stderr?: string
}
return {
exitCode: result.code ?? 1,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
}
}
}

test('Semrush status uses the environment API key without exposing it', async () => {
const configDir = await mkdtemp(join(tmpdir(), 'seo-semrush-cli-config-'))
const cacheDir = await mkdtemp(join(tmpdir(), 'seo-semrush-cli-cache-'))
try {
const result = await runSeo(['providers', 'semrush', 'status', '--json'], {
SEO_CONFIG_DIR: configDir,
SEO_CACHE_DIR: cacheDir,
SEO_SEMRUSH_API_KEY: 'environment-api-key',
})
assert.equal(result.exitCode, 0)
assert.deepEqual(JSON.parse(result.stdout), {
connected: true,
apiVersion: 3,
credentialSource: 'environment',
migratedLegacyCredential: false,
liveCheck: { status: 'not-requested' },
})
assert.doesNotMatch(result.stdout, /environment-api-key/)
} finally {
await rm(configDir, { recursive: true, force: true })
await rm(cacheDir, { recursive: true, force: true })
}
})

test('Semrush status migrates a legacy config API key', async () => {
const configDir = await mkdtemp(join(tmpdir(), 'seo-semrush-cli-config-'))
const cacheDir = await mkdtemp(join(tmpdir(), 'seo-semrush-cli-cache-'))
const configPath = join(configDir, 'config.json')
try {
await writeFile(
configPath,
JSON.stringify({
providers: {
semrushApiKey: 'legacy-api-key',
prefer: 'authoritative',
},
security: { useKeychain: false },
}),
{ mode: 0o600 },
)
const result = await runSeo(['providers', 'semrush', 'status', '--json'], {
SEO_CONFIG_DIR: configDir,
SEO_CACHE_DIR: cacheDir,
SEO_SEMRUSH_API_KEY: '',
})
assert.equal(result.exitCode, 0)
assert.deepEqual(JSON.parse(result.stdout), {
connected: true,
apiVersion: 3,
credentialSource: 'file',
migratedLegacyCredential: true,
liveCheck: { status: 'not-requested' },
})

const config = await readFile(configPath, 'utf8')
assert.doesNotMatch(config, /legacy-api-key|semrushApiKey/)
const secretsPath = join(configDir, 'provider-secrets.json')
const secrets = await readFile(secretsPath, 'utf8')
assert.match(secrets, /semrush-api-key/)
assert.equal((await stat(secretsPath)).mode & 0o777, 0o600)
} finally {
await rm(configDir, { recursive: true, force: true })
await rm(cacheDir, { recursive: true, force: true })
}
})

test('Semrush connect refuses to prompt in JSON or CI mode', async () => {
const configDir = await mkdtemp(join(tmpdir(), 'seo-semrush-cli-config-'))
const cacheDir = await mkdtemp(join(tmpdir(), 'seo-semrush-cli-cache-'))
try {
const result = await runSeo(['providers', 'semrush', 'connect', '--json'], {
SEO_CONFIG_DIR: configDir,
SEO_CACHE_DIR: cacheDir,
SEO_SEMRUSH_API_KEY: '',
})
assert.notEqual(result.exitCode, 0)
const output = JSON.parse(result.stdout) as {
error: { code: string; message: string }
}
assert.equal(output.error.code, 'AUTH_REQUIRED')
assert.match(output.error.message, /run `seo providers semrush connect`/i)
assert.match(output.error.message, /SEO_SEMRUSH_API_KEY/)
assert.equal(result.stderr, '')
} finally {
await rm(configDir, { recursive: true, force: true })
await rm(cacheDir, { recursive: true, force: true })
}
})

test('Semrush disconnect leaves an environment API key explicit', async () => {
const configDir = await mkdtemp(join(tmpdir(), 'seo-semrush-cli-config-'))
const cacheDir = await mkdtemp(join(tmpdir(), 'seo-semrush-cli-cache-'))
try {
const result = await runSeo(
['providers', 'semrush', 'disconnect', '--json'],
{
SEO_CONFIG_DIR: configDir,
SEO_CACHE_DIR: cacheDir,
SEO_SEMRUSH_API_KEY: 'environment-api-key',
},
)
assert.equal(result.exitCode, 0)
assert.deepEqual(JSON.parse(result.stdout), {
savedCredentialRemoved: true,
environmentCredential: 'active',
note: 'The environment variable was not changed. Clear SEO_SEMRUSH_API_KEY to fully disconnect.',
})
assert.doesNotMatch(result.stdout, /environment-api-key/)
} finally {
await rm(configDir, { recursive: true, force: true })
await rm(cacheDir, { recursive: true, force: true })
}
})
Loading