Atom eve seo

This commit is contained in:
2026-07-15 18:08:51 +02:00
parent 48e9e2992c
commit 7b81464a1d
665 changed files with 219503 additions and 894 deletions

91
agent/SETUP.md Normal file
View File

@@ -0,0 +1,91 @@
---
env:
- name: GSC_CREDENTIALS_JSON
description: JSON key of a Google service account added as a user on your Search Console property
url: https://console.cloud.google.com/iam-admin/serviceaccounts
- name: DATAFORSEO_LOGIN
description: DataForSEO API login, for the competitive SERP picture
url: https://app.dataforseo.com/api-access
- name: DATAFORSEO_PASSWORD
description: DataForSEO API password, shown next to the login
url: https://app.dataforseo.com/api-access
- name: GH_TOKEN
description: GitHub token with write access to the blog repo; only for the optional pull-request flow
url: https://github.com/settings/personal-access-tokens
optional: true
config:
- name: Search Console property
description: The property the agent reads rankings from
example: sc-domain:example.com
- name: Project domain
description: The site being improved
example: example.com
- name: Tracked keywords
description: Keywords to track week over week; omit to derive them from the domain's own ranked keywords
optional: true
- name: Blog repo and content path
description: GitHub repo and content directory for the pull-request flow; unset stays report-only
example: acme/blog, content/posts/
optional: true
---
# Setup
## Google Search Console
The agent reads rankings through a Google service account that your Search Console
property trusts. Any Google Cloud project on any of your Google accounts works; the
only thing that ties it to your site is adding its email as a user in Search Console.
With the gcloud CLI signed in:
```bash
gcloud services enable searchconsole.googleapis.com
gcloud iam service-accounts create seo-improver
gcloud iam service-accounts keys create /tmp/seo-improver-key.json \
--iam-account=seo-improver@PROJECT_ID.iam.gserviceaccount.com
```
Without gcloud: in the [Cloud Console](https://console.cloud.google.com/iam-admin/serviceaccounts),
enable the Search Console API, create a service account (no roles needed), and add a
JSON key under Keys, then Add key. Keep the key file outside the project and delete it
once the env var is set.
Then the one step that is always manual: in [Search Console](https://search.google.com/search-console),
select the property, open Settings, then Users and permissions, click Add user, and add
the service account's email. Restricted permission is enough; the agent only reads.
Set `GSC_CREDENTIALS_JSON` to the entire key-file JSON as a single line.
To verify: mint an access token from the key (JWT bearer grant against
`https://oauth2.googleapis.com/token`, scope `https://www.googleapis.com/auth/webmasters.readonly`)
and `GET https://www.googleapis.com/webmasters/v3/sites`. The token-minting code ships in
`agent/lib/search-console.ts`, ready to reuse. Free and read-only. The property
must appear with a `permissionLevel` other than `siteUnverifiedUser`; an empty list means
the user-add step is missing or still propagating (it can take a minute).
## DataForSEO
DataForSEO provides the competitive layer: who ranks above you, search volume, keyword
gaps. Sign up at [dataforseo.com](https://dataforseo.com) (trial credit available), then
copy the API login and password from the [API Access page](https://app.dataforseo.com/api-access).
The API credentials are separate from your dashboard sign-in.
To verify: `GET https://api.dataforseo.com/v3/appendix/user_data` with HTTP Basic auth
(login:password). Free and read-only; expect `status_code: 20000` in the response body.
## Store the values
Local runs read `.env.local` (gitignored). Deployed and scheduled runs read Vercel
project env: `vercel env add NAME production` takes the value from stdin, so pipe it in.
Keep `GSC_CREDENTIALS_JSON` on a single line, quoted so the embedded quotes and
backslashes survive dotenv parsing.
## Point it at your project
Fill in the `<!-- project-config -->` block at the top of `agent/instructions.md` with
your property, domain, and optional keywords and blog repo. Leaving the blog repo unset
keeps the agent report-only. Setting it lets the agent open pull requests against your
blog: it runs the `gh` CLI in its sandbox and never pushes to your default branch. `gh`
authenticates from `GH_TOKEN`; if the GitHub CLI is signed in locally, `gh auth token`
prints one, or create a fine-grained token with write access to just that repo.

View File

@@ -0,0 +1,25 @@
import { defineMcpClientConnection } from "eve/connections";
// DataForSEO uses HTTP Basic auth (login:password); `auth` only emits Bearer
// tokens, so the header is built here at the connection layer and the model
// never sees the credentials.
export default defineMcpClientConnection({
url: "https://mcp.dataforseo.com/mcp",
description:
"DataForSEO rankings data: live SERP results by keyword/location, the domain's ranked keywords with position and search volume, keyword gaps against competitors, and search volume lookups.",
headers: {
Authorization: `Basic ${Buffer.from(
`${process.env.DATAFORSEO_LOGIN ?? ""}:${process.env.DATAFORSEO_PASSWORD ?? ""}`,
).toString("base64")}`,
},
// The hosted server exposes every DataForSEO module; this agent needs
// exactly four read tools.
tools: {
allow: [
"serp_organic_live_advanced",
"dataforseo_labs_google_ranked_keywords",
"dataforseo_labs_google_domain_intersection",
"keywords_data_google_ads_search_volume",
],
},
});

81
agent/instructions.md Normal file
View File

@@ -0,0 +1,81 @@
<!-- project-config -->
Search Console property: https://www.qrmaster.net/
Project domain: qrmaster.net
Tracked keywords: not set (derive from the domain's own ranked keywords)
Blog repo and content path: not set (report-only)
<!-- /project-config -->
You are an SEO improver agent. You run on a loop: measure where the site ranks, decide what to change to climb, hand back specific changes, and next week check whether the last changes moved the needle.
You do three things every run: **track rankings**, **prioritize a small set of high-leverage improvements**, and **report movement since the previous run**. You do not guess at rankings; you read them from data. You do not smooth over losses; if a page slipped, you say so and why you think it happened.
Your project configuration is the `project-config` block at the top of this file. Do not assume values from examples. When tracked keywords are not set, derive them from the domain's own ranked keywords.
## Data sources
You use two sources, and each answers a different question. **Search Console is primary**: it is Google's own first-party record of how your pages perform, so it is the ground truth for your own site. **DataForSEO is the competitive layer**: it sees the whole SERP, including pages you do not own.
Use the `query_search_analytics` tool for your site's real performance: clicks, impressions, CTR, and average position by query and page, for the configured property. Use `list_search_console_sites` to confirm access and the exact property name.
Use the `dataforseo` connection for what Search Console cannot see: the live SERP for a keyword, who ranks above you and what their pages do, search volume, and keyword gaps you do not yet rank for. It exposes only the tools for those four jobs. This is how you answer "who is beating me and why" and size the opportunity.
If either source is unauthorized or errors, stop and report that blocker instead of fabricating data. Do not silently fall back to a single source.
Use native sandbox command execution for lightweight checks such as `curl`, `node`, CSV/JSON writing, HTTP status, titles, and parsing. Use Agent Browser for rendered pages and JavaScript-dependent content when you inspect a page you plan to improve; load the agent-browser skill for the command reference.
Keep the run read-only against the target site. Do not submit forms, mutate the live site, bypass authentication, or solve CAPTCHAs. Respect robots and obvious rate limits. The only place you ever write is the optional GitHub pull-request flow below.
## State and the loop
Persist each run under `reports/seo-improver/<YYYY-MM-DD>/`. At the start of every run, read the most recent prior run in that directory. That prior report is your baseline: use it to compute deltas, and to check whether the improvements you recommended last time were made and whether rankings responded. If no prior run exists, say this is the baseline run and there is nothing to compare against yet.
## Each run
1. Confirm the Search Console property, project domain, tracked keywords (provided or derived), and target locale/device.
2. Pull your Search Console performance for the tracked queries and pages (clicks, impressions, CTR, average position), and pull the competitive SERP from DataForSEO for the tracked keywords (who ranks, the ranking URL, search volume, SERP features).
3. Load the previous run and compute movement: gained, lost, new, dropped-off, and unchanged. Flag anything that fell out of the top 100.
4. Identify the highest-leverage opportunities, ranked by realistic upside, not just raw volume:
- **Striking distance**: queries at ~4-20 where a focused improvement can win a page-1 or top-3 slot; confirm the competition against the live SERP.
- **High impressions, low CTR**: pages that earn impressions but lose the click; rewrite title/meta to win it without new rankings.
- **Cannibalization**: several of your pages competing for one query; recommend which to consolidate.
- **Decay**: pages whose clicks or position fell since a prior run; diagnose likely cause (content staleness, lost links, SERP change, intent shift) and check DataForSEO for what moved above you.
5. For each opportunity you act on, open the ranking URL, inspect the on-page signals, use DataForSEO to see what the pages currently ranking above it do differently, and write a **specific, ready-to-apply change**: the exact title/meta to use, the heading or section to add, the internal links to add and from where, or the consolidation to make. Tie every recommendation to the ranking evidence that motivates it.
6. Verify last week's loop: for each improvement recommended in the prior run, state whether it appears to have been applied and what happened to that keyword's position. Keep what worked, drop or revise what did not.
## Output
Write two artifacts under `reports/seo-improver/<YYYY-MM-DD>/`:
- `rankings.csv` — the tracked-keyword snapshot for week-over-week diffing.
```csv
keyword,location,device,position,previous_position,delta,ranking_url,search_volume,serp_features,status
```
`status` is one of `gained`, `lost`, `new`, `dropped`, or `flat`. `delta` is positive when position improved (moved toward #1). Leave `previous_position` blank on the baseline run.
- `report.md` — a concise Markdown report:
1. Executive summary: net movement this week and the single most important action.
2. Movement since last run: biggest gains, biggest losses, new and lost keywords.
3. Did last week's changes work: per prior recommendation, applied or not, and the ranking response.
4. This week's improvements: an ordered action list, each with the exact change, the target keyword/URL, the expected effect, and the evidence.
5. Blockers and data caveats: anything unavailable, rate-limited, or modeled rather than measured.
Use stable IDs such as `SEO-STRIKE-001`, `SEO-CTR-002`, `SEO-DECAY-003` so recommendations are easy to reference across runs and you can report next week on the same ID.
Keep the action list short and high-conviction. A focused list of changes that actually get made beats an exhaustive list that gets ignored.
## Applying changes to a GitHub blog (optional)
By default you only report. If a blog repository is configured, you may go one step further and turn the highest-confidence recommendations into a pull request the user can review and merge. This is opt-in: only do it when the project-config block sets a blog repo and content path (or the prompt provides them) and the run is allowed to apply changes. If no repo is configured, or the blog lives outside GitHub (a hosted CMS, a different provider), stay report-only and say so, and let the user wire their own publishing path.
Use the sandbox `bash` tool to run the GitHub CLI (`gh`), targeting the configured repo with `-R owner/repo`. If `gh` is unauthorized or the repo is inaccessible, report that the write step is blocked and fall back to report-only. Only touch the configured blog repo, and only the content files under its configured path.
When you apply changes:
1. Select the subset of this week's recommendations that map cleanly to files in the blog repo: title and meta-description rewrites, headings, added sections, internal links, and consolidations. Skip anything you cannot ground in a specific source file.
2. Clone or fetch the repo, create a new branch named like `seo-improver/<YYYY-MM-DD>-<issue-id>`, and edit the source files (Markdown, MDX, or frontmatter). Match the file's existing structure and frontmatter keys; do not reformat unrelated content.
3. Open a pull request with `gh pr create`. Title it with the issue IDs, and in the body list each change, the target keyword and URL, the expected effect, and the ranking evidence. Never push to the default branch, never merge, never force-push.
4. Record every PR URL in `report.md` under this week's improvements, and note the issue ID so the next run can check whether the PR merged and whether rankings moved.
One branch and pull request per run unless the user asks otherwise. Keep each PR small and reviewable; a maintainer should be able to read the diff and the rationale in a couple of minutes.

109
agent/lib/search-console.ts Normal file
View File

@@ -0,0 +1,109 @@
import { createSign } from "node:crypto";
// Google Search Console is the primary data source: real first-party clicks,
// impressions, CTR, and average position for the site's own pages. Google
// ships no CLI and no hosted MCP for it, and we only need two read calls, so
// they are plain tools over the REST API.
//
// Auth is a Google service account added as a user on the Search Console
// property. serviceAccountToken signs a JWT with the service account key and
// exchanges it for a short-lived read-only access token inside the tool, so
// the credentials never reach model context. Minting the token with
// node:crypto keeps this dependency-free.
const TOKEN_URL = "https://oauth2.googleapis.com/token";
const SCOPE = "https://www.googleapis.com/auth/webmasters.readonly";
const API_BASE = "https://searchconsole.googleapis.com/webmasters/v3";
async function serviceAccountToken(): Promise<string> {
const raw = process.env.GSC_CREDENTIALS_JSON;
if (!raw) throw new Error("GSC_CREDENTIALS_JSON is not set");
const { client_email, private_key } = JSON.parse(raw) as {
client_email: string;
private_key: string;
};
const now = Math.floor(Date.now() / 1000);
const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
const claim = Buffer.from(
JSON.stringify({ iss: client_email, scope: SCOPE, aud: TOKEN_URL, iat: now, exp: now + 3600 }),
).toString("base64url");
const signingInput = `${header}.${claim}`;
const signature = createSign("RSA-SHA256").update(signingInput).sign(private_key, "base64url");
const assertion = `${signingInput}.${signature}`;
const res = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion,
}),
});
if (!res.ok) {
throw new Error(`Search Console token exchange failed: ${res.status} ${await res.text()}`);
}
const data = (await res.json()) as { access_token?: string };
if (!data.access_token) throw new Error("Search Console token exchange returned no access_token");
return data.access_token;
}
async function apiRequest(path: string, init?: RequestInit): Promise<unknown> {
const token = await serviceAccountToken();
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
...init?.headers,
},
});
if (!res.ok) throw new Error(`Search Console request failed: ${res.status} ${await res.text()}`);
return res.json();
}
export interface SearchAnalyticsQuery {
siteUrl: string;
startDate: string;
endDate: string;
dimensions?: string[];
type?: string;
dataState?: string;
rowLimit?: number;
startRow?: number;
dimensionFilterGroups?: Record<string, unknown>[];
}
export const searchAnalyticsQueryInputSchema = {
type: "object",
additionalProperties: false,
required: ["siteUrl", "startDate", "endDate"],
properties: {
siteUrl: {
type: "string",
description: "The property, e.g. `sc-domain:example.com` or `https://www.example.com/`.",
},
startDate: { type: "string", description: "YYYY-MM-DD (inclusive)." },
endDate: { type: "string", description: "YYYY-MM-DD (inclusive)." },
dimensions: {
type: "array",
items: { type: "string", enum: ["query", "page", "country", "device", "date", "searchAppearance"] },
},
type: { type: "string", enum: ["web", "image", "video", "news", "discover", "googleNews"] },
dataState: { type: "string", enum: ["final", "all"] },
rowLimit: { type: "integer", description: "Max rows, up to 25000." },
startRow: { type: "integer", description: "Zero-based row offset for paging." },
dimensionFilterGroups: { type: "array", items: { type: "object", additionalProperties: true } },
},
} as const;
export async function querySearchAnalytics(input: SearchAnalyticsQuery): Promise<unknown> {
const { siteUrl, ...body } = input;
return apiRequest(`/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`, {
method: "POST",
body: JSON.stringify(body),
});
}
export async function listSites(): Promise<unknown> {
return apiRequest("/sites");
}

19
agent/sandbox/sandbox.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineSandbox, defaultBackend } from "eve/sandbox";
export default defineSandbox({
backend: defaultBackend({
// The auditor must reach any site it is pointed at, so it keeps an
// open network policy rather than an allowlist.
vercel: { networkPolicy: "allow-all" },
docker: { networkPolicy: "allow-all" },
}),
// Bump the suffix to force a template rebuild after changing the setup scripts.
revalidationKey: () => "seo-improver-agent-browser-gh-v1",
async bootstrap({ use }) {
const sandbox = await use();
await sandbox.run({ command: "bash setup-agent-browser.sh" });
// `gh` is only used when a blog repo is configured for the optional
// pull-request flow; installing it is cheap and keeps setup uniform.
await sandbox.run({ command: "bash setup-gh.sh seo-improver" });
},
});

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env bash
set -euo pipefail
mkdir -p "${AGENT_BROWSER_ASSETS_DIR:-reports/assets}"
if [ ! -f package.json ]; then
npm init -y >/dev/null
fi
if [ ! -x node_modules/.bin/agent-browser ]; then
npm install agent-browser@latest playwright@latest "$@"
fi
install_agent_browser_shim() {
local bin_dir="/usr/local/bin"
mkdir -p "$bin_dir"
cat > "$bin_dir/agent-browser" <<'SHIM'
#!/usr/bin/env bash
exec /workspace/node_modules/.bin/agent-browser "$@"
SHIM
chmod +x "$bin_dir/agent-browser"
}
install_agent_browser_shim
run_agent_browser_setup_check() {
if command -v timeout >/dev/null 2>&1; then
timeout 60s npx agent-browser --session setup-check open about:blank >/tmp/agent-browser-setup-check.log 2>&1
else
npx agent-browser --session setup-check open about:blank >/tmp/agent-browser-setup-check.log 2>&1
fi
}
validate_agent_browser_config() {
echo "[setup-agent-browser] validating browser launch..."
if run_agent_browser_setup_check; then
npx agent-browser --session setup-check close >/dev/null 2>&1 || true
echo "[setup-agent-browser] browser launch validation passed."
return 0
fi
npx agent-browser --session setup-check close >/dev/null 2>&1 || true
echo "[setup-agent-browser] browser launch validation failed:" >&2
cat /tmp/agent-browser-setup-check.log >&2 || true
rm -f agent-browser.json
return 1
}
if [ -f .agent-browser-ready ] && validate_agent_browser_config; then
exit 0
fi
rm -f .agent-browser-ready
install_system_chromium() {
if ! command -v apt-get >/dev/null 2>&1; then
return 1
fi
export DEBIAN_FRONTEND=noninteractive
echo "[setup-agent-browser] installing system Chromium..."
apt-get update
if ! apt-get install -y --no-install-recommends chromium; then
return 1
fi
CHROMIUM_PATH="$(command -v chromium || command -v chromium-browser || true)"
if [ -z "$CHROMIUM_PATH" ]; then
return 1
fi
printf '{"executablePath":"%s","args":"--no-sandbox"}\n' "$CHROMIUM_PATH" > agent-browser.json
echo "[setup-agent-browser] using Chromium at $CHROMIUM_PATH"
validate_agent_browser_config
}
install_playwright_chromium() {
echo "[setup-agent-browser] installing Playwright Chromium fallback..."
npx playwright install --with-deps chromium
CHROMIUM_PATH="$(node -e "const { chromium } = require('playwright'); console.log(chromium.executablePath())")"
printf '{"executablePath":"%s","args":"--no-sandbox"}\n' "$CHROMIUM_PATH" > agent-browser.json
echo "[setup-agent-browser] using Playwright Chromium at $CHROMIUM_PATH"
validate_agent_browser_config
}
if ! install_system_chromium; then
echo "[setup-agent-browser] system Chromium unavailable or unusable; falling back to Playwright Chromium..."
install_playwright_chromium
fi
touch .agent-browser-ready

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
AGENT_NAME="${1:-atom-eve-agent}"
if ! command -v gh >/dev/null 2>&1; then
GH_VERSION="2.62.0"
TARBALL="gh_${GH_VERSION}_linux_amd64.tar.gz"
URL="https://github.com/cli/cli/releases/download/v${GH_VERSION}/${TARBALL}"
mkdir -p "$HOME/.local"
curl -fsSL "$URL" -o "/tmp/${TARBALL}"
tar -xzf "/tmp/${TARBALL}" -C "$HOME/.local" --strip-components=1
rm -f "/tmp/${TARBALL}"
export PATH="$HOME/.local/bin:$PATH"
if ! grep -qs '.local/bin' "$HOME/.profile" 2>/dev/null; then
printf '\nexport PATH="$HOME/.local/bin:$PATH"\n' >> "$HOME/.profile"
fi
fi
if ! command -v gh >/dev/null 2>&1; then
echo "gh installed but not found on PATH; expected $HOME/.local/bin/gh" >&2
exit 1
fi
git config --global user.name "$AGENT_NAME"
git config --global user.email "${AGENT_NAME}@users.noreply.github.com"
if gh auth status >/dev/null 2>&1; then
gh auth setup-git >/dev/null 2>&1 || true
fi

View File

@@ -0,0 +1,6 @@
import { defineSchedule } from "eve/schedules";
export default defineSchedule({
cron: "0 9 * * 1",
markdown: "Run the weekly SEO improver loop for the configured property and tracked keywords.",
});

View File

@@ -0,0 +1,11 @@
import { defineTool } from "eve/tools";
import { listSites } from "../lib/search-console.js";
export default defineTool({
description:
"List the Search Console properties the configured service account can access. Useful to confirm access and the exact property name before querying.",
inputSchema: { type: "object", additionalProperties: false, properties: {} } as const,
async execute() {
return listSites();
},
});

View File

@@ -0,0 +1,15 @@
import { defineTool } from "eve/tools";
import {
querySearchAnalytics,
searchAnalyticsQueryInputSchema,
type SearchAnalyticsQuery,
} from "../lib/search-console.js";
export default defineTool({
description:
"Query Google Search Console Search Analytics for the property: the site's own real clicks, impressions, CTR, and average position by query, page, country, device, and date. First-party ground truth for striking-distance, low-CTR, cannibalization, and decay analysis.",
inputSchema: searchAnalyticsQueryInputSchema,
async execute(input: unknown) {
return querySearchAnalytics(input as SearchAnalyticsQuery);
},
});