Atom eve seo
This commit is contained in:
109
agent/lib/search-console.ts
Normal file
109
agent/lib/search-console.ts
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user