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.
@@ -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
+ }