pagespeed-mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/index.js +901 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 PhialsBasement
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# PageSpeed MCP Server
|
|
2
|
+
|
|
3
|
+
A Model Context Protocol (MCP) server for PageSpeed & SEO audits. **Zero configuration required** - just install and use.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
7 powerful SEO audit tools:
|
|
8
|
+
|
|
9
|
+
| Tool | Description |
|
|
10
|
+
|------|-------------|
|
|
11
|
+
| `run_pagespeed_test` | Full PageSpeed Insights data (performance, accessibility, SEO, best practices) |
|
|
12
|
+
| `technical_seo_audit` | Meta tags, canonical URLs, hreflang, Open Graph, Twitter Cards, JSON-LD structured data |
|
|
13
|
+
| `sitemap_robots_audit` | Validate sitemap.xml and robots.txt |
|
|
14
|
+
| `onpage_seo_audit` | Heading structure, word count, internal/external links, images, keyword analysis |
|
|
15
|
+
| `security_headers_audit` | HTTPS, HSTS, CSP, X-Frame-Options, and more |
|
|
16
|
+
| `performance_audit` | Core Web Vitals summary with optimization opportunities |
|
|
17
|
+
| `mobile_audit` | Mobile-friendliness: viewport, tap targets, font sizes, responsive images, PWA readiness |
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
Add this to your Claude Desktop config:
|
|
22
|
+
|
|
23
|
+
**macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
24
|
+
**Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"mcpServers": {
|
|
29
|
+
"pagespeed": {
|
|
30
|
+
"command": "npx",
|
|
31
|
+
"args": ["-y", "pagespeed-mcp-server"]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Restart Claude Desktop. That's it!
|
|
38
|
+
|
|
39
|
+
## Example Prompts
|
|
40
|
+
|
|
41
|
+
- "Run an SEO audit on https://example.com"
|
|
42
|
+
- "Check the Core Web Vitals for my website"
|
|
43
|
+
- "Is my site mobile-friendly?"
|
|
44
|
+
- "Analyze https://mysite.com/blog for the keyword 'seo tips'"
|
|
45
|
+
- "Check if my sitemap and robots.txt are configured correctly"
|
|
46
|
+
|
|
47
|
+
## Tools
|
|
48
|
+
|
|
49
|
+
### `run_pagespeed_test`
|
|
50
|
+
Full PageSpeed Insights API response with all Lighthouse data.
|
|
51
|
+
|
|
52
|
+
| Parameter | Type | Default | Description |
|
|
53
|
+
|-----------|------|---------|-------------|
|
|
54
|
+
| `url` | string | required | URL to test |
|
|
55
|
+
| `strategy` | string | `mobile` | `mobile` or `desktop` |
|
|
56
|
+
| `category` | array | `['performance']` | Any of: `performance`, `accessibility`, `best-practices`, `pwa`, `seo` |
|
|
57
|
+
|
|
58
|
+
### `technical_seo_audit`
|
|
59
|
+
Analyzes technical SEO elements.
|
|
60
|
+
|
|
61
|
+
**Checks:** Title tag, meta description, canonical URL, hreflang tags, Open Graph, Twitter Cards, JSON-LD structured data, viewport, language attribute.
|
|
62
|
+
|
|
63
|
+
### `sitemap_robots_audit`
|
|
64
|
+
Validates sitemap and robots.txt.
|
|
65
|
+
|
|
66
|
+
**Checks:** robots.txt existence/content, sitemap references, sitemap.xml format, URL count.
|
|
67
|
+
|
|
68
|
+
### `onpage_seo_audit`
|
|
69
|
+
Analyzes on-page SEO factors.
|
|
70
|
+
|
|
71
|
+
| Parameter | Type | Description |
|
|
72
|
+
|-----------|------|-------------|
|
|
73
|
+
| `url` | string | URL to analyze |
|
|
74
|
+
| `targetKeyword` | string | Optional keyword to check optimization |
|
|
75
|
+
|
|
76
|
+
**Checks:** Heading structure (H1-H6), word count, internal/external links, image alt text, keyword placement/density.
|
|
77
|
+
|
|
78
|
+
### `security_headers_audit`
|
|
79
|
+
Checks security headers.
|
|
80
|
+
|
|
81
|
+
**Checks:** HTTPS, Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy.
|
|
82
|
+
|
|
83
|
+
### `performance_audit`
|
|
84
|
+
Summarized performance report.
|
|
85
|
+
|
|
86
|
+
**Returns:** Performance/Accessibility/SEO scores, Core Web Vitals (FCP, LCP, TBT, CLS), top optimization opportunities, overall grade (A-F).
|
|
87
|
+
|
|
88
|
+
### `mobile_audit`
|
|
89
|
+
Mobile-friendliness check.
|
|
90
|
+
|
|
91
|
+
**Checks:** Viewport configuration, tap target sizes, font sizes, content width, responsive images, PWA elements (manifest, theme-color, touch icons).
|
|
92
|
+
|
|
93
|
+
## Using Your Own API Key (Optional)
|
|
94
|
+
|
|
95
|
+
The server works out of the box. For higher rate limits, add your own key:
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"mcpServers": {
|
|
100
|
+
"pagespeed": {
|
|
101
|
+
"command": "npx",
|
|
102
|
+
"args": ["-y", "pagespeed-mcp-server"],
|
|
103
|
+
"env": {
|
|
104
|
+
"PAGESPEED_API_KEY": "your-api-key-here"
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Get a free API key at [Google Cloud Console](https://console.cloud.google.com/apis/credentials).
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
|
116
|
+
|
|
117
|
+
## About Me
|
|
118
|
+
|
|
119
|
+
I'm **Sofian Bettayeb**.
|
|
120
|
+
|
|
121
|
+
By day, I'm a martech consultant, working with billion-dollar brands like Rolex and Helsana. By night, I build tools like **AI SEO Copilot** (15k+ installs), **AEO Copilot**, and blueprints like **Webflow SEO Checklist** (1k+ downloads) to help my Webflow friends make money with SEO and AEO.
|
|
122
|
+
|
|
123
|
+
In between, I ride my bikes and play with my kids in Bern, Switzerland.
|
|
124
|
+
|
|
125
|
+
[GitHub](https://github.com/sofianbettayeb)
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
7
|
+
import fetch from 'node-fetch';
|
|
8
|
+
import * as cheerio from 'cheerio';
|
|
9
|
+
// ============================================
|
|
10
|
+
// API KEY CONFIGURATION
|
|
11
|
+
// ============================================
|
|
12
|
+
// Default API key for PageSpeed Insights - works out of the box
|
|
13
|
+
// Users can override with PAGESPEED_API_KEY env var for higher quotas
|
|
14
|
+
const DEFAULT_API_KEY = "AIzaSyCy_h1_YPQUKGMvLusmCJDZ_q9uoBbYnuU";
|
|
15
|
+
function getApiKey() {
|
|
16
|
+
// Priority: 1) Environment variable, 2) Default key
|
|
17
|
+
return process.env.PAGESPEED_API_KEY || DEFAULT_API_KEY;
|
|
18
|
+
}
|
|
19
|
+
// ============================================
|
|
20
|
+
// SCHEMA DEFINITIONS
|
|
21
|
+
// ============================================
|
|
22
|
+
const UrlSchema = z.object({
|
|
23
|
+
url: z.string().url()
|
|
24
|
+
});
|
|
25
|
+
const RunPageSpeedTestSchema = z.object({
|
|
26
|
+
url: z.string().url(),
|
|
27
|
+
strategy: z.enum(['mobile', 'desktop']).default('mobile'),
|
|
28
|
+
category: z.array(z.enum([
|
|
29
|
+
'accessibility',
|
|
30
|
+
'best-practices',
|
|
31
|
+
'performance',
|
|
32
|
+
'pwa',
|
|
33
|
+
'seo'
|
|
34
|
+
])).default(['performance']),
|
|
35
|
+
locale: z.string().default('en'),
|
|
36
|
+
apiKey: z.string().optional()
|
|
37
|
+
});
|
|
38
|
+
const TechnicalSeoAuditSchema = z.object({
|
|
39
|
+
url: z.string().url()
|
|
40
|
+
});
|
|
41
|
+
const SitemapRobotsSchema = z.object({
|
|
42
|
+
url: z.string().url()
|
|
43
|
+
});
|
|
44
|
+
const OnPageSeoSchema = z.object({
|
|
45
|
+
url: z.string().url(),
|
|
46
|
+
targetKeyword: z.string().optional()
|
|
47
|
+
});
|
|
48
|
+
const SecurityHeadersSchema = z.object({
|
|
49
|
+
url: z.string().url()
|
|
50
|
+
});
|
|
51
|
+
const PerformanceAuditSchema = z.object({
|
|
52
|
+
url: z.string().url(),
|
|
53
|
+
strategy: z.enum(['mobile', 'desktop']).default('mobile')
|
|
54
|
+
});
|
|
55
|
+
const MobileAuditSchema = z.object({
|
|
56
|
+
url: z.string().url()
|
|
57
|
+
});
|
|
58
|
+
// ============================================
|
|
59
|
+
// SERVER SETUP
|
|
60
|
+
// ============================================
|
|
61
|
+
const server = new Server({
|
|
62
|
+
name: "seo-audit-server",
|
|
63
|
+
version: "2.1.0",
|
|
64
|
+
}, {
|
|
65
|
+
capabilities: {
|
|
66
|
+
tools: {},
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
// ============================================
|
|
70
|
+
// HELPER FUNCTIONS
|
|
71
|
+
// ============================================
|
|
72
|
+
async function fetchPage(url) {
|
|
73
|
+
const response = await fetch(url, {
|
|
74
|
+
headers: {
|
|
75
|
+
'User-Agent': 'Mozilla/5.0 (compatible; SEOAuditBot/1.0)'
|
|
76
|
+
},
|
|
77
|
+
redirect: 'follow'
|
|
78
|
+
});
|
|
79
|
+
const html = await response.text();
|
|
80
|
+
const headers = {};
|
|
81
|
+
response.headers.forEach((value, key) => {
|
|
82
|
+
headers[key] = value;
|
|
83
|
+
});
|
|
84
|
+
return { html, response, headers };
|
|
85
|
+
}
|
|
86
|
+
function getBaseUrl(url) {
|
|
87
|
+
const parsed = new URL(url);
|
|
88
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
89
|
+
}
|
|
90
|
+
// ============================================
|
|
91
|
+
// TOOL IMPLEMENTATIONS
|
|
92
|
+
// ============================================
|
|
93
|
+
// 1. PageSpeed Test (existing, with env var fix)
|
|
94
|
+
async function runPageSpeedTest(params) {
|
|
95
|
+
const { url, strategy, category, locale, apiKey: passedApiKey } = params;
|
|
96
|
+
const apiKey = passedApiKey || getApiKey();
|
|
97
|
+
const categoriesParam = category.join('&category=');
|
|
98
|
+
const apiUrl = `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(url)}&strategy=${strategy}&category=${categoriesParam}&locale=${locale}${apiKey ? `&key=${apiKey}` : ''}`;
|
|
99
|
+
const response = await fetch(apiUrl);
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
throw new Error(`PageSpeed API error: ${response.statusText}`);
|
|
102
|
+
}
|
|
103
|
+
const data = await response.json();
|
|
104
|
+
return data;
|
|
105
|
+
}
|
|
106
|
+
// 2. Technical SEO Audit
|
|
107
|
+
async function runTechnicalSeoAudit(params) {
|
|
108
|
+
const { url } = params;
|
|
109
|
+
const { html, headers } = await fetchPage(url);
|
|
110
|
+
const $ = cheerio.load(html);
|
|
111
|
+
// Meta tags analysis
|
|
112
|
+
const metaTags = {
|
|
113
|
+
title: $('title').text() || null,
|
|
114
|
+
titleLength: $('title').text()?.length || 0,
|
|
115
|
+
metaDescription: $('meta[name="description"]').attr('content') || null,
|
|
116
|
+
metaDescriptionLength: $('meta[name="description"]').attr('content')?.length || 0,
|
|
117
|
+
metaKeywords: $('meta[name="keywords"]').attr('content') || null,
|
|
118
|
+
metaRobots: $('meta[name="robots"]').attr('content') || null,
|
|
119
|
+
metaViewport: $('meta[name="viewport"]').attr('content') || null,
|
|
120
|
+
ogTitle: $('meta[property="og:title"]').attr('content') || null,
|
|
121
|
+
ogDescription: $('meta[property="og:description"]').attr('content') || null,
|
|
122
|
+
ogImage: $('meta[property="og:image"]').attr('content') || null,
|
|
123
|
+
ogUrl: $('meta[property="og:url"]').attr('content') || null,
|
|
124
|
+
ogType: $('meta[property="og:type"]').attr('content') || null,
|
|
125
|
+
twitterCard: $('meta[name="twitter:card"]').attr('content') || null,
|
|
126
|
+
twitterTitle: $('meta[name="twitter:title"]').attr('content') || null,
|
|
127
|
+
twitterDescription: $('meta[name="twitter:description"]').attr('content') || null,
|
|
128
|
+
twitterImage: $('meta[name="twitter:image"]').attr('content') || null,
|
|
129
|
+
};
|
|
130
|
+
// Canonical URL
|
|
131
|
+
const canonical = $('link[rel="canonical"]').attr('href') || null;
|
|
132
|
+
// Hreflang tags
|
|
133
|
+
const hreflangTags = [];
|
|
134
|
+
$('link[rel="alternate"][hreflang]').each((_, el) => {
|
|
135
|
+
hreflangTags.push({
|
|
136
|
+
lang: $(el).attr('hreflang') || '',
|
|
137
|
+
href: $(el).attr('href') || ''
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
// Structured data (JSON-LD)
|
|
141
|
+
const structuredData = [];
|
|
142
|
+
$('script[type="application/ld+json"]').each((_, el) => {
|
|
143
|
+
try {
|
|
144
|
+
const json = JSON.parse($(el).html() || '');
|
|
145
|
+
structuredData.push(json);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
structuredData.push({ error: 'Invalid JSON-LD', raw: $(el).html()?.substring(0, 200) });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
// Language
|
|
152
|
+
const htmlLang = $('html').attr('lang') || null;
|
|
153
|
+
// Charset
|
|
154
|
+
const charset = $('meta[charset]').attr('charset') ||
|
|
155
|
+
$('meta[http-equiv="Content-Type"]').attr('content') || null;
|
|
156
|
+
// Issues and recommendations
|
|
157
|
+
const issues = [];
|
|
158
|
+
const recommendations = [];
|
|
159
|
+
// Title checks
|
|
160
|
+
if (!metaTags.title) {
|
|
161
|
+
issues.push('Missing title tag');
|
|
162
|
+
}
|
|
163
|
+
else if (metaTags.titleLength < 30) {
|
|
164
|
+
recommendations.push(`Title is too short (${metaTags.titleLength} chars). Aim for 50-60 characters.`);
|
|
165
|
+
}
|
|
166
|
+
else if (metaTags.titleLength > 60) {
|
|
167
|
+
recommendations.push(`Title is too long (${metaTags.titleLength} chars). Keep under 60 characters.`);
|
|
168
|
+
}
|
|
169
|
+
// Meta description checks
|
|
170
|
+
if (!metaTags.metaDescription) {
|
|
171
|
+
issues.push('Missing meta description');
|
|
172
|
+
}
|
|
173
|
+
else if (metaTags.metaDescriptionLength < 120) {
|
|
174
|
+
recommendations.push(`Meta description is short (${metaTags.metaDescriptionLength} chars). Aim for 150-160 characters.`);
|
|
175
|
+
}
|
|
176
|
+
else if (metaTags.metaDescriptionLength > 160) {
|
|
177
|
+
recommendations.push(`Meta description is too long (${metaTags.metaDescriptionLength} chars). Keep under 160 characters.`);
|
|
178
|
+
}
|
|
179
|
+
// Canonical check
|
|
180
|
+
if (!canonical) {
|
|
181
|
+
recommendations.push('Consider adding a canonical URL to prevent duplicate content issues.');
|
|
182
|
+
}
|
|
183
|
+
// Viewport check
|
|
184
|
+
if (!metaTags.metaViewport) {
|
|
185
|
+
issues.push('Missing viewport meta tag - site may not be mobile-friendly');
|
|
186
|
+
}
|
|
187
|
+
// Open Graph checks
|
|
188
|
+
if (!metaTags.ogTitle || !metaTags.ogDescription || !metaTags.ogImage) {
|
|
189
|
+
recommendations.push('Missing Open Graph tags - social sharing may not display properly.');
|
|
190
|
+
}
|
|
191
|
+
// Language check
|
|
192
|
+
if (!htmlLang) {
|
|
193
|
+
recommendations.push('Missing lang attribute on <html> tag. Important for accessibility and SEO.');
|
|
194
|
+
}
|
|
195
|
+
// Structured data check
|
|
196
|
+
if (structuredData.length === 0) {
|
|
197
|
+
recommendations.push('No structured data found. Consider adding JSON-LD schema markup.');
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
url,
|
|
201
|
+
metaTags,
|
|
202
|
+
canonical,
|
|
203
|
+
hreflangTags,
|
|
204
|
+
structuredData,
|
|
205
|
+
htmlLang,
|
|
206
|
+
charset,
|
|
207
|
+
issues,
|
|
208
|
+
recommendations,
|
|
209
|
+
score: {
|
|
210
|
+
total: 100 - (issues.length * 15) - (recommendations.length * 5),
|
|
211
|
+
issuesCount: issues.length,
|
|
212
|
+
recommendationsCount: recommendations.length
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
// 3. Sitemap & Robots.txt Validation
|
|
217
|
+
async function runSitemapRobotsAudit(params) {
|
|
218
|
+
const { url } = params;
|
|
219
|
+
const baseUrl = getBaseUrl(url);
|
|
220
|
+
const results = {
|
|
221
|
+
url,
|
|
222
|
+
robotsTxt: {
|
|
223
|
+
exists: false,
|
|
224
|
+
content: null,
|
|
225
|
+
issues: [],
|
|
226
|
+
sitemapReferences: []
|
|
227
|
+
},
|
|
228
|
+
sitemap: {
|
|
229
|
+
exists: false,
|
|
230
|
+
url: null,
|
|
231
|
+
urlCount: 0,
|
|
232
|
+
issues: [],
|
|
233
|
+
sampleUrls: []
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
// Check robots.txt
|
|
237
|
+
try {
|
|
238
|
+
const robotsUrl = `${baseUrl}/robots.txt`;
|
|
239
|
+
const robotsResponse = await fetch(robotsUrl);
|
|
240
|
+
if (robotsResponse.ok) {
|
|
241
|
+
results.robotsTxt.exists = true;
|
|
242
|
+
results.robotsTxt.content = await robotsResponse.text();
|
|
243
|
+
// Parse robots.txt
|
|
244
|
+
const lines = results.robotsTxt.content.split('\n');
|
|
245
|
+
for (const line of lines) {
|
|
246
|
+
const trimmed = line.trim().toLowerCase();
|
|
247
|
+
if (trimmed.startsWith('sitemap:')) {
|
|
248
|
+
results.robotsTxt.sitemapReferences.push(line.split(':').slice(1).join(':').trim());
|
|
249
|
+
}
|
|
250
|
+
if (trimmed === 'disallow: /') {
|
|
251
|
+
results.robotsTxt.issues.push('WARNING: Entire site is blocked from crawling (Disallow: /)');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
results.robotsTxt.issues.push('robots.txt not found or not accessible');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
results.robotsTxt.issues.push(`Error fetching robots.txt: ${error}`);
|
|
261
|
+
}
|
|
262
|
+
// Check sitemap
|
|
263
|
+
const sitemapUrls = [
|
|
264
|
+
...results.robotsTxt.sitemapReferences,
|
|
265
|
+
`${baseUrl}/sitemap.xml`,
|
|
266
|
+
`${baseUrl}/sitemap_index.xml`,
|
|
267
|
+
`${baseUrl}/sitemap/sitemap.xml`
|
|
268
|
+
];
|
|
269
|
+
for (const sitemapUrl of [...new Set(sitemapUrls)]) {
|
|
270
|
+
try {
|
|
271
|
+
const sitemapResponse = await fetch(sitemapUrl);
|
|
272
|
+
if (sitemapResponse.ok) {
|
|
273
|
+
results.sitemap.exists = true;
|
|
274
|
+
results.sitemap.url = sitemapUrl;
|
|
275
|
+
const sitemapContent = await sitemapResponse.text();
|
|
276
|
+
// Parse sitemap
|
|
277
|
+
const $ = cheerio.load(sitemapContent, { xmlMode: true });
|
|
278
|
+
// Check if it's a sitemap index
|
|
279
|
+
const sitemapIndex = $('sitemapindex sitemap loc');
|
|
280
|
+
if (sitemapIndex.length > 0) {
|
|
281
|
+
results.sitemap.type = 'sitemap_index';
|
|
282
|
+
results.sitemap.childSitemaps = [];
|
|
283
|
+
sitemapIndex.each((_, el) => {
|
|
284
|
+
results.sitemap.childSitemaps.push($(el).text());
|
|
285
|
+
});
|
|
286
|
+
results.sitemap.urlCount = sitemapIndex.length;
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
// Regular sitemap
|
|
290
|
+
results.sitemap.type = 'sitemap';
|
|
291
|
+
const urls = $('urlset url loc');
|
|
292
|
+
results.sitemap.urlCount = urls.length;
|
|
293
|
+
urls.slice(0, 5).each((_, el) => {
|
|
294
|
+
results.sitemap.sampleUrls.push($(el).text());
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
// Validation
|
|
298
|
+
if (results.sitemap.urlCount === 0) {
|
|
299
|
+
results.sitemap.issues.push('Sitemap appears to be empty');
|
|
300
|
+
}
|
|
301
|
+
if (results.sitemap.urlCount > 50000) {
|
|
302
|
+
results.sitemap.issues.push('Sitemap exceeds 50,000 URL limit');
|
|
303
|
+
}
|
|
304
|
+
break; // Found a valid sitemap
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
// Continue to next sitemap URL
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (!results.sitemap.exists) {
|
|
312
|
+
results.sitemap.issues.push('No sitemap found. Consider creating one for better indexing.');
|
|
313
|
+
}
|
|
314
|
+
return results;
|
|
315
|
+
}
|
|
316
|
+
// 4. On-Page SEO Analysis
|
|
317
|
+
async function runOnPageSeoAudit(params) {
|
|
318
|
+
const { url, targetKeyword } = params;
|
|
319
|
+
const { html } = await fetchPage(url);
|
|
320
|
+
const $ = cheerio.load(html);
|
|
321
|
+
// Remove script and style tags for content analysis
|
|
322
|
+
$('script, style, noscript').remove();
|
|
323
|
+
// Heading structure
|
|
324
|
+
const headings = {
|
|
325
|
+
h1: [],
|
|
326
|
+
h2: [],
|
|
327
|
+
h3: [],
|
|
328
|
+
h4: [],
|
|
329
|
+
h5: [],
|
|
330
|
+
h6: []
|
|
331
|
+
};
|
|
332
|
+
for (let i = 1; i <= 6; i++) {
|
|
333
|
+
$(`h${i}`).each((_, el) => {
|
|
334
|
+
const text = $(el).text().trim();
|
|
335
|
+
if (text)
|
|
336
|
+
headings[`h${i}`].push(text);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
// Content analysis
|
|
340
|
+
const bodyText = $('body').text().replace(/\s+/g, ' ').trim();
|
|
341
|
+
const wordCount = bodyText.split(/\s+/).filter(w => w.length > 0).length;
|
|
342
|
+
// Links analysis
|
|
343
|
+
const internalLinks = [];
|
|
344
|
+
const externalLinks = [];
|
|
345
|
+
const baseHost = new URL(url).host;
|
|
346
|
+
$('a[href]').each((_, el) => {
|
|
347
|
+
const href = $(el).attr('href');
|
|
348
|
+
if (!href)
|
|
349
|
+
return;
|
|
350
|
+
try {
|
|
351
|
+
const linkUrl = new URL(href, url);
|
|
352
|
+
if (linkUrl.host === baseHost) {
|
|
353
|
+
internalLinks.push(href);
|
|
354
|
+
}
|
|
355
|
+
else if (linkUrl.protocol.startsWith('http')) {
|
|
356
|
+
externalLinks.push(href);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
// Invalid URL, skip
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
// Images analysis
|
|
364
|
+
const images = [];
|
|
365
|
+
$('img').each((_, el) => {
|
|
366
|
+
const src = $(el).attr('src') || $(el).attr('data-src') || '';
|
|
367
|
+
const alt = $(el).attr('alt');
|
|
368
|
+
images.push({
|
|
369
|
+
src: src.substring(0, 100),
|
|
370
|
+
alt: alt || null,
|
|
371
|
+
hasAlt: alt !== undefined && alt !== ''
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
const imagesWithoutAlt = images.filter(img => !img.hasAlt).length;
|
|
375
|
+
// Keyword analysis (if provided)
|
|
376
|
+
let keywordAnalysis = null;
|
|
377
|
+
if (targetKeyword) {
|
|
378
|
+
const keyword = targetKeyword.toLowerCase();
|
|
379
|
+
const titleText = $('title').text().toLowerCase();
|
|
380
|
+
const metaDesc = ($('meta[name="description"]').attr('content') || '').toLowerCase();
|
|
381
|
+
const h1Text = headings.h1.join(' ').toLowerCase();
|
|
382
|
+
const bodyLower = bodyText.toLowerCase();
|
|
383
|
+
// Count keyword occurrences
|
|
384
|
+
const keywordRegex = new RegExp(keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
|
385
|
+
const keywordCount = (bodyText.match(keywordRegex) || []).length;
|
|
386
|
+
const keywordDensity = wordCount > 0 ? ((keywordCount / wordCount) * 100).toFixed(2) : '0';
|
|
387
|
+
keywordAnalysis = {
|
|
388
|
+
keyword: targetKeyword,
|
|
389
|
+
inTitle: titleText.includes(keyword),
|
|
390
|
+
inMetaDescription: metaDesc.includes(keyword),
|
|
391
|
+
inH1: h1Text.includes(keyword),
|
|
392
|
+
inFirst100Words: bodyLower.substring(0, bodyLower.split(/\s+/).slice(0, 100).join(' ').length).includes(keyword),
|
|
393
|
+
totalOccurrences: keywordCount,
|
|
394
|
+
density: `${keywordDensity}%`
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
// Issues and recommendations
|
|
398
|
+
const issues = [];
|
|
399
|
+
const recommendations = [];
|
|
400
|
+
// H1 checks
|
|
401
|
+
if (headings.h1.length === 0) {
|
|
402
|
+
issues.push('Missing H1 tag');
|
|
403
|
+
}
|
|
404
|
+
else if (headings.h1.length > 1) {
|
|
405
|
+
recommendations.push(`Multiple H1 tags found (${headings.h1.length}). Consider using only one H1.`);
|
|
406
|
+
}
|
|
407
|
+
// Content length
|
|
408
|
+
if (wordCount < 300) {
|
|
409
|
+
recommendations.push(`Content is thin (${wordCount} words). Aim for at least 300 words for better SEO.`);
|
|
410
|
+
}
|
|
411
|
+
// Images
|
|
412
|
+
if (imagesWithoutAlt > 0) {
|
|
413
|
+
issues.push(`${imagesWithoutAlt} image(s) missing alt text`);
|
|
414
|
+
}
|
|
415
|
+
// Internal linking
|
|
416
|
+
if (internalLinks.length < 3) {
|
|
417
|
+
recommendations.push('Few internal links. Add more internal links for better site structure.');
|
|
418
|
+
}
|
|
419
|
+
// Keyword optimization
|
|
420
|
+
if (keywordAnalysis) {
|
|
421
|
+
if (!keywordAnalysis.inTitle) {
|
|
422
|
+
recommendations.push(`Target keyword "${targetKeyword}" not found in title`);
|
|
423
|
+
}
|
|
424
|
+
if (!keywordAnalysis.inH1) {
|
|
425
|
+
recommendations.push(`Target keyword "${targetKeyword}" not found in H1`);
|
|
426
|
+
}
|
|
427
|
+
if (!keywordAnalysis.inMetaDescription) {
|
|
428
|
+
recommendations.push(`Target keyword "${targetKeyword}" not found in meta description`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
url,
|
|
433
|
+
headings: {
|
|
434
|
+
h1: headings.h1,
|
|
435
|
+
h2: headings.h2,
|
|
436
|
+
h3: headings.h3.slice(0, 10),
|
|
437
|
+
structure: `H1: ${headings.h1.length}, H2: ${headings.h2.length}, H3: ${headings.h3.length}, H4: ${headings.h4.length}`
|
|
438
|
+
},
|
|
439
|
+
content: {
|
|
440
|
+
wordCount,
|
|
441
|
+
readingTime: `${Math.ceil(wordCount / 200)} min`
|
|
442
|
+
},
|
|
443
|
+
links: {
|
|
444
|
+
internal: internalLinks.length,
|
|
445
|
+
external: externalLinks.length,
|
|
446
|
+
sampleInternal: internalLinks.slice(0, 5),
|
|
447
|
+
sampleExternal: externalLinks.slice(0, 5)
|
|
448
|
+
},
|
|
449
|
+
images: {
|
|
450
|
+
total: images.length,
|
|
451
|
+
withoutAlt: imagesWithoutAlt,
|
|
452
|
+
altCoverage: images.length > 0 ? `${((1 - imagesWithoutAlt / images.length) * 100).toFixed(0)}%` : 'N/A'
|
|
453
|
+
},
|
|
454
|
+
keywordAnalysis,
|
|
455
|
+
issues,
|
|
456
|
+
recommendations
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
// 5. Security & Headers Check
|
|
460
|
+
async function runSecurityHeadersAudit(params) {
|
|
461
|
+
const { url } = params;
|
|
462
|
+
const { headers, response } = await fetchPage(url);
|
|
463
|
+
const parsedUrl = new URL(url);
|
|
464
|
+
const isHttps = parsedUrl.protocol === 'https:';
|
|
465
|
+
// Security headers to check
|
|
466
|
+
const securityHeaders = {
|
|
467
|
+
'strict-transport-security': {
|
|
468
|
+
present: false,
|
|
469
|
+
value: null,
|
|
470
|
+
description: 'HSTS - Forces HTTPS connections'
|
|
471
|
+
},
|
|
472
|
+
'content-security-policy': {
|
|
473
|
+
present: false,
|
|
474
|
+
value: null,
|
|
475
|
+
description: 'CSP - Prevents XSS attacks'
|
|
476
|
+
},
|
|
477
|
+
'x-content-type-options': {
|
|
478
|
+
present: false,
|
|
479
|
+
value: null,
|
|
480
|
+
description: 'Prevents MIME type sniffing'
|
|
481
|
+
},
|
|
482
|
+
'x-frame-options': {
|
|
483
|
+
present: false,
|
|
484
|
+
value: null,
|
|
485
|
+
description: 'Prevents clickjacking'
|
|
486
|
+
},
|
|
487
|
+
'x-xss-protection': {
|
|
488
|
+
present: false,
|
|
489
|
+
value: null,
|
|
490
|
+
description: 'XSS filter (legacy)'
|
|
491
|
+
},
|
|
492
|
+
'referrer-policy': {
|
|
493
|
+
present: false,
|
|
494
|
+
value: null,
|
|
495
|
+
description: 'Controls referrer information'
|
|
496
|
+
},
|
|
497
|
+
'permissions-policy': {
|
|
498
|
+
present: false,
|
|
499
|
+
value: null,
|
|
500
|
+
description: 'Controls browser features'
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
// Check each security header
|
|
504
|
+
for (const [header, info] of Object.entries(securityHeaders)) {
|
|
505
|
+
const value = headers[header];
|
|
506
|
+
if (value) {
|
|
507
|
+
info.present = true;
|
|
508
|
+
info.value = value;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// Issues and recommendations
|
|
512
|
+
const issues = [];
|
|
513
|
+
const recommendations = [];
|
|
514
|
+
if (!isHttps) {
|
|
515
|
+
issues.push('Site is not using HTTPS - this is critical for security and SEO');
|
|
516
|
+
}
|
|
517
|
+
if (!securityHeaders['strict-transport-security'].present && isHttps) {
|
|
518
|
+
recommendations.push('Add Strict-Transport-Security header to enforce HTTPS');
|
|
519
|
+
}
|
|
520
|
+
if (!securityHeaders['content-security-policy'].present) {
|
|
521
|
+
recommendations.push('Add Content-Security-Policy header to prevent XSS attacks');
|
|
522
|
+
}
|
|
523
|
+
if (!securityHeaders['x-content-type-options'].present) {
|
|
524
|
+
recommendations.push('Add X-Content-Type-Options: nosniff header');
|
|
525
|
+
}
|
|
526
|
+
if (!securityHeaders['x-frame-options'].present) {
|
|
527
|
+
recommendations.push('Add X-Frame-Options header to prevent clickjacking');
|
|
528
|
+
}
|
|
529
|
+
// Calculate security score
|
|
530
|
+
const headersPresent = Object.values(securityHeaders).filter(h => h.present).length;
|
|
531
|
+
const totalHeaders = Object.keys(securityHeaders).length;
|
|
532
|
+
const httpsScore = isHttps ? 30 : 0;
|
|
533
|
+
const headersScore = (headersPresent / totalHeaders) * 70;
|
|
534
|
+
return {
|
|
535
|
+
url,
|
|
536
|
+
https: {
|
|
537
|
+
enabled: isHttps,
|
|
538
|
+
protocol: parsedUrl.protocol
|
|
539
|
+
},
|
|
540
|
+
securityHeaders,
|
|
541
|
+
allHeaders: headers,
|
|
542
|
+
issues,
|
|
543
|
+
recommendations,
|
|
544
|
+
score: {
|
|
545
|
+
total: Math.round(httpsScore + headersScore),
|
|
546
|
+
httpsEnabled: isHttps,
|
|
547
|
+
headersPresent,
|
|
548
|
+
headersTotal: totalHeaders
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
// 6. Performance Audit Summary (simplified PageSpeed results)
|
|
553
|
+
async function runPerformanceAudit(params) {
|
|
554
|
+
const { url, strategy } = params;
|
|
555
|
+
const apiKey = getApiKey();
|
|
556
|
+
const categories = ['performance', 'accessibility', 'best-practices', 'seo'];
|
|
557
|
+
const categoriesParam = categories.join('&category=');
|
|
558
|
+
const apiUrl = `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(url)}&strategy=${strategy}&category=${categoriesParam}${apiKey ? `&key=${apiKey}` : ''}`;
|
|
559
|
+
const response = await fetch(apiUrl);
|
|
560
|
+
if (!response.ok) {
|
|
561
|
+
throw new Error(`PageSpeed API error: ${response.statusText}`);
|
|
562
|
+
}
|
|
563
|
+
const data = await response.json();
|
|
564
|
+
const lighthouse = data.lighthouseResult;
|
|
565
|
+
// Extract key metrics
|
|
566
|
+
const metrics = {
|
|
567
|
+
firstContentfulPaint: lighthouse?.audits?.['first-contentful-paint']?.displayValue || 'N/A',
|
|
568
|
+
largestContentfulPaint: lighthouse?.audits?.['largest-contentful-paint']?.displayValue || 'N/A',
|
|
569
|
+
totalBlockingTime: lighthouse?.audits?.['total-blocking-time']?.displayValue || 'N/A',
|
|
570
|
+
cumulativeLayoutShift: lighthouse?.audits?.['cumulative-layout-shift']?.displayValue || 'N/A',
|
|
571
|
+
speedIndex: lighthouse?.audits?.['speed-index']?.displayValue || 'N/A',
|
|
572
|
+
timeToInteractive: lighthouse?.audits?.['interactive']?.displayValue || 'N/A'
|
|
573
|
+
};
|
|
574
|
+
// Extract scores
|
|
575
|
+
const scores = {
|
|
576
|
+
performance: Math.round((lighthouse?.categories?.performance?.score || 0) * 100),
|
|
577
|
+
accessibility: Math.round((lighthouse?.categories?.accessibility?.score || 0) * 100),
|
|
578
|
+
bestPractices: Math.round((lighthouse?.categories?.['best-practices']?.score || 0) * 100),
|
|
579
|
+
seo: Math.round((lighthouse?.categories?.seo?.score || 0) * 100)
|
|
580
|
+
};
|
|
581
|
+
// Extract top opportunities
|
|
582
|
+
const opportunities = [];
|
|
583
|
+
const audits = lighthouse?.audits || {};
|
|
584
|
+
for (const [key, audit] of Object.entries(audits)) {
|
|
585
|
+
if (audit?.details?.overallSavingsMs > 0) {
|
|
586
|
+
opportunities.push({
|
|
587
|
+
title: audit.title,
|
|
588
|
+
savings: `${(audit.details.overallSavingsMs / 1000).toFixed(1)}s`
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
opportunities.sort((a, b) => parseFloat(b.savings) - parseFloat(a.savings));
|
|
593
|
+
// Issues based on scores
|
|
594
|
+
const issues = [];
|
|
595
|
+
if (scores.performance < 50)
|
|
596
|
+
issues.push('Critical: Performance score is poor');
|
|
597
|
+
if (scores.accessibility < 90)
|
|
598
|
+
issues.push('Accessibility needs improvement');
|
|
599
|
+
if (scores.seo < 90)
|
|
600
|
+
issues.push('SEO score needs improvement');
|
|
601
|
+
return {
|
|
602
|
+
url,
|
|
603
|
+
strategy,
|
|
604
|
+
scores,
|
|
605
|
+
coreWebVitals: metrics,
|
|
606
|
+
topOpportunities: opportunities.slice(0, 5),
|
|
607
|
+
issues,
|
|
608
|
+
overallGrade: scores.performance >= 90 ? 'A' :
|
|
609
|
+
scores.performance >= 70 ? 'B' :
|
|
610
|
+
scores.performance >= 50 ? 'C' :
|
|
611
|
+
scores.performance >= 30 ? 'D' : 'F'
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
// 7. Mobile-Friendliness Audit
|
|
615
|
+
async function runMobileAudit(params) {
|
|
616
|
+
const { url } = params;
|
|
617
|
+
const apiKey = getApiKey();
|
|
618
|
+
// Fetch page HTML for static checks
|
|
619
|
+
const { html } = await fetchPage(url);
|
|
620
|
+
const $ = cheerio.load(html);
|
|
621
|
+
// Static mobile checks
|
|
622
|
+
const viewport = $('meta[name="viewport"]').attr('content') || null;
|
|
623
|
+
const hasViewport = !!viewport;
|
|
624
|
+
const viewportHasWidthDevice = viewport?.includes('width=device-width') || false;
|
|
625
|
+
const viewportHasInitialScale = viewport?.includes('initial-scale=1') || false;
|
|
626
|
+
// Check for mobile-specific meta tags
|
|
627
|
+
const appleMobileWebAppCapable = $('meta[name="apple-mobile-web-app-capable"]').attr('content') || null;
|
|
628
|
+
const themeColor = $('meta[name="theme-color"]').attr('content') || null;
|
|
629
|
+
const mobileOptimized = $('meta[name="MobileOptimized"]').attr('content') || null;
|
|
630
|
+
const handheldFriendly = $('meta[name="HandheldFriendly"]').attr('content') || null;
|
|
631
|
+
// Check for touch icons
|
|
632
|
+
const appleTouchIcon = $('link[rel="apple-touch-icon"]').attr('href') || null;
|
|
633
|
+
const manifest = $('link[rel="manifest"]').attr('href') || null;
|
|
634
|
+
// Check for responsive images
|
|
635
|
+
const images = $('img');
|
|
636
|
+
let responsiveImages = 0;
|
|
637
|
+
let totalImages = images.length;
|
|
638
|
+
images.each((_, el) => {
|
|
639
|
+
const srcset = $(el).attr('srcset');
|
|
640
|
+
const sizes = $(el).attr('sizes');
|
|
641
|
+
const style = $(el).attr('style') || '';
|
|
642
|
+
const hasMaxWidth = style.includes('max-width') || $(el).attr('class')?.includes('responsive');
|
|
643
|
+
if (srcset || sizes || hasMaxWidth) {
|
|
644
|
+
responsiveImages++;
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
// Check for horizontal scroll issues (fixed width elements)
|
|
648
|
+
const potentialOverflowElements = [];
|
|
649
|
+
$('[style*="width"]').each((_, el) => {
|
|
650
|
+
const style = $(el).attr('style') || '';
|
|
651
|
+
const widthMatch = style.match(/width:\s*(\d+)px/);
|
|
652
|
+
if (widthMatch && parseInt(widthMatch[1]) > 400) {
|
|
653
|
+
potentialOverflowElements.push($(el).prop('tagName') || 'unknown');
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
// Get mobile PageSpeed data for tap targets, font sizes, etc.
|
|
657
|
+
let mobilePageSpeed = null;
|
|
658
|
+
let tapTargets = null;
|
|
659
|
+
let fontSizes = null;
|
|
660
|
+
let contentWidth = null;
|
|
661
|
+
try {
|
|
662
|
+
const apiUrl = `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(url)}&strategy=mobile&category=accessibility&category=seo${apiKey ? `&key=${apiKey}` : ''}`;
|
|
663
|
+
const response = await fetch(apiUrl);
|
|
664
|
+
if (response.ok) {
|
|
665
|
+
mobilePageSpeed = await response.json();
|
|
666
|
+
const audits = mobilePageSpeed?.lighthouseResult?.audits || {};
|
|
667
|
+
// Tap targets
|
|
668
|
+
tapTargets = {
|
|
669
|
+
score: audits['tap-targets']?.score,
|
|
670
|
+
displayValue: audits['tap-targets']?.displayValue || 'N/A',
|
|
671
|
+
description: audits['tap-targets']?.description || '',
|
|
672
|
+
failures: audits['tap-targets']?.details?.items?.slice(0, 5).map((item) => ({
|
|
673
|
+
target: item.tapTarget?.snippet?.substring(0, 100),
|
|
674
|
+
size: item.size,
|
|
675
|
+
overlappingTarget: item.overlappingTarget?.snippet?.substring(0, 50)
|
|
676
|
+
})) || []
|
|
677
|
+
};
|
|
678
|
+
// Font sizes
|
|
679
|
+
fontSizes = {
|
|
680
|
+
score: audits['font-size']?.score,
|
|
681
|
+
displayValue: audits['font-size']?.displayValue || 'N/A',
|
|
682
|
+
description: audits['font-size']?.description || ''
|
|
683
|
+
};
|
|
684
|
+
// Content width
|
|
685
|
+
contentWidth = {
|
|
686
|
+
score: audits['content-width']?.score,
|
|
687
|
+
description: audits['content-width']?.description || ''
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
catch (error) {
|
|
692
|
+
// PageSpeed API failed, continue with static checks only
|
|
693
|
+
}
|
|
694
|
+
// Build issues and recommendations
|
|
695
|
+
const issues = [];
|
|
696
|
+
const recommendations = [];
|
|
697
|
+
// Viewport issues
|
|
698
|
+
if (!hasViewport) {
|
|
699
|
+
issues.push('CRITICAL: Missing viewport meta tag - page will not render correctly on mobile');
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
if (!viewportHasWidthDevice) {
|
|
703
|
+
issues.push('Viewport missing width=device-width');
|
|
704
|
+
}
|
|
705
|
+
if (!viewportHasInitialScale) {
|
|
706
|
+
recommendations.push('Consider adding initial-scale=1 to viewport meta tag');
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
// Tap targets
|
|
710
|
+
if (tapTargets?.score !== null && tapTargets.score < 1) {
|
|
711
|
+
issues.push(`Tap targets too small: ${tapTargets.displayValue}`);
|
|
712
|
+
}
|
|
713
|
+
// Font sizes
|
|
714
|
+
if (fontSizes?.score !== null && fontSizes.score < 1) {
|
|
715
|
+
issues.push(`Font sizes too small for mobile: ${fontSizes.displayValue}`);
|
|
716
|
+
}
|
|
717
|
+
// Content width
|
|
718
|
+
if (contentWidth?.score !== null && contentWidth.score < 1) {
|
|
719
|
+
issues.push('Content wider than screen - causes horizontal scrolling');
|
|
720
|
+
}
|
|
721
|
+
// Responsive images
|
|
722
|
+
if (totalImages > 0 && responsiveImages / totalImages < 0.5) {
|
|
723
|
+
recommendations.push(`Only ${Math.round((responsiveImages / totalImages) * 100)}% of images use responsive techniques (srcset/sizes)`);
|
|
724
|
+
}
|
|
725
|
+
// PWA recommendations
|
|
726
|
+
if (!manifest) {
|
|
727
|
+
recommendations.push('No web app manifest found - consider adding for PWA support');
|
|
728
|
+
}
|
|
729
|
+
if (!themeColor) {
|
|
730
|
+
recommendations.push('No theme-color meta tag - browser UI won\'t match your brand on mobile');
|
|
731
|
+
}
|
|
732
|
+
if (!appleTouchIcon) {
|
|
733
|
+
recommendations.push('No Apple touch icon - iOS home screen icon will be a screenshot');
|
|
734
|
+
}
|
|
735
|
+
// Calculate score
|
|
736
|
+
let score = 100;
|
|
737
|
+
score -= issues.length * 20;
|
|
738
|
+
score -= recommendations.length * 5;
|
|
739
|
+
score = Math.max(0, score);
|
|
740
|
+
return {
|
|
741
|
+
url,
|
|
742
|
+
viewport: {
|
|
743
|
+
present: hasViewport,
|
|
744
|
+
content: viewport,
|
|
745
|
+
hasWidthDevice: viewportHasWidthDevice,
|
|
746
|
+
hasInitialScale: viewportHasInitialScale
|
|
747
|
+
},
|
|
748
|
+
mobileMeta: {
|
|
749
|
+
appleMobileWebAppCapable,
|
|
750
|
+
themeColor,
|
|
751
|
+
mobileOptimized,
|
|
752
|
+
handheldFriendly,
|
|
753
|
+
appleTouchIcon,
|
|
754
|
+
manifest
|
|
755
|
+
},
|
|
756
|
+
responsiveImages: {
|
|
757
|
+
total: totalImages,
|
|
758
|
+
responsive: responsiveImages,
|
|
759
|
+
percentage: totalImages > 0 ? `${Math.round((responsiveImages / totalImages) * 100)}%` : 'N/A'
|
|
760
|
+
},
|
|
761
|
+
tapTargets,
|
|
762
|
+
fontSizes,
|
|
763
|
+
contentWidth,
|
|
764
|
+
potentialOverflowElements: potentialOverflowElements.slice(0, 5),
|
|
765
|
+
issues,
|
|
766
|
+
recommendations,
|
|
767
|
+
score: {
|
|
768
|
+
total: score,
|
|
769
|
+
grade: score >= 90 ? 'A' : score >= 70 ? 'B' : score >= 50 ? 'C' : score >= 30 ? 'D' : 'F'
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
// ============================================
|
|
774
|
+
// TOOL HANDLERS
|
|
775
|
+
// ============================================
|
|
776
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
777
|
+
return {
|
|
778
|
+
tools: [
|
|
779
|
+
{
|
|
780
|
+
name: "run_pagespeed_test",
|
|
781
|
+
description: "Run a full PageSpeed Insights test on a URL. Returns detailed performance, accessibility, SEO, and best practices data.",
|
|
782
|
+
inputSchema: zodToJsonSchema(RunPageSpeedTestSchema),
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
name: "technical_seo_audit",
|
|
786
|
+
description: "Audit technical SEO elements: meta tags, canonical URLs, hreflang, Open Graph, Twitter Cards, structured data (JSON-LD), and more.",
|
|
787
|
+
inputSchema: zodToJsonSchema(TechnicalSeoAuditSchema),
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
name: "sitemap_robots_audit",
|
|
791
|
+
description: "Validate sitemap.xml and robots.txt files. Checks existence, format, URL count, and common issues.",
|
|
792
|
+
inputSchema: zodToJsonSchema(SitemapRobotsSchema),
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
name: "onpage_seo_audit",
|
|
796
|
+
description: "Analyze on-page SEO factors: heading structure, content length, internal/external links, images alt text, and optional keyword optimization analysis.",
|
|
797
|
+
inputSchema: zodToJsonSchema(OnPageSeoSchema),
|
|
798
|
+
},
|
|
799
|
+
{
|
|
800
|
+
name: "security_headers_audit",
|
|
801
|
+
description: "Check HTTPS status and security headers (HSTS, CSP, X-Frame-Options, etc.). Important for both security and SEO.",
|
|
802
|
+
inputSchema: zodToJsonSchema(SecurityHeadersSchema),
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
name: "performance_audit",
|
|
806
|
+
description: "Get a summarized performance audit with Core Web Vitals, scores for performance/accessibility/SEO, and top optimization opportunities.",
|
|
807
|
+
inputSchema: zodToJsonSchema(PerformanceAuditSchema),
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
name: "mobile_audit",
|
|
811
|
+
description: "Comprehensive mobile-friendliness audit: viewport configuration, tap target sizes, font sizes, content width, responsive images, PWA readiness, and mobile-specific meta tags.",
|
|
812
|
+
inputSchema: zodToJsonSchema(MobileAuditSchema),
|
|
813
|
+
}
|
|
814
|
+
],
|
|
815
|
+
};
|
|
816
|
+
});
|
|
817
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
818
|
+
try {
|
|
819
|
+
const { name, arguments: args } = request.params;
|
|
820
|
+
let result;
|
|
821
|
+
switch (name) {
|
|
822
|
+
case "run_pagespeed_test": {
|
|
823
|
+
const parsed = RunPageSpeedTestSchema.safeParse(args);
|
|
824
|
+
if (!parsed.success)
|
|
825
|
+
throw new Error(`Invalid arguments: ${parsed.error}`);
|
|
826
|
+
result = await runPageSpeedTest(parsed.data);
|
|
827
|
+
break;
|
|
828
|
+
}
|
|
829
|
+
case "technical_seo_audit": {
|
|
830
|
+
const parsed = TechnicalSeoAuditSchema.safeParse(args);
|
|
831
|
+
if (!parsed.success)
|
|
832
|
+
throw new Error(`Invalid arguments: ${parsed.error}`);
|
|
833
|
+
result = await runTechnicalSeoAudit(parsed.data);
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
case "sitemap_robots_audit": {
|
|
837
|
+
const parsed = SitemapRobotsSchema.safeParse(args);
|
|
838
|
+
if (!parsed.success)
|
|
839
|
+
throw new Error(`Invalid arguments: ${parsed.error}`);
|
|
840
|
+
result = await runSitemapRobotsAudit(parsed.data);
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
case "onpage_seo_audit": {
|
|
844
|
+
const parsed = OnPageSeoSchema.safeParse(args);
|
|
845
|
+
if (!parsed.success)
|
|
846
|
+
throw new Error(`Invalid arguments: ${parsed.error}`);
|
|
847
|
+
result = await runOnPageSeoAudit(parsed.data);
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
case "security_headers_audit": {
|
|
851
|
+
const parsed = SecurityHeadersSchema.safeParse(args);
|
|
852
|
+
if (!parsed.success)
|
|
853
|
+
throw new Error(`Invalid arguments: ${parsed.error}`);
|
|
854
|
+
result = await runSecurityHeadersAudit(parsed.data);
|
|
855
|
+
break;
|
|
856
|
+
}
|
|
857
|
+
case "performance_audit": {
|
|
858
|
+
const parsed = PerformanceAuditSchema.safeParse(args);
|
|
859
|
+
if (!parsed.success)
|
|
860
|
+
throw new Error(`Invalid arguments: ${parsed.error}`);
|
|
861
|
+
result = await runPerformanceAudit(parsed.data);
|
|
862
|
+
break;
|
|
863
|
+
}
|
|
864
|
+
case "mobile_audit": {
|
|
865
|
+
const parsed = MobileAuditSchema.safeParse(args);
|
|
866
|
+
if (!parsed.success)
|
|
867
|
+
throw new Error(`Invalid arguments: ${parsed.error}`);
|
|
868
|
+
result = await runMobileAudit(parsed.data);
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
default:
|
|
872
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
873
|
+
}
|
|
874
|
+
return {
|
|
875
|
+
content: [{
|
|
876
|
+
type: "text",
|
|
877
|
+
text: JSON.stringify(result, null, 2)
|
|
878
|
+
}],
|
|
879
|
+
isError: false,
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
catch (error) {
|
|
883
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
884
|
+
return {
|
|
885
|
+
content: [{ type: "text", text: `Error: ${errorMessage}` }],
|
|
886
|
+
isError: true,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
// ============================================
|
|
891
|
+
// START SERVER
|
|
892
|
+
// ============================================
|
|
893
|
+
async function runServer() {
|
|
894
|
+
const transport = new StdioServerTransport();
|
|
895
|
+
await server.connect(transport);
|
|
896
|
+
console.error("SEO Audit Server v2.0 running on stdio");
|
|
897
|
+
}
|
|
898
|
+
runServer().catch((error) => {
|
|
899
|
+
console.error("Fatal error running server:", error);
|
|
900
|
+
process.exit(1);
|
|
901
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pagespeed-mcp-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for PageSpeed & SEO audits. Zero config required - works out of the box.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"pagespeed-mcp-server": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc && chmod +x dist/index.js",
|
|
12
|
+
"dev": "tsx src/index.ts",
|
|
13
|
+
"prepublishOnly": "npm run build"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"mcp",
|
|
17
|
+
"model-context-protocol",
|
|
18
|
+
"seo",
|
|
19
|
+
"pagespeed",
|
|
20
|
+
"core-web-vitals",
|
|
21
|
+
"technical-seo",
|
|
22
|
+
"sitemap",
|
|
23
|
+
"robots-txt",
|
|
24
|
+
"security-headers",
|
|
25
|
+
"on-page-seo",
|
|
26
|
+
"mobile-friendly",
|
|
27
|
+
"claude",
|
|
28
|
+
"ai"
|
|
29
|
+
],
|
|
30
|
+
"author": "Sofian Bettayeb <sofianbettayeb@gmail.com>",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
34
|
+
"cheerio": "^1.0.0",
|
|
35
|
+
"node-fetch": "^3.3.2",
|
|
36
|
+
"zod": "^3.23.0",
|
|
37
|
+
"zod-to-json-schema": "^3.23.5"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^20.0.0",
|
|
41
|
+
"tsx": "^4.19.0",
|
|
42
|
+
"typescript": "^5.6.0"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18.0.0"
|
|
46
|
+
},
|
|
47
|
+
"files": [
|
|
48
|
+
"dist",
|
|
49
|
+
"README.md",
|
|
50
|
+
"LICENSE"
|
|
51
|
+
],
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/sofianbettayeb/pagespeed-mcp-server.git"
|
|
55
|
+
},
|
|
56
|
+
"bugs": {
|
|
57
|
+
"url": "https://github.com/sofianbettayeb/pagespeed-mcp-server/issues"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://github.com/sofianbettayeb/pagespeed-mcp-server#readme"
|
|
60
|
+
}
|