UNPKG

@weppa-cloud/mcp-search-console

Version:

MCP server for Google Search Console with growth-focused metrics

562 lines (561 loc) 23.6 kB
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; } }