@weppa-cloud/mcp-search-console
Version:
MCP server for Google Search Console with growth-focused metrics
562 lines (561 loc) • 23.6 kB
JavaScript
export class AdvancedSearchTools {
client;
siteUrl;
constructor(client, siteUrl) {
this.client = client;
this.siteUrl = siteUrl;
}
/**
* Find 404 errors and broken pages
* NOTE: Currently using simulated data for URL inspection.
* TODO: Integrate real URL Inspection API in v1.1.1
*/
async find404Errors(days = 30) {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
try {
// First, get all URLs with low performance (potential 404s)
const response = await this.client.searchanalytics.query({
siteUrl: this.siteUrl,
requestBody: {
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
dimensions: ['page'],
dimensionFilterGroups: [{
filters: [{
dimension: 'page',
operator: 'contains',
expression: this.siteUrl
}]
}],
rowLimit: 1000,
},
});
const urls = response.data.rows?.map(row => row.keys?.[0]) || [];
const errors404 = [];
const crawlErrors = [];
const otherErrors = [];
// Inspect each URL for coverage issues
for (const url of urls.slice(0, 50)) { // Limit to 50 for API quota
try {
const inspection = await this.inspectURL(url);
if (inspection.coverageState === 'ERROR' ||
inspection.indexingState === 'ERROR' ||
inspection.pageFetchState === 'NOT_FOUND') {
const errorType = this.categorizeError(inspection);
const errorData = {
...inspection,
errorType,
impact: await this.calculateErrorImpact(url),
};
switch (errorType) {
case '404':
errors404.push(errorData);
break;
case 'CRAWL_ERROR':
crawlErrors.push(errorData);
break;
default:
otherErrors.push(errorData);
}
}
}
catch (error) {
// Skip individual URL errors
continue;
}
}
// Get additional 404s from crawl errors (if available)
const crawlErrorsResponse = await this.getCrawlErrors();
return {
summary: {
total404s: errors404.length,
totalCrawlErrors: crawlErrors.length,
totalOtherErrors: otherErrors.length,
lastChecked: new Date().toISOString(),
},
errors404: errors404.sort((a, b) => b.impact.impressions - a.impact.impressions),
crawlErrors: crawlErrors.sort((a, b) => b.impact.impressions - a.impact.impressions),
otherErrors,
recommendations: this.generate404Recommendations(errors404),
};
}
catch (error) {
throw new Error(`Failed to find 404 errors: ${error.message}`);
}
}
/**
* Analyze Core Web Vitals performance
* NOTE: Currently using simulated CWV data.
* TODO: Integrate real PageSpeed Insights API in v1.1.1
*/
async analyzeCoreWebVitals(days = 28) {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
try {
// Get top pages by traffic
const pagesResponse = await this.client.searchanalytics.query({
siteUrl: this.siteUrl,
requestBody: {
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
dimensions: ['page'],
rowLimit: 50,
},
});
const topPages = pagesResponse.data.rows || [];
const coreWebVitalsData = [];
// Get Core Web Vitals data using PageSpeed Insights API
// Note: This requires additional API setup, using mock data for structure
for (const page of topPages.slice(0, 20)) {
const url = page.keys?.[0];
const vitals = await this.fetchCoreWebVitals(url);
coreWebVitalsData.push(vitals);
}
// Analyze patterns
const analysis = {
summary: {
totalPagesAnalyzed: coreWebVitalsData.length,
passingLCP: coreWebVitalsData.filter(d => d.metrics.LCP?.rating === 'GOOD').length,
passingFID: coreWebVitalsData.filter(d => d.metrics.FID?.rating === 'GOOD').length,
passingCLS: coreWebVitalsData.filter(d => d.metrics.CLS?.rating === 'GOOD').length,
passingINP: coreWebVitalsData.filter(d => d.metrics.INP?.rating === 'GOOD').length,
},
criticalIssues: this.identifyCriticalCWVIssues(coreWebVitalsData),
pagesByPerformance: {
needsImprovement: coreWebVitalsData.filter(d => d.metrics.LCP?.rating === 'NEEDS_IMPROVEMENT' ||
d.metrics.FID?.rating === 'NEEDS_IMPROVEMENT' ||
d.metrics.CLS?.rating === 'NEEDS_IMPROVEMENT'),
poor: coreWebVitalsData.filter(d => d.metrics.LCP?.rating === 'POOR' ||
d.metrics.FID?.rating === 'POOR' ||
d.metrics.CLS?.rating === 'POOR'),
},
recommendations: this.generateCWVRecommendations(coreWebVitalsData),
priorityFixes: this.prioritizeCWVFixes(coreWebVitalsData, topPages),
};
return analysis;
}
catch (error) {
throw new Error(`Failed to analyze Core Web Vitals: ${error.message}`);
}
}
/**
* Advanced content opportunity finder
*/
async findContentOpportunities(options = {}) {
const { minImpressions = 100, maxPosition = 20, minCTR = 0.02 } = options;
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 28);
try {
// Get all queries
const queriesResponse = await this.client.searchanalytics.query({
siteUrl: this.siteUrl,
requestBody: {
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
dimensions: ['query', 'page'],
rowLimit: 5000,
},
});
const data = queriesResponse.data.rows || [];
// Categorize opportunities
const opportunities = {
quickWins: [], // Position 11-20, high impressions
lowHangingFruit: [], // Position 4-10, good CTR potential
featuredSnippets: [], // Position 1-3, low CTR (losing to snippets)
longTail: [], // Low competition, specific queries
contentGaps: [], // High impressions, no good ranking page
cannibalization: [], // Multiple pages for same query
};
// Analyze each query
const queryMap = new Map();
for (const row of data) {
const query = row.keys?.[0];
const page = row.keys?.[1];
const metrics = {
impressions: row.impressions || 0,
clicks: row.clicks || 0,
ctr: row.ctr || 0,
position: row.position || 0,
};
// Quick wins: Page 2 rankings
if (metrics.position >= 11 && metrics.position <= 20 &&
metrics.impressions >= minImpressions) {
opportunities.quickWins.push({
query,
page,
...metrics,
potentialTraffic: this.estimateTrafficGain(metrics, 10),
optimization: this.suggestOptimization(query, page, metrics),
});
}
// Low hanging fruit
if (metrics.position >= 4 && metrics.position <= 10 &&
metrics.impressions >= minImpressions) {
opportunities.lowHangingFruit.push({
query,
page,
...metrics,
potentialTraffic: this.estimateTrafficGain(metrics, 3),
optimization: this.suggestOptimization(query, page, metrics),
});
}
// Featured snippet opportunities
if (metrics.position <= 3 && metrics.ctr < 0.15 &&
metrics.impressions >= minImpressions * 2) {
opportunities.featuredSnippets.push({
query,
page,
...metrics,
snippetType: this.identifySnippetType(query),
optimization: this.suggestSnippetOptimization(query),
});
}
// Track for cannibalization
if (!queryMap.has(query)) {
queryMap.set(query, []);
}
queryMap.get(query).push({ page, ...metrics });
}
// Find cannibalization issues
for (const [query, pages] of queryMap.entries()) {
if (pages.length > 1) {
const totalImpressions = pages.reduce((sum, p) => sum + p.impressions, 0);
if (totalImpressions >= minImpressions) {
opportunities.cannibalization.push({
query,
pages: pages.sort((a, b) => a.position - b.position),
totalImpressions,
recommendation: this.suggestCanonicalStrategy(pages),
});
}
}
}
// Sort and limit results
return {
summary: {
totalOpportunities: Object.values(opportunities).reduce((sum, arr) => sum + arr.length, 0),
estimatedTrafficGain: this.calculateTotalPotentialTraffic(opportunities),
topPriorities: this.prioritizeOpportunities(opportunities),
},
quickWins: opportunities.quickWins.slice(0, 20),
lowHangingFruit: opportunities.lowHangingFruit.slice(0, 20),
featuredSnippets: opportunities.featuredSnippets.slice(0, 10),
cannibalization: opportunities.cannibalization.slice(0, 10),
actionPlan: this.generateActionPlan(opportunities),
};
}
catch (error) {
throw new Error(`Failed to find content opportunities: ${error.message}`);
}
}
// Helper methods
async inspectURL(url) {
// URL Inspection API would be called here
// For now, returning mock structure
// TODO: Implement real URL Inspection API call
console.warn('URL Inspection API not yet implemented - returning simulated data');
return {
url,
coverageState: 'SUBMITTED_AND_INDEXED',
indexingState: 'INDEXING_ALLOWED',
lastCrawlTime: new Date().toISOString(),
pageFetchState: 'SUCCESSFUL',
robotsTxtState: 'ALLOWED',
crawledAs: 'DESKTOP',
verdict: 'PASS',
};
}
categorizeError(inspection) {
if (inspection.pageFetchState === 'NOT_FOUND')
return '404';
if (inspection.pageFetchState === 'SOFT_404')
return 'SOFT_404';
if (inspection.robotsTxtState === 'DISALLOWED')
return 'ROBOTS_BLOCKED';
if (inspection.coverageState === 'ERROR')
return 'CRAWL_ERROR';
return 'OTHER';
}
async calculateErrorImpact(url) {
// Get historical data for the URL
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 90);
try {
const response = await this.client.searchanalytics.query({
siteUrl: this.siteUrl,
requestBody: {
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
dimensions: ['page'],
dimensionFilterGroups: [{
filters: [{
dimension: 'page',
operator: 'equals',
expression: url
}]
}],
},
});
const data = response.data.rows?.[0];
return {
impressions: data?.impressions || 0,
clicks: data?.clicks || 0,
potentialLoss: (data?.clicks || 0) * 30, // Monthly estimate
};
}
catch {
return { impressions: 0, clicks: 0, potentialLoss: 0 };
}
}
async getCrawlErrors() {
// This would use the URL Inspection API in bulk
return [];
}
generate404Recommendations(errors404) {
const recommendations = [];
if (errors404.length > 0) {
recommendations.push({
priority: 'HIGH',
action: 'Implement 301 redirects',
details: `Found ${errors404.length} 404 errors. Redirect to relevant pages or create custom 404 page.`,
impact: errors404.reduce((sum, e) => sum + e.impact.potentialLoss, 0),
});
}
// Group by patterns
const patterns = this.findURLPatterns(errors404.map(e => e.url));
if (patterns.length > 0) {
recommendations.push({
priority: 'MEDIUM',
action: 'Fix systematic URL issues',
details: `Detected patterns in 404s: ${patterns.join(', ')}`,
solution: 'Review URL structure and implement regex redirects',
});
}
return recommendations;
}
findURLPatterns(urls) {
const patterns = new Set();
// Check for common patterns
const categoryPattern = urls.filter(url => url.includes('/category/'));
if (categoryPattern.length > 3)
patterns.add('Category pages');
const datePattern = urls.filter(url => /\/\d{4}\/\d{2}\//.test(url));
if (datePattern.length > 3)
patterns.add('Date-based URLs');
return Array.from(patterns);
}
async fetchCoreWebVitals(url) {
// This would call PageSpeed Insights API
// Returning mock data for structure
// TODO: Implement real PageSpeed Insights API call
console.warn('PageSpeed Insights API not yet implemented - returning simulated data');
return {
url,
device: 'mobile',
metrics: {
LCP: { value: 2.5, rating: 'GOOD' },
FID: { value: 100, rating: 'GOOD' },
CLS: { value: 0.1, rating: 'GOOD' },
INP: { value: 200, rating: 'GOOD' },
TTFB: { value: 800, rating: 'GOOD' },
},
};
}
identifyCriticalCWVIssues(data) {
const issues = [];
const poorLCP = data.filter(d => d.metrics.LCP?.rating === 'POOR');
if (poorLCP.length > 0) {
issues.push({
metric: 'LCP',
severity: 'CRITICAL',
affectedPages: poorLCP.length,
commonCauses: ['Large images', 'Render-blocking resources', 'Slow server response'],
});
}
const poorCLS = data.filter(d => d.metrics.CLS?.rating === 'POOR');
if (poorCLS.length > 0) {
issues.push({
metric: 'CLS',
severity: 'HIGH',
affectedPages: poorCLS.length,
commonCauses: ['Images without dimensions', 'Dynamic content injection', 'Web fonts'],
});
}
return issues;
}
generateCWVRecommendations(data) {
const recommendations = [];
// LCP recommendations
const avgLCP = data.reduce((sum, d) => sum + (d.metrics.LCP?.value || 0), 0) / data.length;
if (avgLCP > 2.5) {
recommendations.push({
metric: 'LCP',
priority: 'HIGH',
actions: [
'Optimize largest image/text block',
'Implement lazy loading',
'Use CDN for static assets',
'Preload critical resources',
],
estimatedImprovement: '30-50% faster LCP',
});
}
return recommendations;
}
prioritizeCWVFixes(vitalsData, trafficData) {
// Combine CWV scores with traffic to prioritize
return vitalsData
.map(vital => {
const traffic = trafficData.find(t => t.keys?.[0] === vital.url);
const score = this.calculatePriorityScore(vital, traffic);
return { ...vital, traffic, priorityScore: score };
})
.sort((a, b) => b.priorityScore - a.priorityScore)
.slice(0, 10);
}
calculatePriorityScore(vital, traffic) {
let score = 0;
// Traffic weight (50%)
score += (traffic?.impressions || 0) * 0.5;
// CWV severity weight (50%)
if (vital.metrics.LCP?.rating === 'POOR')
score += 1000;
if (vital.metrics.CLS?.rating === 'POOR')
score += 800;
if (vital.metrics.INP?.rating === 'POOR')
score += 600;
return score;
}
estimateTrafficGain(metrics, targetPosition) {
const currentCTR = metrics.ctr;
const estimatedCTR = this.getCTRForPosition(targetPosition);
const ctrGain = Math.max(0, estimatedCTR - currentCTR);
return Math.round(metrics.impressions * ctrGain);
}
getCTRForPosition(position) {
// Average CTRs by position
const ctrByPosition = {
1: 0.28, 2: 0.15, 3: 0.11, 4: 0.08, 5: 0.07,
6: 0.05, 7: 0.04, 8: 0.03, 9: 0.03, 10: 0.03,
};
return ctrByPosition[Math.round(position)] || 0.02;
}
suggestOptimization(query, page, metrics) {
const suggestions = [];
if (metrics.position > 10) {
suggestions.push('Add more relevant content');
suggestions.push('Improve internal linking');
suggestions.push('Optimize title and meta description');
}
if (metrics.ctr < this.getCTRForPosition(metrics.position) * 0.8) {
suggestions.push('Improve meta description');
suggestions.push('Make title more compelling');
suggestions.push('Add structured data');
}
return suggestions;
}
identifySnippetType(query) {
if (query.toLowerCase().includes('how to'))
return 'HOW_TO';
if (query.toLowerCase().includes('what is'))
return 'DEFINITION';
if (query.toLowerCase().includes('list of'))
return 'LIST';
if (query.includes('?'))
return 'FAQ';
return 'PARAGRAPH';
}
suggestSnippetOptimization(query) {
const type = this.identifySnippetType(query);
const suggestions = {
'HOW_TO': [
'Use numbered steps',
'Add HowTo schema markup',
'Include clear H2/H3 headings',
],
'DEFINITION': [
'Start with clear definition',
'Use "is/are" statement',
'Keep under 50 words',
],
'LIST': [
'Use bullet points or numbered list',
'Add ListItem schema',
'Include 5-8 items',
],
'FAQ': [
'Use FAQ schema markup',
'Format as Q&A',
'Answer directly in first sentence',
],
'PARAGRAPH': [
'Answer in 40-50 words',
'Put answer in <p> tag near H2',
'Be direct and concise',
],
};
return suggestions[type] || suggestions['PARAGRAPH'];
}
suggestCanonicalStrategy(pages) {
const primary = pages[0]; // Best performing
return {
primaryPage: primary.page,
action: 'Consolidate content to primary page',
method: 'Add canonical tags or 301 redirects',
expectedImprovement: `${Math.round((pages.length - 1) * 20)}% ranking boost`,
};
}
calculateTotalPotentialTraffic(opportunities) {
let total = 0;
if (opportunities.quickWins) {
total += opportunities.quickWins.reduce((sum, o) => sum + (o.potentialTraffic || 0), 0);
}
if (opportunities.lowHangingFruit) {
total += opportunities.lowHangingFruit.reduce((sum, o) => sum + (o.potentialTraffic || 0), 0);
}
return total;
}
prioritizeOpportunities(opportunities) {
const allOpps = [
...opportunities.quickWins.map((o) => ({ ...o, type: 'quickWin' })),
...opportunities.lowHangingFruit.map((o) => ({ ...o, type: 'lowHanging' })),
...opportunities.featuredSnippets.map((o) => ({ ...o, type: 'snippet' })),
];
return allOpps
.sort((a, b) => (b.potentialTraffic || 0) - (a.potentialTraffic || 0))
.slice(0, 10);
}
generateActionPlan(opportunities) {
const plan = [];
if (opportunities.quickWins.length > 0) {
plan.push({
week: '1-2',
focus: 'Quick Wins',
actions: [
'Update meta titles/descriptions',
'Add internal links',
'Improve content depth',
],
expectedResults: `${opportunities.quickWins.length} pages to page 1`,
});
}
if (opportunities.featuredSnippets.length > 0) {
plan.push({
week: '3-4',
focus: 'Featured Snippets',
actions: [
'Restructure content for snippets',
'Add schema markup',
'Create FAQ sections',
],
expectedResults: `Capture ${opportunities.featuredSnippets.length} featured snippets`,
});
}
return plan;
}
}