@djangocfg/seo 2.1.50

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.
Files changed (68) hide show
  1. package/README.md +192 -0
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.mjs +3780 -0
  4. package/dist/cli.mjs.map +1 -0
  5. package/dist/crawler/index.d.ts +88 -0
  6. package/dist/crawler/index.mjs +610 -0
  7. package/dist/crawler/index.mjs.map +1 -0
  8. package/dist/google-console/index.d.ts +95 -0
  9. package/dist/google-console/index.mjs +539 -0
  10. package/dist/google-console/index.mjs.map +1 -0
  11. package/dist/index.d.ts +285 -0
  12. package/dist/index.mjs +3236 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/dist/link-checker/index.d.ts +76 -0
  15. package/dist/link-checker/index.mjs +326 -0
  16. package/dist/link-checker/index.mjs.map +1 -0
  17. package/dist/markdown-report-B3QdDzxE.d.ts +193 -0
  18. package/dist/reports/index.d.ts +24 -0
  19. package/dist/reports/index.mjs +836 -0
  20. package/dist/reports/index.mjs.map +1 -0
  21. package/dist/routes/index.d.ts +69 -0
  22. package/dist/routes/index.mjs +372 -0
  23. package/dist/routes/index.mjs.map +1 -0
  24. package/dist/scanner-Cz4Th2Pt.d.ts +60 -0
  25. package/dist/types/index.d.ts +144 -0
  26. package/dist/types/index.mjs +3 -0
  27. package/dist/types/index.mjs.map +1 -0
  28. package/package.json +114 -0
  29. package/src/analyzer.ts +256 -0
  30. package/src/cli/commands/audit.ts +260 -0
  31. package/src/cli/commands/content.ts +180 -0
  32. package/src/cli/commands/crawl.ts +32 -0
  33. package/src/cli/commands/index.ts +12 -0
  34. package/src/cli/commands/inspect.ts +60 -0
  35. package/src/cli/commands/links.ts +41 -0
  36. package/src/cli/commands/robots.ts +36 -0
  37. package/src/cli/commands/routes.ts +126 -0
  38. package/src/cli/commands/sitemap.ts +48 -0
  39. package/src/cli/index.ts +149 -0
  40. package/src/cli/types.ts +40 -0
  41. package/src/config.ts +207 -0
  42. package/src/content/index.ts +51 -0
  43. package/src/content/link-checker.ts +182 -0
  44. package/src/content/link-fixer.ts +188 -0
  45. package/src/content/scanner.ts +200 -0
  46. package/src/content/sitemap-generator.ts +321 -0
  47. package/src/content/types.ts +140 -0
  48. package/src/crawler/crawler.ts +425 -0
  49. package/src/crawler/index.ts +10 -0
  50. package/src/crawler/robots-parser.ts +171 -0
  51. package/src/crawler/sitemap-validator.ts +204 -0
  52. package/src/google-console/analyzer.ts +317 -0
  53. package/src/google-console/auth.ts +100 -0
  54. package/src/google-console/client.ts +281 -0
  55. package/src/google-console/index.ts +9 -0
  56. package/src/index.ts +144 -0
  57. package/src/link-checker/index.ts +461 -0
  58. package/src/reports/claude-context.ts +149 -0
  59. package/src/reports/generator.ts +244 -0
  60. package/src/reports/index.ts +27 -0
  61. package/src/reports/json-report.ts +320 -0
  62. package/src/reports/markdown-report.ts +246 -0
  63. package/src/reports/split-report.ts +252 -0
  64. package/src/routes/analyzer.ts +324 -0
  65. package/src/routes/index.ts +25 -0
  66. package/src/routes/scanner.ts +298 -0
  67. package/src/types/index.ts +222 -0
  68. package/src/utils/index.ts +154 -0
@@ -0,0 +1,204 @@
1
+ /**
2
+ * @djangocfg/seo - Sitemap Validator
3
+ * Validate XML sitemaps
4
+ */
5
+
6
+ import { load } from 'cheerio';
7
+ import consola from 'consola';
8
+ import type { SeoIssue } from '../types/index.js';
9
+
10
+ export interface SitemapAnalysis {
11
+ url: string;
12
+ exists: boolean;
13
+ type: 'sitemap' | 'sitemap-index' | 'unknown';
14
+ urls: string[];
15
+ childSitemaps: string[];
16
+ lastmod?: string;
17
+ issues: SeoIssue[];
18
+ }
19
+
20
+ /**
21
+ * Analyze a sitemap URL
22
+ */
23
+ export async function analyzeSitemap(sitemapUrl: string): Promise<SitemapAnalysis> {
24
+ const analysis: SitemapAnalysis = {
25
+ url: sitemapUrl,
26
+ exists: false,
27
+ type: 'unknown',
28
+ urls: [],
29
+ childSitemaps: [],
30
+ issues: [],
31
+ };
32
+
33
+ try {
34
+ const response = await fetch(sitemapUrl, {
35
+ headers: {
36
+ Accept: 'application/xml, text/xml, */*',
37
+ },
38
+ });
39
+
40
+ if (!response.ok) {
41
+ analysis.issues.push({
42
+ id: `sitemap-not-found-${hash(sitemapUrl)}`,
43
+ url: sitemapUrl,
44
+ category: 'technical',
45
+ severity: 'error',
46
+ title: 'Sitemap not accessible',
47
+ description: `Sitemap returned HTTP ${response.status}.`,
48
+ recommendation: 'Ensure the sitemap URL is correct and accessible.',
49
+ detectedAt: new Date().toISOString(),
50
+ metadata: { statusCode: response.status },
51
+ });
52
+ return analysis;
53
+ }
54
+
55
+ analysis.exists = true;
56
+ const content = await response.text();
57
+
58
+ // Check content type
59
+ const contentType = response.headers.get('content-type') || '';
60
+ if (!contentType.includes('xml') && !content.trim().startsWith('<?xml')) {
61
+ analysis.issues.push({
62
+ id: `sitemap-not-xml-${hash(sitemapUrl)}`,
63
+ url: sitemapUrl,
64
+ category: 'technical',
65
+ severity: 'warning',
66
+ title: 'Sitemap is not XML',
67
+ description: 'The sitemap does not have an XML content type.',
68
+ recommendation: 'Ensure sitemap is served with Content-Type: application/xml.',
69
+ detectedAt: new Date().toISOString(),
70
+ metadata: { contentType },
71
+ });
72
+ }
73
+
74
+ // Parse XML
75
+ const $ = load(content, { xmlMode: true });
76
+
77
+ // Check if it's a sitemap index
78
+ const sitemapIndex = $('sitemapindex');
79
+ if (sitemapIndex.length > 0) {
80
+ analysis.type = 'sitemap-index';
81
+
82
+ $('sitemap').each((_, el) => {
83
+ const loc = $('loc', el).text().trim();
84
+ if (loc) {
85
+ analysis.childSitemaps.push(loc);
86
+ }
87
+ });
88
+
89
+ consola.debug(`Sitemap index contains ${analysis.childSitemaps.length} sitemaps`);
90
+ } else {
91
+ analysis.type = 'sitemap';
92
+
93
+ $('url').each((_, el) => {
94
+ const loc = $('loc', el).text().trim();
95
+ if (loc) {
96
+ analysis.urls.push(loc);
97
+ }
98
+ });
99
+
100
+ const lastmod = $('url lastmod').first().text().trim();
101
+ if (lastmod) {
102
+ analysis.lastmod = lastmod;
103
+ }
104
+
105
+ consola.debug(`Sitemap contains ${analysis.urls.length} URLs`);
106
+ }
107
+
108
+ // Validate sitemap content
109
+ if (analysis.type === 'sitemap' && analysis.urls.length === 0) {
110
+ analysis.issues.push({
111
+ id: `sitemap-empty-${hash(sitemapUrl)}`,
112
+ url: sitemapUrl,
113
+ category: 'technical',
114
+ severity: 'warning',
115
+ title: 'Sitemap is empty',
116
+ description: 'The sitemap contains no URLs.',
117
+ recommendation: 'Add URLs to your sitemap or remove it if not needed.',
118
+ detectedAt: new Date().toISOString(),
119
+ });
120
+ }
121
+
122
+ // Check for too many URLs (Google limit is 50,000)
123
+ if (analysis.urls.length > 50000) {
124
+ analysis.issues.push({
125
+ id: `sitemap-too-large-${hash(sitemapUrl)}`,
126
+ url: sitemapUrl,
127
+ category: 'technical',
128
+ severity: 'error',
129
+ title: 'Sitemap exceeds URL limit',
130
+ description: `Sitemap contains ${analysis.urls.length} URLs. Maximum is 50,000.`,
131
+ recommendation: 'Split the sitemap into multiple files using a sitemap index.',
132
+ detectedAt: new Date().toISOString(),
133
+ metadata: { urlCount: analysis.urls.length },
134
+ });
135
+ }
136
+
137
+ // Check file size (Google limit is 50MB uncompressed)
138
+ const sizeInMB = new Blob([content]).size / (1024 * 1024);
139
+ if (sizeInMB > 50) {
140
+ analysis.issues.push({
141
+ id: `sitemap-too-large-size-${hash(sitemapUrl)}`,
142
+ url: sitemapUrl,
143
+ category: 'technical',
144
+ severity: 'error',
145
+ title: 'Sitemap exceeds size limit',
146
+ description: `Sitemap is ${sizeInMB.toFixed(2)}MB. Maximum is 50MB.`,
147
+ recommendation: 'Split the sitemap or compress it.',
148
+ detectedAt: new Date().toISOString(),
149
+ metadata: { sizeMB: sizeInMB },
150
+ });
151
+ }
152
+ } catch (error) {
153
+ consola.error('Failed to analyze sitemap:', error);
154
+ analysis.issues.push({
155
+ id: `sitemap-error-${hash(sitemapUrl)}`,
156
+ url: sitemapUrl,
157
+ category: 'technical',
158
+ severity: 'error',
159
+ title: 'Failed to parse sitemap',
160
+ description: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
161
+ recommendation: 'Check sitemap validity using Google Search Console.',
162
+ detectedAt: new Date().toISOString(),
163
+ });
164
+ }
165
+
166
+ return analysis;
167
+ }
168
+
169
+ /**
170
+ * Recursively analyze a sitemap and all its children
171
+ */
172
+ export async function analyzeAllSitemaps(
173
+ sitemapUrl: string,
174
+ maxDepth = 3
175
+ ): Promise<SitemapAnalysis[]> {
176
+ const results: SitemapAnalysis[] = [];
177
+ const visited = new Set<string>();
178
+
179
+ async function analyze(url: string, depth: number): Promise<void> {
180
+ if (depth > maxDepth || visited.has(url)) return;
181
+ visited.add(url);
182
+
183
+ const analysis = await analyzeSitemap(url);
184
+ results.push(analysis);
185
+
186
+ // Recursively analyze child sitemaps
187
+ for (const childUrl of analysis.childSitemaps) {
188
+ await analyze(childUrl, depth + 1);
189
+ }
190
+ }
191
+
192
+ await analyze(sitemapUrl, 0);
193
+ return results;
194
+ }
195
+
196
+ function hash(str: string): string {
197
+ let hash = 0;
198
+ for (let i = 0; i < str.length; i++) {
199
+ const char = str.charCodeAt(i);
200
+ hash = (hash << 5) - hash + char;
201
+ hash = hash & hash;
202
+ }
203
+ return Math.abs(hash).toString(36);
204
+ }
@@ -0,0 +1,317 @@
1
+ /**
2
+ * @djangocfg/seo - Google Console Analyzer
3
+ * Analyze URL inspection results and detect SEO issues
4
+ */
5
+
6
+ import type {
7
+ UrlInspectionResult,
8
+ SeoIssue,
9
+ IssueSeverity,
10
+ IssueCategory,
11
+ } from '../types/index.js';
12
+
13
+ /**
14
+ * Analyze URL inspection results and extract SEO issues
15
+ */
16
+ export function analyzeInspectionResults(results: UrlInspectionResult[]): SeoIssue[] {
17
+ const issues: SeoIssue[] = [];
18
+
19
+ for (const result of results) {
20
+ issues.push(...analyzeUrlInspection(result));
21
+ }
22
+
23
+ return issues.sort((a, b) => severityOrder(a.severity) - severityOrder(b.severity));
24
+ }
25
+
26
+ function analyzeUrlInspection(result: UrlInspectionResult): SeoIssue[] {
27
+ const issues: SeoIssue[] = [];
28
+ const { indexStatusResult, mobileUsabilityResult, richResultsResult } = result;
29
+
30
+ // Indexing Issues
31
+ switch (indexStatusResult.coverageState) {
32
+ case 'CRAWLED_CURRENTLY_NOT_INDEXED':
33
+ issues.push({
34
+ id: `crawled-not-indexed-${hash(result.url)}`,
35
+ url: result.url,
36
+ category: 'indexing',
37
+ severity: 'error',
38
+ title: 'Page crawled but not indexed',
39
+ description:
40
+ 'Google crawled this page but decided not to index it. This often indicates low content quality or duplicate content.',
41
+ recommendation:
42
+ 'Improve content quality, ensure uniqueness, add more valuable information, and check for duplicate content issues.',
43
+ detectedAt: new Date().toISOString(),
44
+ metadata: { coverageState: indexStatusResult.coverageState },
45
+ });
46
+ break;
47
+
48
+ case 'DISCOVERED_CURRENTLY_NOT_INDEXED':
49
+ issues.push({
50
+ id: `discovered-not-indexed-${hash(result.url)}`,
51
+ url: result.url,
52
+ category: 'indexing',
53
+ severity: 'warning',
54
+ title: 'Page discovered but not crawled',
55
+ description:
56
+ 'Google discovered this URL but has not crawled it yet. This may indicate crawl budget issues or low priority.',
57
+ recommendation:
58
+ 'Improve internal linking to this page, submit URL through Google Search Console, or add to sitemap.',
59
+ detectedAt: new Date().toISOString(),
60
+ metadata: { coverageState: indexStatusResult.coverageState },
61
+ });
62
+ break;
63
+
64
+ case 'DUPLICATE_WITHOUT_USER_SELECTED_CANONICAL':
65
+ issues.push({
66
+ id: `duplicate-no-canonical-${hash(result.url)}`,
67
+ url: result.url,
68
+ category: 'indexing',
69
+ severity: 'warning',
70
+ title: 'Duplicate page without canonical',
71
+ description:
72
+ 'This page is considered a duplicate but no canonical URL has been specified. Google chose a canonical for you.',
73
+ recommendation:
74
+ 'Add a canonical tag pointing to the preferred version of this page.',
75
+ detectedAt: new Date().toISOString(),
76
+ metadata: {
77
+ coverageState: indexStatusResult.coverageState,
78
+ googleCanonical: indexStatusResult.googleCanonical,
79
+ },
80
+ });
81
+ break;
82
+
83
+ case 'DUPLICATE_GOOGLE_CHOSE_DIFFERENT_CANONICAL':
84
+ issues.push({
85
+ id: `canonical-mismatch-${hash(result.url)}`,
86
+ url: result.url,
87
+ category: 'indexing',
88
+ severity: 'warning',
89
+ title: 'Google chose different canonical',
90
+ description:
91
+ 'You specified a canonical URL, but Google chose a different one. This may cause indexing issues.',
92
+ recommendation:
93
+ 'Review canonical tags and ensure they point to the correct URL. Check for duplicate content.',
94
+ detectedAt: new Date().toISOString(),
95
+ metadata: {
96
+ coverageState: indexStatusResult.coverageState,
97
+ userCanonical: indexStatusResult.userCanonical,
98
+ googleCanonical: indexStatusResult.googleCanonical,
99
+ },
100
+ });
101
+ break;
102
+ }
103
+
104
+ // Indexing State Issues
105
+ switch (indexStatusResult.indexingState) {
106
+ case 'BLOCKED_BY_META_TAG':
107
+ issues.push({
108
+ id: `blocked-meta-noindex-${hash(result.url)}`,
109
+ url: result.url,
110
+ category: 'indexing',
111
+ severity: 'error',
112
+ title: 'Blocked by noindex meta tag',
113
+ description: 'This page has a noindex meta tag preventing it from being indexed.',
114
+ recommendation:
115
+ 'Remove the noindex meta tag if you want this page to be indexed. If intentional, no action needed.',
116
+ detectedAt: new Date().toISOString(),
117
+ metadata: { indexingState: indexStatusResult.indexingState },
118
+ });
119
+ break;
120
+
121
+ case 'BLOCKED_BY_HTTP_HEADER':
122
+ issues.push({
123
+ id: `blocked-http-header-${hash(result.url)}`,
124
+ url: result.url,
125
+ category: 'indexing',
126
+ severity: 'error',
127
+ title: 'Blocked by X-Robots-Tag header',
128
+ description: 'This page has a noindex directive in the X-Robots-Tag HTTP header.',
129
+ recommendation:
130
+ 'Remove the X-Robots-Tag: noindex header if you want this page to be indexed.',
131
+ detectedAt: new Date().toISOString(),
132
+ metadata: { indexingState: indexStatusResult.indexingState },
133
+ });
134
+ break;
135
+
136
+ case 'BLOCKED_BY_ROBOTS_TXT':
137
+ issues.push({
138
+ id: `blocked-robots-txt-${hash(result.url)}`,
139
+ url: result.url,
140
+ category: 'crawling',
141
+ severity: 'error',
142
+ title: 'Blocked by robots.txt',
143
+ description: 'This page is blocked from crawling by robots.txt rules.',
144
+ recommendation:
145
+ 'Update robots.txt to allow crawling if you want this page to be indexed.',
146
+ detectedAt: new Date().toISOString(),
147
+ metadata: { indexingState: indexStatusResult.indexingState },
148
+ });
149
+ break;
150
+ }
151
+
152
+ // Page Fetch Issues
153
+ switch (indexStatusResult.pageFetchState) {
154
+ case 'SOFT_404':
155
+ issues.push({
156
+ id: `soft-404-${hash(result.url)}`,
157
+ url: result.url,
158
+ category: 'technical',
159
+ severity: 'error',
160
+ title: 'Soft 404 error',
161
+ description:
162
+ 'This page returns a 200 status but Google detected it as a 404 page (empty or low-value content).',
163
+ recommendation:
164
+ 'Either return a proper 404 status code or add meaningful content to this page.',
165
+ detectedAt: new Date().toISOString(),
166
+ metadata: { pageFetchState: indexStatusResult.pageFetchState },
167
+ });
168
+ break;
169
+
170
+ case 'NOT_FOUND':
171
+ issues.push({
172
+ id: `404-error-${hash(result.url)}`,
173
+ url: result.url,
174
+ category: 'technical',
175
+ severity: 'error',
176
+ title: '404 Not Found',
177
+ description: 'This page returns a 404 error.',
178
+ recommendation:
179
+ 'Either restore the page content or set up a redirect to a relevant page.',
180
+ detectedAt: new Date().toISOString(),
181
+ metadata: { pageFetchState: indexStatusResult.pageFetchState },
182
+ });
183
+ break;
184
+
185
+ case 'SERVER_ERROR':
186
+ issues.push({
187
+ id: `server-error-${hash(result.url)}`,
188
+ url: result.url,
189
+ category: 'technical',
190
+ severity: 'critical',
191
+ title: 'Server error (5xx)',
192
+ description: 'This page returns a server error when Google tries to crawl it.',
193
+ recommendation:
194
+ 'Fix the server-side error. Check server logs for details.',
195
+ detectedAt: new Date().toISOString(),
196
+ metadata: { pageFetchState: indexStatusResult.pageFetchState },
197
+ });
198
+ break;
199
+
200
+ case 'REDIRECT_ERROR':
201
+ issues.push({
202
+ id: `redirect-error-${hash(result.url)}`,
203
+ url: result.url,
204
+ category: 'technical',
205
+ severity: 'error',
206
+ title: 'Redirect error',
207
+ description:
208
+ 'There is a redirect issue with this page (redirect loop, too many redirects, or invalid redirect).',
209
+ recommendation:
210
+ 'Fix the redirect chain. Ensure redirects point to valid, accessible pages.',
211
+ detectedAt: new Date().toISOString(),
212
+ metadata: { pageFetchState: indexStatusResult.pageFetchState },
213
+ });
214
+ break;
215
+
216
+ case 'ACCESS_DENIED':
217
+ case 'ACCESS_FORBIDDEN':
218
+ issues.push({
219
+ id: `access-denied-${hash(result.url)}`,
220
+ url: result.url,
221
+ category: 'technical',
222
+ severity: 'error',
223
+ title: 'Access denied (401/403)',
224
+ description: 'Google cannot access this page due to authentication requirements.',
225
+ recommendation:
226
+ 'Ensure the page is publicly accessible without authentication for Googlebot.',
227
+ detectedAt: new Date().toISOString(),
228
+ metadata: { pageFetchState: indexStatusResult.pageFetchState },
229
+ });
230
+ break;
231
+ }
232
+
233
+ // Mobile Usability Issues
234
+ if (mobileUsabilityResult?.verdict === 'FAIL' && mobileUsabilityResult.issues) {
235
+ for (const issue of mobileUsabilityResult.issues) {
236
+ issues.push({
237
+ id: `mobile-${issue.issueType}-${hash(result.url)}`,
238
+ url: result.url,
239
+ category: 'mobile',
240
+ severity: 'warning',
241
+ title: `Mobile usability: ${formatIssueType(issue.issueType)}`,
242
+ description: issue.message || 'Mobile usability issue detected.',
243
+ recommendation: getMobileRecommendation(issue.issueType),
244
+ detectedAt: new Date().toISOString(),
245
+ metadata: { issueType: issue.issueType },
246
+ });
247
+ }
248
+ }
249
+
250
+ // Rich Results Issues
251
+ if (richResultsResult?.verdict === 'FAIL' && richResultsResult.detectedItems) {
252
+ for (const item of richResultsResult.detectedItems) {
253
+ for (const i of item.items || []) {
254
+ for (const issueDetail of i.issues || []) {
255
+ issues.push({
256
+ id: `rich-result-${item.richResultType}-${hash(result.url)}`,
257
+ url: result.url,
258
+ category: 'structured-data',
259
+ severity: issueDetail.severity === 'ERROR' ? 'error' : 'warning',
260
+ title: `${item.richResultType}: ${i.name}`,
261
+ description: issueDetail.issueMessage,
262
+ recommendation:
263
+ 'Fix the structured data markup according to Google guidelines.',
264
+ detectedAt: new Date().toISOString(),
265
+ metadata: { richResultType: item.richResultType },
266
+ });
267
+ }
268
+ }
269
+ }
270
+ }
271
+
272
+ return issues;
273
+ }
274
+
275
+ function severityOrder(severity: IssueSeverity): number {
276
+ const order: Record<IssueSeverity, number> = {
277
+ critical: 0,
278
+ error: 1,
279
+ warning: 2,
280
+ info: 3,
281
+ };
282
+ return order[severity];
283
+ }
284
+
285
+ function hash(str: string): string {
286
+ let hash = 0;
287
+ for (let i = 0; i < str.length; i++) {
288
+ const char = str.charCodeAt(i);
289
+ hash = (hash << 5) - hash + char;
290
+ hash = hash & hash;
291
+ }
292
+ return Math.abs(hash).toString(36);
293
+ }
294
+
295
+ function formatIssueType(type: string): string {
296
+ return type
297
+ .replace(/_/g, ' ')
298
+ .toLowerCase()
299
+ .replace(/\b\w/g, (c) => c.toUpperCase());
300
+ }
301
+
302
+ function getMobileRecommendation(issueType: string): string {
303
+ const recommendations: Record<string, string> = {
304
+ MOBILE_FRIENDLY_RULE_USES_INCOMPATIBLE_PLUGINS:
305
+ 'Remove Flash or other incompatible plugins. Use HTML5 alternatives.',
306
+ MOBILE_FRIENDLY_RULE_CONFIGURE_VIEWPORT:
307
+ 'Add a viewport meta tag: <meta name="viewport" content="width=device-width, initial-scale=1">',
308
+ MOBILE_FRIENDLY_RULE_CONTENT_NOT_SIZED_TO_VIEWPORT:
309
+ 'Ensure content width fits the viewport. Use responsive CSS.',
310
+ MOBILE_FRIENDLY_RULE_TAP_TARGETS_TOO_SMALL:
311
+ 'Increase the size of touch targets (buttons, links) to at least 48x48 pixels.',
312
+ MOBILE_FRIENDLY_RULE_TEXT_TOO_SMALL:
313
+ 'Use at least 16px font size for body text.',
314
+ };
315
+
316
+ return recommendations[issueType] || 'Fix the mobile usability issue according to Google guidelines.';
317
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @djangocfg/seo - Google Console Authentication
3
+ * Service Account authentication for Google Search Console API
4
+ */
5
+
6
+ import { JWT } from 'google-auth-library';
7
+ import { readFileSync, existsSync } from 'node:fs';
8
+ import consola from 'consola';
9
+ import type { GoogleConsoleConfig } from '../types/index.js';
10
+
11
+ const SCOPES = [
12
+ 'https://www.googleapis.com/auth/webmasters.readonly',
13
+ 'https://www.googleapis.com/auth/webmasters',
14
+ ];
15
+
16
+ export interface ServiceAccountCredentials {
17
+ client_email: string;
18
+ private_key: string;
19
+ project_id?: string;
20
+ }
21
+
22
+ /**
23
+ * Load service account credentials from file or config
24
+ */
25
+ export function loadCredentials(config: GoogleConsoleConfig): ServiceAccountCredentials {
26
+ if (config.serviceAccountJson) {
27
+ return config.serviceAccountJson;
28
+ }
29
+
30
+ if (config.serviceAccountPath) {
31
+ if (!existsSync(config.serviceAccountPath)) {
32
+ throw new Error(`Service account file not found: ${config.serviceAccountPath}`);
33
+ }
34
+
35
+ const content = readFileSync(config.serviceAccountPath, 'utf-8');
36
+ return JSON.parse(content) as ServiceAccountCredentials;
37
+ }
38
+
39
+ // Try to load from environment variable
40
+ const envJson = process.env.GOOGLE_SERVICE_ACCOUNT_JSON;
41
+ if (envJson) {
42
+ return JSON.parse(envJson) as ServiceAccountCredentials;
43
+ }
44
+
45
+ // Try default path
46
+ const defaultPath = './service_account.json';
47
+ if (existsSync(defaultPath)) {
48
+ const content = readFileSync(defaultPath, 'utf-8');
49
+ return JSON.parse(content) as ServiceAccountCredentials;
50
+ }
51
+
52
+ throw new Error(
53
+ 'No service account credentials found. Provide serviceAccountPath, serviceAccountJson, or set GOOGLE_SERVICE_ACCOUNT_JSON env variable.'
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Create authenticated JWT client
59
+ */
60
+ export function createAuthClient(config: GoogleConsoleConfig): JWT {
61
+ const credentials = loadCredentials(config);
62
+
63
+ const auth = new JWT({
64
+ email: credentials.client_email,
65
+ key: credentials.private_key,
66
+ scopes: SCOPES,
67
+ });
68
+
69
+ // Store email for later display
70
+ (auth as any)._serviceAccountEmail = credentials.client_email;
71
+
72
+ return auth;
73
+ }
74
+
75
+ /**
76
+ * Verify authentication is working
77
+ */
78
+ export async function verifyAuth(auth: JWT, siteUrl?: string): Promise<boolean> {
79
+ const email = (auth as any)._serviceAccountEmail || auth.email;
80
+
81
+ try {
82
+ await auth.authorize();
83
+ consola.success('Google Search Console authentication verified');
84
+ consola.info(`Service account: ${email}`);
85
+
86
+ // Build GSC users URL with domain
87
+ if (siteUrl) {
88
+ const domain = new URL(siteUrl).hostname;
89
+ const gscUrl = `https://search.google.com/search-console/users?resource_id=sc-domain%3A${domain}`;
90
+ consola.info(`Ensure this email has Full access in GSC: ${gscUrl}`);
91
+ }
92
+
93
+ return true;
94
+ } catch (error) {
95
+ consola.error('Authentication failed');
96
+ consola.info(`Service account email: ${email}`);
97
+ consola.info('Make sure this email is added to GSC with Full access');
98
+ return false;
99
+ }
100
+ }