seo-gravity-mcp 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 +20 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +521 -0
- package/dist/test.d.ts +1 -0
- package/dist/test.js +76 -0
- package/dist/tools/eeat.d.ts +3 -0
- package/dist/tools/eeat.js +155 -0
- package/dist/tools/geo.d.ts +17 -0
- package/dist/tools/geo.js +190 -0
- package/dist/tools/keywords.d.ts +26 -0
- package/dist/tools/keywords.js +112 -0
- package/dist/tools/onpage.d.ts +15 -0
- package/dist/tools/onpage.js +254 -0
- package/dist/tools/performance.d.ts +32 -0
- package/dist/tools/performance.js +142 -0
- package/dist/tools/schema.d.ts +8 -0
- package/dist/tools/schema.js +191 -0
- package/dist/tools/serp.d.ts +6 -0
- package/dist/tools/serp.js +225 -0
- package/dist/tools/technical.d.ts +36 -0
- package/dist/tools/technical.js +277 -0
- package/dist/types/seo.d.ts +354 -0
- package/dist/types/seo.js +1 -0
- package/dist/utils/jsdomRenderer.d.ts +5 -0
- package/dist/utils/jsdomRenderer.js +79 -0
- package/dist/utils/nlp.d.ts +41 -0
- package/dist/utils/nlp.js +214 -0
- package/dist/utils/scraper.d.ts +36 -0
- package/dist/utils/scraper.js +129 -0
- package/dist/utils/searchEngines.d.ts +17 -0
- package/dist/utils/searchEngines.js +197 -0
- package/package.json +61 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { fetchAndParsePage } from '../utils/scraper.js';
|
|
2
|
+
import { computeContentGapTfIdf, calculateReadability } from '../utils/nlp.js';
|
|
3
|
+
import { scrapeGoogleSerp, scrapeForumDiscussions } from '../utils/searchEngines.js';
|
|
4
|
+
export async function analyzeSerp(query, country = 'us', language = 'en', numResults = 10) {
|
|
5
|
+
return await scrapeGoogleSerp(query, country, language, numResults);
|
|
6
|
+
}
|
|
7
|
+
export async function profileCompetitor(url) {
|
|
8
|
+
const page = await fetchAndParsePage(url);
|
|
9
|
+
const readability = calculateReadability(page.cleanText);
|
|
10
|
+
// Schema extraction summary
|
|
11
|
+
const schemaTypes = [];
|
|
12
|
+
page.schemas.forEach(s => {
|
|
13
|
+
if (s['@type']) {
|
|
14
|
+
schemaTypes.push(typeof s['@type'] === 'string' ? s['@type'] : JSON.stringify(s['@type']));
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
const missingAlt = page.images.filter(img => !img.alt || img.alt.trim() === '').length;
|
|
18
|
+
const canonical = page.$('link[rel="canonical"]').attr('href') || '';
|
|
19
|
+
const robots = page.$('meta[name="robots"]').attr('content') || 'all (default)';
|
|
20
|
+
const openGraph = {};
|
|
21
|
+
page.$('meta[property^="og:"]').each((_, el) => {
|
|
22
|
+
const prop = page.$(el).attr('property');
|
|
23
|
+
const content = page.$(el).attr('content');
|
|
24
|
+
if (prop && content) {
|
|
25
|
+
openGraph[prop] = content;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
url,
|
|
30
|
+
title: page.title,
|
|
31
|
+
description: page.metaDescription,
|
|
32
|
+
h1: page.headings.h1,
|
|
33
|
+
h2: page.headings.h2,
|
|
34
|
+
h3: page.headings.h3,
|
|
35
|
+
h4: page.headings.h4,
|
|
36
|
+
wordCount: page.wordCount,
|
|
37
|
+
readingTimeMinutes: Math.max(1, Math.round(page.wordCount / 200)),
|
|
38
|
+
readingGradeLevel: `${readability.gradeLevel} (${readability.readingLevelSummary})`,
|
|
39
|
+
schemasFound: Array.from(new Set(schemaTypes)),
|
|
40
|
+
canonical,
|
|
41
|
+
robotsDirectives: robots,
|
|
42
|
+
openGraph,
|
|
43
|
+
imageCount: page.images.length,
|
|
44
|
+
imagesMissingAlt: missingAlt,
|
|
45
|
+
internalLinksCount: page.links.internal.length,
|
|
46
|
+
externalLinksCount: page.links.external.length
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export async function analyzeCompetitorContentGap(targetUrlOrText, targetKeyword, competitorUrls) {
|
|
50
|
+
let targetText = targetUrlOrText;
|
|
51
|
+
let targetWordCount = 0;
|
|
52
|
+
if (targetUrlOrText.startsWith('http') || targetUrlOrText.includes('.html')) {
|
|
53
|
+
const targetPage = await fetchAndParsePage(targetUrlOrText);
|
|
54
|
+
targetText = targetPage.cleanText;
|
|
55
|
+
targetWordCount = targetPage.wordCount;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
targetWordCount = targetText.split(/\s+/).filter(Boolean).length;
|
|
59
|
+
}
|
|
60
|
+
// Determine competitor URLs
|
|
61
|
+
let urlsToScrape = competitorUrls || [];
|
|
62
|
+
if (urlsToScrape.length === 0) {
|
|
63
|
+
const serp = await scrapeGoogleSerp(targetKeyword, 'us', 'en', 5);
|
|
64
|
+
urlsToScrape = serp.organicResults.slice(0, 3).map(r => r.url);
|
|
65
|
+
}
|
|
66
|
+
const competitorTexts = [];
|
|
67
|
+
const competitorHeadings = [];
|
|
68
|
+
const scrapedUrls = [];
|
|
69
|
+
let totalCompWords = 0;
|
|
70
|
+
for (const url of urlsToScrape) {
|
|
71
|
+
try {
|
|
72
|
+
const page = await fetchAndParsePage(url);
|
|
73
|
+
competitorTexts.push(page.cleanText);
|
|
74
|
+
competitorHeadings.push({
|
|
75
|
+
url,
|
|
76
|
+
h2s: page.headings.h2,
|
|
77
|
+
h3s: page.headings.h3
|
|
78
|
+
});
|
|
79
|
+
scrapedUrls.push(url);
|
|
80
|
+
totalCompWords += page.wordCount;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Continue to next competitor if one fails
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const avgCompWords = competitorTexts.length > 0 ? Math.round(totalCompWords / competitorTexts.length) : 1500;
|
|
87
|
+
const missingEntities = computeContentGapTfIdf(targetText, competitorTexts);
|
|
88
|
+
// Identify heading topic gaps
|
|
89
|
+
const headingGaps = [];
|
|
90
|
+
competitorHeadings.forEach(comp => {
|
|
91
|
+
comp.h2s.forEach(h2 => {
|
|
92
|
+
const h2Lower = h2.toLowerCase();
|
|
93
|
+
// If target text does not contain key terms from this competitor H2
|
|
94
|
+
const words = h2Lower.split(/\s+/).filter(w => w.length > 4);
|
|
95
|
+
const isCovered = words.some(w => targetText.toLowerCase().includes(w));
|
|
96
|
+
if (!isCovered && words.length >= 2) {
|
|
97
|
+
const existing = headingGaps.find(g => g.subtopic === h2);
|
|
98
|
+
if (existing) {
|
|
99
|
+
existing.coveredByCompetitorUrls.push(comp.url);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
headingGaps.push({
|
|
103
|
+
subtopic: h2,
|
|
104
|
+
coveredByCompetitorUrls: [comp.url]
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
const actionItems = [];
|
|
111
|
+
if (targetWordCount < avgCompWords * 0.75) {
|
|
112
|
+
actionItems.push(`Expand content depth: Target is ~${targetWordCount} words vs competitor average of ~${avgCompWords} words (deficit of ${avgCompWords - targetWordCount} words).`);
|
|
113
|
+
}
|
|
114
|
+
if (missingEntities.length > 0) {
|
|
115
|
+
actionItems.push(`Integrate high-importance semantic entities: ${missingEntities.slice(0, 5).map(e => `"${e.term}"`).join(', ')}.`);
|
|
116
|
+
}
|
|
117
|
+
if (headingGaps.length > 0) {
|
|
118
|
+
actionItems.push(`Add missing H2/H3 subtopics covered by top competitors: "${headingGaps[0]?.subtopic}" and "${headingGaps[1]?.subtopic || headingGaps[0]?.subtopic}".`);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
targetUrlOrKeyword: targetUrlOrText.substring(0, 100),
|
|
122
|
+
analyzedCompetitors: scrapedUrls,
|
|
123
|
+
averageCompetitorWordCount: avgCompWords,
|
|
124
|
+
targetWordCount,
|
|
125
|
+
wordCountDelta: targetWordCount - avgCompWords,
|
|
126
|
+
missingEntities,
|
|
127
|
+
headingCoverageGaps: headingGaps.slice(0, 8),
|
|
128
|
+
suggestedActionItems: actionItems
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export async function diffCompetitor(myUrl, competitorUrl, focusKeyword) {
|
|
132
|
+
const [myPage, compPage] = await Promise.all([
|
|
133
|
+
profileCompetitor(myUrl),
|
|
134
|
+
profileCompetitor(competitorUrl)
|
|
135
|
+
]);
|
|
136
|
+
const kw = focusKeyword.toLowerCase();
|
|
137
|
+
const myHasKwTitle = myPage.title.toLowerCase().includes(kw);
|
|
138
|
+
const compHasKwTitle = compPage.title.toLowerCase().includes(kw);
|
|
139
|
+
const myHasKwH1 = myPage.h1.some(h => h.toLowerCase().includes(kw));
|
|
140
|
+
const compHasKwH1 = compPage.h1.some(h => h.toLowerCase().includes(kw));
|
|
141
|
+
const scorecard = [
|
|
142
|
+
{
|
|
143
|
+
metric: 'Title Tag Focus Keyword',
|
|
144
|
+
myValue: myHasKwTitle ? 'Yes' : 'No',
|
|
145
|
+
competitorValue: compHasKwTitle ? 'Yes' : 'No',
|
|
146
|
+
winner: myHasKwTitle && !compHasKwTitle ? 'my_site' : !myHasKwTitle && compHasKwTitle ? 'competitor' : 'tie',
|
|
147
|
+
notes: `My: "${myPage.title.substring(0, 45)}..." | Comp: "${compPage.title.substring(0, 45)}..."`
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
metric: 'H1 Tag Focus Keyword',
|
|
151
|
+
myValue: myHasKwH1 ? 'Yes' : 'No',
|
|
152
|
+
competitorValue: compHasKwH1 ? 'Yes' : 'No',
|
|
153
|
+
winner: myHasKwH1 && !compHasKwH1 ? 'my_site' : !myHasKwH1 && compHasKwH1 ? 'competitor' : 'tie',
|
|
154
|
+
notes: `My H1 count: ${myPage.h1.length} | Comp H1 count: ${compPage.h1.length}`
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
metric: 'Total Word Count & Depth',
|
|
158
|
+
myValue: `${myPage.wordCount} words`,
|
|
159
|
+
competitorValue: `${compPage.wordCount} words`,
|
|
160
|
+
winner: myPage.wordCount >= compPage.wordCount ? 'my_site' : 'competitor',
|
|
161
|
+
notes: myPage.wordCount >= compPage.wordCount ? 'Your content is deeper' : 'Competitor content is more extensive'
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
metric: 'Structured Schema Markup',
|
|
165
|
+
myValue: myPage.schemasFound.length > 0 ? myPage.schemasFound.join(', ') : 'None',
|
|
166
|
+
competitorValue: compPage.schemasFound.length > 0 ? compPage.schemasFound.join(', ') : 'None',
|
|
167
|
+
winner: myPage.schemasFound.length > compPage.schemasFound.length ? 'my_site' : myPage.schemasFound.length < compPage.schemasFound.length ? 'competitor' : 'tie',
|
|
168
|
+
notes: `My schemas: ${myPage.schemasFound.length} | Comp schemas: ${compPage.schemasFound.length}`
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
metric: 'Image Alt Tag Hygiene',
|
|
172
|
+
myValue: `${myPage.imageCount - myPage.imagesMissingAlt}/${myPage.imageCount} with alt`,
|
|
173
|
+
competitorValue: `${compPage.imageCount - compPage.imagesMissingAlt}/${compPage.imageCount} with alt`,
|
|
174
|
+
winner: myPage.imagesMissingAlt === 0 ? 'my_site' : myPage.imagesMissingAlt <= compPage.imagesMissingAlt ? 'my_site' : 'competitor',
|
|
175
|
+
notes: `Missing alt: My site (${myPage.imagesMissingAlt}) vs Competitor (${compPage.imagesMissingAlt})`
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
metric: 'Heading Subtopic Structure (H2/H3)',
|
|
179
|
+
myValue: `${myPage.h2.length} H2s, ${myPage.h3.length} H3s`,
|
|
180
|
+
competitorValue: `${compPage.h2.length} H2s, ${compPage.h3.length} H3s`,
|
|
181
|
+
winner: (myPage.h2.length + myPage.h3.length) >= (compPage.h2.length + compPage.h3.length) ? 'my_site' : 'competitor',
|
|
182
|
+
notes: 'Comparison of subtopic granularity'
|
|
183
|
+
}
|
|
184
|
+
];
|
|
185
|
+
let myScore = 0;
|
|
186
|
+
let compScore = 0;
|
|
187
|
+
scorecard.forEach(item => {
|
|
188
|
+
if (item.winner === 'my_site')
|
|
189
|
+
myScore += 10;
|
|
190
|
+
else if (item.winner === 'competitor')
|
|
191
|
+
compScore += 10;
|
|
192
|
+
else {
|
|
193
|
+
myScore += 5;
|
|
194
|
+
compScore += 5;
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
const topPriorityFixes = [];
|
|
198
|
+
if (!myHasKwTitle && compHasKwTitle) {
|
|
199
|
+
topPriorityFixes.push(`Include primary keyword '${focusKeyword}' in your title tag.`);
|
|
200
|
+
}
|
|
201
|
+
if (!myHasKwH1 && compHasKwH1) {
|
|
202
|
+
topPriorityFixes.push(`Ensure your H1 explicitly includes '${focusKeyword}'.`);
|
|
203
|
+
}
|
|
204
|
+
if (myPage.wordCount < compPage.wordCount * 0.8) {
|
|
205
|
+
topPriorityFixes.push(`Expand total word count to match or exceed competitor's ${compPage.wordCount} words.`);
|
|
206
|
+
}
|
|
207
|
+
if (myPage.schemasFound.length === 0 && compPage.schemasFound.length > 0) {
|
|
208
|
+
topPriorityFixes.push(`Add Schema.org markup (Competitor is using: ${compPage.schemasFound.join(', ')}).`);
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
myUrl,
|
|
212
|
+
competitorUrl,
|
|
213
|
+
focusKeyword,
|
|
214
|
+
scorecard,
|
|
215
|
+
summary: {
|
|
216
|
+
myScore,
|
|
217
|
+
competitorScore: compScore,
|
|
218
|
+
winner: myScore > compScore ? 'my_site' : myScore < compScore ? 'competitor' : 'tie',
|
|
219
|
+
topPriorityFixes
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
export async function analyzeForumDiscussions(topic) {
|
|
224
|
+
return await scrapeForumDiscussions(topic);
|
|
225
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { TechnicalAuditReport, JsRenderingDiffReport } from '../types/seo.js';
|
|
2
|
+
export declare function auditTechnical(url: string): Promise<TechnicalAuditReport>;
|
|
3
|
+
export declare function diffJsRendering(url: string): Promise<JsRenderingDiffReport>;
|
|
4
|
+
export declare function validateRobotsTxt(domainOrUrl: string, testPath?: string, userAgent?: string): Promise<{
|
|
5
|
+
domain: string;
|
|
6
|
+
testPath: string;
|
|
7
|
+
userAgent: string;
|
|
8
|
+
accessStatus: 'ALLOWED' | 'DISALLOWED';
|
|
9
|
+
matchedDirective: string;
|
|
10
|
+
sitemapsFound: string[];
|
|
11
|
+
rawDirectivesSample: string[];
|
|
12
|
+
}>;
|
|
13
|
+
export declare function inspectSitemap(sitemapUrl: string): Promise<{
|
|
14
|
+
sitemapUrl: string;
|
|
15
|
+
isIndex: boolean;
|
|
16
|
+
totalUrls: number;
|
|
17
|
+
sampleUrls: Array<{
|
|
18
|
+
loc: string;
|
|
19
|
+
lastmod?: string;
|
|
20
|
+
changefreq?: string;
|
|
21
|
+
priority?: string;
|
|
22
|
+
}>;
|
|
23
|
+
errorsDetected: string[];
|
|
24
|
+
}>;
|
|
25
|
+
export declare function analyzeInternalLinks(url: string, maxCrawlDepth?: number): Promise<{
|
|
26
|
+
targetUrl: string;
|
|
27
|
+
totalInternalLinksFound: number;
|
|
28
|
+
uniqueInternalDestinations: number;
|
|
29
|
+
genericAnchorTextCount: number;
|
|
30
|
+
topAnchorTexts: Array<{
|
|
31
|
+
anchor: string;
|
|
32
|
+
count: number;
|
|
33
|
+
}>;
|
|
34
|
+
nofollowInternalLinks: string[];
|
|
35
|
+
recommendations: string[];
|
|
36
|
+
}>;
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
3
|
+
import { fetchAndParsePage, getRandomUserAgent } from '../utils/scraper.js';
|
|
4
|
+
import { compareServerVsClientDom } from '../utils/jsdomRenderer.js';
|
|
5
|
+
export async function auditTechnical(url) {
|
|
6
|
+
const startTime = Date.now();
|
|
7
|
+
const redirectChain = [];
|
|
8
|
+
let response;
|
|
9
|
+
try {
|
|
10
|
+
response = await axios.get(url, {
|
|
11
|
+
headers: { 'User-Agent': getRandomUserAgent() },
|
|
12
|
+
timeout: 15000,
|
|
13
|
+
maxRedirects: 10,
|
|
14
|
+
validateStatus: () => true
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
throw new Error(`Technical audit failed to connect to ${url}: ${err.message}`);
|
|
19
|
+
}
|
|
20
|
+
const responseTimeMs = Date.now() - startTime;
|
|
21
|
+
const page = await fetchAndParsePage(url);
|
|
22
|
+
const html = page.html;
|
|
23
|
+
const $ = page.$;
|
|
24
|
+
// Canonical check
|
|
25
|
+
const canonicalVal = $('link[rel="canonical"]').attr('href') || '';
|
|
26
|
+
const isSelfRef = canonicalVal ? canonicalVal.replace(/\/$/, '') === url.replace(/\/$/, '') : false;
|
|
27
|
+
// Robots meta
|
|
28
|
+
const metaRobots = $('meta[name="robots"]').attr('content') || '';
|
|
29
|
+
const xRobots = response.headers['x-robots-tag'] || '';
|
|
30
|
+
const isNoIndex = /noindex/i.test(metaRobots) || /noindex/i.test(xRobots);
|
|
31
|
+
const isNoFollow = /nofollow/i.test(metaRobots) || /nofollow/i.test(xRobots);
|
|
32
|
+
// Hreflang
|
|
33
|
+
const hreflangs = [];
|
|
34
|
+
$('link[rel="alternate"][hreflang]').each((_, el) => {
|
|
35
|
+
const lang = $(el).attr('hreflang') || '';
|
|
36
|
+
const href = $(el).attr('href') || '';
|
|
37
|
+
if (lang && href)
|
|
38
|
+
hreflangs.push({ lang, href });
|
|
39
|
+
});
|
|
40
|
+
// OpenGraph & Twitter
|
|
41
|
+
const ogTitle = $('meta[property="og:title"]').attr('content');
|
|
42
|
+
const ogDesc = $('meta[property="og:description"]').attr('content');
|
|
43
|
+
const ogImage = $('meta[property="og:image"]').attr('content');
|
|
44
|
+
const ogType = $('meta[property="og:type"]').attr('content');
|
|
45
|
+
const twCard = $('meta[name="twitter:card"]').attr('content');
|
|
46
|
+
const twTitle = $('meta[name="twitter:title"]').attr('content');
|
|
47
|
+
const twImage = $('meta[name="twitter:image"]').attr('content');
|
|
48
|
+
const issues = [];
|
|
49
|
+
if (isNoIndex) {
|
|
50
|
+
issues.push({
|
|
51
|
+
severity: 'critical',
|
|
52
|
+
message: 'Page has a NOINDEX directive, preventing Google from ranking it in search results.',
|
|
53
|
+
fix: 'Remove "noindex" from <meta name="robots"> or X-Robots-Tag header before launching in production.'
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (!canonicalVal) {
|
|
57
|
+
issues.push({
|
|
58
|
+
severity: 'warning',
|
|
59
|
+
message: 'Missing canonical tag.',
|
|
60
|
+
fix: `Add <link rel="canonical" href="${url}" /> to clarify the preferred master URL.`
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (response.status >= 400) {
|
|
64
|
+
issues.push({
|
|
65
|
+
severity: 'critical',
|
|
66
|
+
message: `HTTP Status Code is ${response.status} (Error).`,
|
|
67
|
+
fix: 'Ensure server returns HTTP 200 OK for valid pages.'
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (responseTimeMs > 1800) {
|
|
71
|
+
issues.push({
|
|
72
|
+
severity: 'warning',
|
|
73
|
+
message: `Slow Server Response Time: ${responseTimeMs}ms (TTFB is over 1.8s).`,
|
|
74
|
+
fix: 'Enable edge caching, optimize database queries, or use a CDN to bring TTFB under 800ms.'
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (!url.startsWith('https://') && !url.includes('localhost') && !url.includes('127.0.0.1')) {
|
|
78
|
+
issues.push({
|
|
79
|
+
severity: 'critical',
|
|
80
|
+
message: 'Page is served over unencrypted HTTP rather than HTTPS.',
|
|
81
|
+
fix: 'Enforce SSL/TLS certificate and 301 redirect all HTTP traffic to HTTPS.'
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
url,
|
|
86
|
+
statusCode: response.status,
|
|
87
|
+
responseTimeMs,
|
|
88
|
+
redirectChain,
|
|
89
|
+
isHttps: url.startsWith('https://'),
|
|
90
|
+
canonicalTag: {
|
|
91
|
+
present: Boolean(canonicalVal),
|
|
92
|
+
value: canonicalVal,
|
|
93
|
+
isSelfReferencing: isSelfRef
|
|
94
|
+
},
|
|
95
|
+
robotsDirectives: {
|
|
96
|
+
metaRobots,
|
|
97
|
+
xRobotsTag: xRobots,
|
|
98
|
+
isNoIndex,
|
|
99
|
+
isNoFollow
|
|
100
|
+
},
|
|
101
|
+
hreflangTags: hreflangs,
|
|
102
|
+
openGraphTags: {
|
|
103
|
+
hasTitle: Boolean(ogTitle),
|
|
104
|
+
hasDescription: Boolean(ogDesc),
|
|
105
|
+
hasImage: Boolean(ogImage),
|
|
106
|
+
hasType: Boolean(ogType)
|
|
107
|
+
},
|
|
108
|
+
twitterCards: {
|
|
109
|
+
hasCard: Boolean(twCard),
|
|
110
|
+
hasTitle: Boolean(twTitle),
|
|
111
|
+
hasImage: Boolean(twImage)
|
|
112
|
+
},
|
|
113
|
+
issuesFound: issues
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export async function diffJsRendering(url) {
|
|
117
|
+
return await compareServerVsClientDom(url);
|
|
118
|
+
}
|
|
119
|
+
export async function validateRobotsTxt(domainOrUrl, testPath = '/', userAgent = 'Googlebot') {
|
|
120
|
+
const domain = domainOrUrl.startsWith('http') ? new URL(domainOrUrl).origin : `https://${domainOrUrl}`;
|
|
121
|
+
const robotsUrl = `${domain}/robots.txt`;
|
|
122
|
+
let content = '';
|
|
123
|
+
try {
|
|
124
|
+
const res = await axios.get(robotsUrl, {
|
|
125
|
+
headers: { 'User-Agent': getRandomUserAgent() },
|
|
126
|
+
timeout: 8000
|
|
127
|
+
});
|
|
128
|
+
content = res.data;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return {
|
|
132
|
+
domain,
|
|
133
|
+
testPath,
|
|
134
|
+
userAgent,
|
|
135
|
+
accessStatus: 'ALLOWED',
|
|
136
|
+
matchedDirective: 'No robots.txt found (default ALLOWED)',
|
|
137
|
+
sitemapsFound: [],
|
|
138
|
+
rawDirectivesSample: []
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const sitemaps = [];
|
|
142
|
+
const lines = content.split('\n').map(l => l.trim());
|
|
143
|
+
let currentAgent = '';
|
|
144
|
+
let matchedDirective = 'None (Default Allow)';
|
|
145
|
+
let isDisallowed = false;
|
|
146
|
+
for (const line of lines) {
|
|
147
|
+
if (line.toLowerCase().startsWith('sitemap:')) {
|
|
148
|
+
sitemaps.push(line.substring(8).trim());
|
|
149
|
+
}
|
|
150
|
+
if (line.toLowerCase().startsWith('user-agent:')) {
|
|
151
|
+
currentAgent = line.substring(11).trim();
|
|
152
|
+
}
|
|
153
|
+
if (currentAgent === '*' || currentAgent.toLowerCase() === userAgent.toLowerCase()) {
|
|
154
|
+
if (line.toLowerCase().startsWith('disallow:')) {
|
|
155
|
+
const pathRule = line.substring(9).trim();
|
|
156
|
+
if (pathRule && testPath.startsWith(pathRule)) {
|
|
157
|
+
isDisallowed = true;
|
|
158
|
+
matchedDirective = `User-agent: ${currentAgent} -> Disallow: ${pathRule}`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (line.toLowerCase().startsWith('allow:')) {
|
|
162
|
+
const pathRule = line.substring(6).trim();
|
|
163
|
+
if (pathRule && testPath.startsWith(pathRule)) {
|
|
164
|
+
isDisallowed = false;
|
|
165
|
+
matchedDirective = `User-agent: ${currentAgent} -> Allow: ${pathRule}`;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
domain,
|
|
172
|
+
testPath,
|
|
173
|
+
userAgent,
|
|
174
|
+
accessStatus: isDisallowed ? 'DISALLOWED' : 'ALLOWED',
|
|
175
|
+
matchedDirective,
|
|
176
|
+
sitemapsFound: sitemaps,
|
|
177
|
+
rawDirectivesSample: lines.slice(0, 15)
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
export async function inspectSitemap(sitemapUrl) {
|
|
181
|
+
let xmlData = '';
|
|
182
|
+
try {
|
|
183
|
+
const res = await axios.get(sitemapUrl, {
|
|
184
|
+
headers: { 'User-Agent': getRandomUserAgent() },
|
|
185
|
+
timeout: 12000
|
|
186
|
+
});
|
|
187
|
+
xmlData = res.data;
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
throw new Error(`Failed to download sitemap from ${sitemapUrl}: ${err.message}`);
|
|
191
|
+
}
|
|
192
|
+
const parser = new XMLParser();
|
|
193
|
+
const jsonObj = parser.parse(xmlData);
|
|
194
|
+
const errors = [];
|
|
195
|
+
let isIndex = false;
|
|
196
|
+
let totalUrls = 0;
|
|
197
|
+
const sample = [];
|
|
198
|
+
if (jsonObj.sitemapindex && jsonObj.sitemapindex.sitemap) {
|
|
199
|
+
isIndex = true;
|
|
200
|
+
const subMaps = Array.isArray(jsonObj.sitemapindex.sitemap) ? jsonObj.sitemapindex.sitemap : [jsonObj.sitemapindex.sitemap];
|
|
201
|
+
totalUrls = subMaps.length;
|
|
202
|
+
subMaps.slice(0, 10).forEach((s) => {
|
|
203
|
+
sample.push({ loc: s.loc, lastmod: s.lastmod });
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
else if (jsonObj.urlset && jsonObj.urlset.url) {
|
|
207
|
+
const urls = Array.isArray(jsonObj.urlset.url) ? jsonObj.urlset.url : [jsonObj.urlset.url];
|
|
208
|
+
totalUrls = urls.length;
|
|
209
|
+
urls.slice(0, 10).forEach((u) => {
|
|
210
|
+
sample.push({
|
|
211
|
+
loc: u.loc,
|
|
212
|
+
lastmod: u.lastmod,
|
|
213
|
+
changefreq: u.changefreq,
|
|
214
|
+
priority: u.priority
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
if (totalUrls > 50000) {
|
|
218
|
+
errors.push(`Sitemap contains ${totalUrls} URLs, exceeding Google's single-sitemap limit of 50,000 URLs. Break into a Sitemap Index.`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
errors.push('Unrecognized XML sitemap format. Missing <urlset> or <sitemapindex> root node.');
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
sitemapUrl,
|
|
226
|
+
isIndex,
|
|
227
|
+
totalUrls,
|
|
228
|
+
sampleUrls: sample,
|
|
229
|
+
errorsDetected: errors
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
export async function analyzeInternalLinks(url, maxCrawlDepth = 2) {
|
|
233
|
+
const page = await fetchAndParsePage(url);
|
|
234
|
+
const $ = page.$;
|
|
235
|
+
const domain = url.startsWith('http') ? new URL(url).hostname : '';
|
|
236
|
+
const anchorCounts = new Map();
|
|
237
|
+
const internalDestinations = new Set();
|
|
238
|
+
const nofollowLinks = [];
|
|
239
|
+
let genericCount = 0;
|
|
240
|
+
$('a[href]').each((_, el) => {
|
|
241
|
+
const href = $(el).attr('href')?.trim() || '';
|
|
242
|
+
const anchor = $(el).text().trim();
|
|
243
|
+
const rel = $(el).attr('rel') || '';
|
|
244
|
+
if (href.startsWith('/') || (domain && href.includes(domain))) {
|
|
245
|
+
internalDestinations.add(href);
|
|
246
|
+
if (['click here', 'read more', 'learn more', 'here', 'link'].includes(anchor.toLowerCase())) {
|
|
247
|
+
genericCount++;
|
|
248
|
+
}
|
|
249
|
+
if (anchor) {
|
|
250
|
+
anchorCounts.set(anchor, (anchorCounts.get(anchor) || 0) + 1);
|
|
251
|
+
}
|
|
252
|
+
if (rel.includes('nofollow')) {
|
|
253
|
+
nofollowLinks.push(href);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
const topAnchors = Array.from(anchorCounts.entries())
|
|
258
|
+
.map(([anchor, count]) => ({ anchor, count }))
|
|
259
|
+
.sort((a, b) => b.count - a.count)
|
|
260
|
+
.slice(0, 10);
|
|
261
|
+
const recommendations = [];
|
|
262
|
+
if (genericCount > 0) {
|
|
263
|
+
recommendations.push(`Replace ${genericCount} generic anchor texts ("click here", "read more") with descriptive keyword-rich anchors.`);
|
|
264
|
+
}
|
|
265
|
+
if (nofollowLinks.length > 0) {
|
|
266
|
+
recommendations.push(`Detected ${nofollowLinks.length} internal links marked with "nofollow". Avoid nofollowing internal links to preserve PageRank flow.`);
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
targetUrl: url,
|
|
270
|
+
totalInternalLinksFound: page.links.internal.length,
|
|
271
|
+
uniqueInternalDestinations: internalDestinations.size,
|
|
272
|
+
genericAnchorTextCount: genericCount,
|
|
273
|
+
topAnchorTexts: topAnchors,
|
|
274
|
+
nofollowInternalLinks: nofollowLinks.slice(0, 5),
|
|
275
|
+
recommendations
|
|
276
|
+
};
|
|
277
|
+
}
|