Google Search Console
Category: SEO & Analytics
Integration type: Tenant OAuth (stored in integrations table)
External API: Google Search Console API v1 (via googleapis SDK)
Purpose
Google Search Console (GSC) provides organic search performance data directly from Google — impressions, clicks, average position, and CTR per keyword and page. This data powers:
- Report Writer — monthly SEO performance section
- Anomaly Detector — detects ranking drops or traffic anomalies
- Site Auditor — baseline organic performance before audit recommendations
- Dashboard — live organic traffic metrics (D4 — SEO Performance screen)
Tenants connect their own Google Search Console property via OAuth. The platform never sees the raw API credentials — only the OAuth tokens stored in integrations.
Config Structure
OAuth flow
Tenant clicks "Connect Google Search Console" in Dashboard → Settings → Integrations
│
▼
Platform redirects to Google OAuth consent screen
scope: https://www.googleapis.com/auth/webmasters.readonly
│
▼
Google redirects back with authorization code
│
▼
Platform exchanges code for access_token + refresh_token
Stores in integrations table:
provider: 'google_search_console'
api_key: encrypt(refresh_token)
metadata: { siteUrl, propertyType, accessToken, accessTokenExpiresAt }Integration record
interface GSCIntegrationMetadata {
siteUrl: string; // e.g. "https://acmeplumbing.com/" or "sc-domain:acmeplumbing.com"
propertyType: 'url' | 'domain';
accessToken: string; // Short-lived (1hr) — refreshed automatically
accessTokenExpiresAt: string; // ISO 8601
}Platform OAuth credentials (env vars)
GOOGLE_CLIENT_ID=xxxxxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxx
GOOGLE_REDIRECT_URI=https://app.leadmetrics.io/api/auth/callback/googleIntegration Pattern
Token refresh
Access tokens expire after 1 hour. The tool layer refreshes automatically before making calls:
async function getGSCClient(integration: Integration): Promise<google.searchconsole_v1.Searchconsole> {
const meta = integration.metadata as GSCIntegrationMetadata;
if (isExpired(meta.accessTokenExpiresAt)) {
const refreshToken = decrypt(integration.api_key);
const { credentials } = await oauth2Client.refreshAccessToken(refreshToken);
// Update stored access token
await db.update(integrations)
.set({ metadata: { ...meta, accessToken: credentials.access_token, accessTokenExpiresAt: ... } })
.where(eq(integrations.id, integration.id));
oauth2Client.setCredentials(credentials);
} else {
oauth2Client.setCredentials({ access_token: meta.accessToken });
}
return google.searchconsole({ version: 'v1', auth: oauth2Client });
}Querying performance data
class GSCTool {
async getPerformance(
integration: Integration,
options: {
startDate: string; // YYYY-MM-DD
endDate: string; // YYYY-MM-DD
dimensions: ('query' | 'page' | 'device' | 'country')[];
rowLimit?: number; // Max 25000
filters?: {
dimension: 'query' | 'page' | 'device' | 'country';
operator: 'equals' | 'contains' | 'notContains' | 'includingRegex' | 'excludingRegex';
expression: string;
}[];
},
): Promise<GSCPerformanceRow[]> {
const client = await getGSCClient(integration);
const meta = integration.metadata as GSCIntegrationMetadata;
const response = await client.searchanalytics.query({
siteUrl: meta.siteUrl,
requestBody: {
startDate: options.startDate,
endDate: options.endDate,
dimensions: options.dimensions,
rowLimit: options.rowLimit ?? 1000,
dataState: 'final', // 'all' includes partial data for recent days
...(options.filters?.length && {
dimensionFilterGroups: [{
filters: options.filters.map(f => ({
dimension: f.dimension,
operator: f.operator,
expression: f.expression,
})),
}],
}),
},
});
return (response.data.rows ?? []).map(row => ({
keys: row.keys ?? [],
clicks: row.clicks ?? 0,
impressions: row.impressions ?? 0,
ctr: row.ctr ?? 0,
position: row.position ?? 0,
}));
}
/**
* Top keywords (queries) by clicks for a date range.
* Used by Report Writer for the organic keywords section.
*/
async getTopKeywords(
integration: Integration,
options: { startDate: string; endDate: string; limit?: number },
): Promise<{ keyword: string; clicks: number; impressions: number; ctr: number; position: number }[]> {
const rows = await this.getPerformance(integration, {
startDate: options.startDate,
endDate: options.endDate,
dimensions: ['query'],
rowLimit: options.limit ?? 25,
});
return rows.map(r => ({
keyword: r.keys[0] ?? '',
clicks: r.clicks,
impressions: r.impressions,
ctr: r.ctr,
position: r.position,
}));
}
/**
* Top pages by clicks for a date range.
* Used by Report Writer for the content performance section.
*/
async getTopPages(
integration: Integration,
options: { startDate: string; endDate: string; limit?: number },
): Promise<{ page: string; clicks: number; impressions: number; position: number }[]> {
const rows = await this.getPerformance(integration, {
startDate: options.startDate,
endDate: options.endDate,
dimensions: ['page'],
rowLimit: options.limit ?? 25,
});
return rows.map(r => ({
page: r.keys[0] ?? '',
clicks: r.clicks,
impressions: r.impressions,
position: r.position,
}));
}
/**
* Weekly click trend for a specific keyword over the past N weeks.
* Used by Keyword Researcher and Site Auditor for trend analysis.
*/
async getKeywordTrend(
integration: Integration,
options: { keyword: string; weeks?: number },
): Promise<{ weekStart: string; clicks: number; impressions: number; position: number }[]> {
const weeks = options.weeks ?? 12;
const endDate = subtractDays(new Date(), 3); // GSC 3-day lag
const startDate = subtractDays(endDate, weeks * 7);
const rows = await this.getPerformance(integration, {
startDate: toISODate(startDate),
endDate: toISODate(endDate),
dimensions: ['date', 'query'],
rowLimit: 25000,
filters: [{
dimension: 'query',
operator: 'equals',
expression: options.keyword,
}],
});
// Aggregate daily rows into weekly buckets
const weeks_map = new Map<string, { clicks: number; impressions: number; positions: number[]; }>();
for (const row of rows) {
const date = row.keys[0]; // 'YYYY-MM-DD'
const weekStart = getWeekStart(date);
const bucket = weeks_map.get(weekStart) ?? { clicks: 0, impressions: 0, positions: [] };
bucket.clicks += row.clicks;
bucket.impressions += row.impressions;
bucket.positions.push(row.position);
weeks_map.set(weekStart, bucket);
}
return [...weeks_map.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([weekStart, b]) => ({
weekStart,
clicks: b.clicks,
impressions: b.impressions,
position: b.positions.length
? b.positions.reduce((s, p) => s + p, 0) / b.positions.length
: 0,
}));
}
/**
* Inspect the indexing status of a specific URL.
* Used by Site Auditor to check whether key pages are indexed.
*/
async getUrlInspection(
integration: Integration,
url: string,
): Promise<{
indexingState: 'INDEXED' | 'NOT_INDEXED' | 'EXCLUDED' | 'UNKNOWN';
coverageState?: string; // Human-readable GSC coverage state
lastCrawlTime?: string; // ISO 8601
robotsTxtState?: 'ALLOWED' | 'DISALLOWED';
indexingAllowed?: boolean;
googleCanonical?: string;
verdict?: string; // e.g. 'PASS', 'FAIL'
}> {
const client = await getGSCClient(integration);
const meta = integration.metadata as GSCIntegrationMetadata;
const response = await client.urlInspection.index.inspect({
requestBody: {
inspectionUrl: url,
siteUrl: meta.siteUrl,
},
});
const result = response.data.inspectionResult;
const idx = result?.indexStatusResult;
return {
indexingState: (idx?.verdict as any) === 'PASS' ? 'INDEXED' : 'NOT_INDEXED',
coverageState: idx?.coverageState ?? undefined,
lastCrawlTime: idx?.lastCrawlTime ?? undefined,
robotsTxtState: idx?.robotsTxtState as any ?? undefined,
indexingAllowed: idx?.indexingState === 'INDEXING_ALLOWED',
googleCanonical: idx?.googleCanonicalUrl ?? undefined,
verdict: idx?.verdict ?? undefined,
};
}
/**
* List all sitemaps submitted for the property.
* Used by Site Auditor to validate sitemap submission.
*/
async getSitemaps(
integration: Integration,
): Promise<{
path: string;
type: string; // e.g. 'sitemap', 'atomFeed'
lastSubmitted?: string; // ISO 8601
lastDownloaded?: string; // ISO 8601
errors: number;
warnings: number;
urlCount?: number;
}[]> {
const client = await getGSCClient(integration);
const meta = integration.metadata as GSCIntegrationMetadata;
const response = await client.sitemaps.list({ siteUrl: meta.siteUrl });
return (response.data.sitemap ?? []).map(s => ({
path: s.path ?? '',
type: s.type ?? 'sitemap',
lastSubmitted: s.lastSubmitted ?? undefined,
lastDownloaded: s.lastDownloaded ?? undefined,
errors: Number(s.errors ?? 0),
warnings: Number(s.warnings ?? 0),
urlCount: s.contents?.[0]?.submitted ? Number(s.contents[0].submitted) : undefined,
}));
}
/**
* Verify that the integration is valid and has at least one accessible property.
*/
async verify(integration: Integration): Promise<void> {
const properties = await this.listProperties(integration);
if (properties.length === 0) {
throw new Error('No verified Search Console properties found for this account');
}
}
async listProperties(integration: Integration): Promise<string[]> {
const client = await getGSCClient(integration);
const response = await client.sites.list();
return (response.data.siteEntry ?? [])
.filter(s => s.permissionLevel === 'siteOwner' || s.permissionLevel === 'siteFullUser')
.map(s => s.siteUrl!)
.filter(Boolean);
}
}Data Latency
GSC data is delayed by 2–3 days. When generating reports for a given month, always use endDate = last day of month − 3 days, not yesterday. The platform uses dataState: 'final' to avoid partial data.
Test Cases
Unit tests (packages/tools/src/google-search-console.test.ts)
| Test | Approach |
|---|---|
getPerformance() builds correct request body | Mock searchanalytics.query; assert startDate, endDate, dimensions |
getPerformance() includes dimensionFilterGroups when filters provided | Mock query; assert filter array in request body |
getPerformance() omits dimensionFilterGroups when no filters | Assert key absent from request body when filters not passed |
getPerformance() maps rows to typed objects | Feed mock rows; assert clicks, impressions, ctr, position |
getPerformance() returns empty array when rows is null | Mock { rows: null }; assert [] returned |
getTopKeywords() defaults limit to 25 | Assert rowLimit: 25 passed to getPerformance |
getTopKeywords() maps keys[0] to keyword | Feed row with keys: ['seo tools']; assert keyword: 'seo tools' |
getTopPages() maps keys[0] to page | Feed row with keys: ['/blog/seo']; assert page: '/blog/seo' |
getKeywordTrend() aggregates daily rows into weekly buckets | Feed 14 daily rows spanning 2 weeks; assert 2 weekly results |
getKeywordTrend() filters by the target keyword | Assert filters contains { dimension: 'query', operator: 'equals', expression: keyword } |
getKeywordTrend() computes average position per week | Feed 3 daily rows with positions 2, 4, 6; assert avg = 4 |
getUrlInspection() maps verdict PASS to INDEXED | Mock verdict: 'PASS'; assert indexingState: 'INDEXED' |
getUrlInspection() maps non-PASS verdict to NOT_INDEXED | Mock verdict: 'FAIL'; assert indexingState: 'NOT_INDEXED' |
getSitemaps() maps error/warning counts as numbers | Mock string values '3'; assert Number conversion |
verify() throws when no properties returned | Mock listProperties returning []; assert throws |
verify() resolves when at least one property exists | Mock returning ['https://example.com/']; assert resolves |
| Token refresh triggered when access token expired | Mock expired accessTokenExpiresAt; assert refreshAccessToken called |
| Throws when OAuth scope revoked (401) | Mock googleapis throws 401; assert error propagated |
Integration tests
| Test | Approach |
|---|---|
| List properties for test account | Use test OAuth tokens; assert at least one property returned |
| Fetch performance for known site | Assert clicks/impressions > 0 for previous month |
Related
- Google Analytics Provider — complementary traffic analytics
- Site Auditor Agent — uses GSC data for baseline
- Report Writer Agent — includes GSC data in monthly reports
- Tool Integration Layer — OAuth integration management