n8n-nodes-seo-scanner 1.2.21 → 1.2.23
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/dist/SeoScanner.node.js +73 -74
- package/dist/SeoScanner.node.js.map +1 -1
- package/dist/SeoScannerApi.credentials.js +4 -4
- package/dist/SeoScannerApi.credentials.js.map +1 -1
- package/dist/geoUtils.d.ts +28 -14
- package/dist/geoUtils.js +149 -82
- package/dist/geoUtils.js.map +1 -1
- package/dist/nodes/SeoScanner/SeoScanner.node.js +73 -74
- package/dist/nodes/SeoScanner/SeoScannerApi.credentials.js +4 -4
- package/dist/nodes/SeoScanner/geoUtils.js +149 -82
- package/package.json +1 -1
package/dist/SeoScanner.node.js
CHANGED
|
@@ -59,7 +59,7 @@ function readBoolean(value) {
|
|
|
59
59
|
return value;
|
|
60
60
|
if (typeof value === 'string') {
|
|
61
61
|
const normalized = value.trim().toLowerCase();
|
|
62
|
-
if (['true', '1', 'yes', 'si', '
|
|
62
|
+
if (['true', '1', 'yes', 'si', 'sÃ'].includes(normalized))
|
|
63
63
|
return true;
|
|
64
64
|
if (['false', '0', 'no'].includes(normalized))
|
|
65
65
|
return false;
|
|
@@ -108,18 +108,18 @@ async function validateApiKeyWithApp(params) {
|
|
|
108
108
|
if (!appBaseUrl) {
|
|
109
109
|
throw new Error('No se ha podido determinar la URL de la app para validar la API key.');
|
|
110
110
|
}
|
|
111
|
-
if (!params.
|
|
112
|
-
throw new Error('Falta
|
|
111
|
+
if (!params.apiKey) {
|
|
112
|
+
throw new Error('Falta la API key generada en UOPIX.');
|
|
113
113
|
}
|
|
114
114
|
const response = await fetch(`${appBaseUrl}/api/n8n/validate-key`, {
|
|
115
115
|
method: 'POST',
|
|
116
116
|
headers: {
|
|
117
117
|
'Accept': 'application/json',
|
|
118
118
|
'Content-Type': 'application/json',
|
|
119
|
-
'X-API-
|
|
119
|
+
'X-API-Key': params.apiKey,
|
|
120
120
|
},
|
|
121
121
|
body: JSON.stringify({
|
|
122
|
-
|
|
122
|
+
apiKey: params.apiKey,
|
|
123
123
|
}),
|
|
124
124
|
});
|
|
125
125
|
let body;
|
|
@@ -137,8 +137,8 @@ async function notifyCallbackError(params) {
|
|
|
137
137
|
if (!params.callbackUrl || !params.reportId)
|
|
138
138
|
return;
|
|
139
139
|
const headers = { 'Content-Type': 'application/json' };
|
|
140
|
-
if (params.
|
|
141
|
-
headers['X-API-
|
|
140
|
+
if (params.apiKey)
|
|
141
|
+
headers['X-API-Key'] = params.apiKey;
|
|
142
142
|
await fetch(params.callbackUrl, {
|
|
143
143
|
method: 'POST',
|
|
144
144
|
headers,
|
|
@@ -159,8 +159,8 @@ function getIncomingWebhookPayload(input) {
|
|
|
159
159
|
url: readString(source.url),
|
|
160
160
|
reportId: readString(source.reportId),
|
|
161
161
|
callbackUrl: readString(source.callbackUrl),
|
|
162
|
-
|
|
163
|
-
readHeader(headers, 'x-api-
|
|
162
|
+
apiKey: readString(source.apiKey) ||
|
|
163
|
+
readHeader(headers, 'x-api-key') ||
|
|
164
164
|
readBearerToken(authorization),
|
|
165
165
|
scanInternalLinks: readBoolean(source.allsite) ?? readBoolean(source.scanInternalLinks),
|
|
166
166
|
maxInternalUrls: readNumber(source.maxInternalUrls),
|
|
@@ -169,18 +169,18 @@ function getIncomingWebhookPayload(input) {
|
|
|
169
169
|
class SeoScanner {
|
|
170
170
|
constructor() {
|
|
171
171
|
this.description = {
|
|
172
|
-
displayName: 'SEO
|
|
172
|
+
displayName: 'SEO UOPIX',
|
|
173
173
|
name: 'seoScanner',
|
|
174
174
|
icon: 'file:seoScanner.svg',
|
|
175
175
|
group: ['transform'],
|
|
176
176
|
version: 1,
|
|
177
|
-
description: 'Escaneo SEO
|
|
177
|
+
description: 'Escaneo SEO técnico completo de una página web. Opción de escanear también enlaces internos.',
|
|
178
178
|
usableAsTool: {
|
|
179
179
|
replacements: {
|
|
180
|
-
description: 'Herramienta para escaneo SEO
|
|
180
|
+
description: 'Herramienta para escaneo SEO técnico completo de una página web. Parámetros: url (obligatorio), scanInternalLinks, maxInternalUrls, timeoutSeconds, customUserAgent, customUserAgentValue, simulateGooglebot, followRedirects, acceptLanguage, detailOptions. Devuelve: mainPage con problemsBySeverity (critical, important, warning, info), severitySummary (conteos por gravedad), issues, warnings, passed, recommendations. Gravedad: critical=bloquea indexación (no HTTPS, noindex, sin viewport, sin title), important=afecta SEO (sin meta description, sin H1, sin canonical, sin lang), warning=mejores prácticas (longitud tÃtulo/description, imágenes sin alt, OG), info=opcional (JSON-LD, favicon). Usar para auditorÃa SEO o análisis técnico.',
|
|
181
181
|
},
|
|
182
182
|
},
|
|
183
|
-
defaults: { name: 'SEO
|
|
183
|
+
defaults: { name: 'SEO UOPIX' },
|
|
184
184
|
inputs: [n8n_workflow_1.NodeConnectionTypes.Main],
|
|
185
185
|
outputs: [n8n_workflow_1.NodeConnectionTypes.Main],
|
|
186
186
|
credentials: [
|
|
@@ -191,59 +191,59 @@ class SeoScanner {
|
|
|
191
191
|
],
|
|
192
192
|
properties: [
|
|
193
193
|
{
|
|
194
|
-
displayName: 'URL de la
|
|
194
|
+
displayName: 'URL de la página',
|
|
195
195
|
name: 'url',
|
|
196
196
|
type: 'string',
|
|
197
197
|
default: '',
|
|
198
198
|
placeholder: 'https://example.com/pagina',
|
|
199
|
-
description: 'URL absoluta de la
|
|
199
|
+
description: 'URL absoluta de la página a escanear (ej: https://example.com/pagina)',
|
|
200
200
|
},
|
|
201
201
|
{
|
|
202
202
|
displayName: 'Escanear todo el sitio',
|
|
203
203
|
name: 'scanInternalLinks',
|
|
204
204
|
type: 'boolean',
|
|
205
205
|
default: false,
|
|
206
|
-
description: 'Si
|
|
206
|
+
description: 'Si está activado, además de la página principal se escanean URLs internas del mismo dominio encontradas en la página (hasta el lÃmite indicado)',
|
|
207
207
|
},
|
|
208
208
|
{
|
|
209
|
-
displayName: '
|
|
209
|
+
displayName: 'LÃmite de páginas a escanear',
|
|
210
210
|
name: 'maxInternalUrls',
|
|
211
211
|
type: 'number',
|
|
212
212
|
default: 20,
|
|
213
213
|
typeOptions: { minValue: 1, maxValue: 100 },
|
|
214
|
-
description: '
|
|
214
|
+
description: 'Número máximo de páginas internas a analizar (1-100). Se usan las URLs internas encontradas en la página principal.',
|
|
215
215
|
displayOptions: { show: { scanInternalLinks: [true] } },
|
|
216
216
|
},
|
|
217
217
|
{
|
|
218
|
-
displayName: 'Ignorar Fallos (Uno por
|
|
218
|
+
displayName: 'Ignorar Fallos (Uno por lÃnea)',
|
|
219
219
|
name: 'ignoredIssues',
|
|
220
220
|
type: 'string',
|
|
221
221
|
typeOptions: { alwaysOpenEditWindow: true },
|
|
222
222
|
default: '',
|
|
223
|
-
description: 'Textos de los errores que deseas ignorar. Si el mensaje del error contiene alguno de estos textos, no se mostrar
|
|
223
|
+
description: 'Textos de los errores que deseas ignorar. Si el mensaje del error contiene alguno de estos textos, no se mostrará.',
|
|
224
224
|
},
|
|
225
225
|
{
|
|
226
|
-
displayName: 'Ignorar
|
|
226
|
+
displayName: 'Ignorar Páginas (Una por lÃnea)',
|
|
227
227
|
name: 'ignoredPages',
|
|
228
228
|
type: 'string',
|
|
229
229
|
typeOptions: { alwaysOpenEditWindow: true },
|
|
230
230
|
default: '',
|
|
231
|
-
description: 'Fragmentos de URL que no deseas escanear.
|
|
231
|
+
description: 'Fragmentos de URL que no deseas escanear. Útil para excluir /tags/, /author/, etc.',
|
|
232
232
|
displayOptions: { show: { scanInternalLinks: [true] } },
|
|
233
233
|
},
|
|
234
234
|
{
|
|
235
|
-
displayName: 'Timeout por
|
|
235
|
+
displayName: 'Timeout por página (segundos)',
|
|
236
236
|
name: 'timeoutSeconds',
|
|
237
237
|
type: 'number',
|
|
238
238
|
default: 15,
|
|
239
|
-
description: 'Tiempo
|
|
239
|
+
description: 'Tiempo máximo de espera por cada página antes de cancelar (5-60)',
|
|
240
240
|
},
|
|
241
241
|
{
|
|
242
242
|
displayName: 'User-Agent personalizado',
|
|
243
243
|
name: 'customUserAgent',
|
|
244
244
|
type: 'boolean',
|
|
245
245
|
default: false,
|
|
246
|
-
description: 'Si
|
|
246
|
+
description: 'Si está activado, usa el User-Agent indicado en el campo siguiente en lugar del navegador por defecto',
|
|
247
247
|
},
|
|
248
248
|
{
|
|
249
249
|
displayName: 'User-Agent',
|
|
@@ -251,7 +251,7 @@ class SeoScanner {
|
|
|
251
251
|
type: 'string',
|
|
252
252
|
default: '',
|
|
253
253
|
placeholder: 'Mozilla/5.0 (compatible; MiBot/1.0)',
|
|
254
|
-
description: 'Cadena User-Agent a enviar en las peticiones HTTP. Solo aplica si "User-Agent personalizado"
|
|
254
|
+
description: 'Cadena User-Agent a enviar en las peticiones HTTP. Solo aplica si "User-Agent personalizado" está activado.',
|
|
255
255
|
displayOptions: { show: { customUserAgent: [true] } },
|
|
256
256
|
},
|
|
257
257
|
{
|
|
@@ -259,7 +259,7 @@ class SeoScanner {
|
|
|
259
259
|
name: 'simulateGooglebot',
|
|
260
260
|
type: 'boolean',
|
|
261
261
|
default: false,
|
|
262
|
-
description: 'Usa el User-Agent de Googlebot para ver la
|
|
262
|
+
description: 'Usa el User-Agent de Googlebot para ver la página como la verÃa el crawler de Google',
|
|
263
263
|
},
|
|
264
264
|
{
|
|
265
265
|
displayName: 'Seguir redirecciones',
|
|
@@ -274,7 +274,7 @@ class SeoScanner {
|
|
|
274
274
|
type: 'string',
|
|
275
275
|
default: '',
|
|
276
276
|
placeholder: 'es-ES,es;q=0.9,en;q=0.8',
|
|
277
|
-
description: 'Cabecera Accept-Language para solicitar contenido en un idioma concreto.
|
|
277
|
+
description: 'Cabecera Accept-Language para solicitar contenido en un idioma concreto. VacÃo = no enviar.',
|
|
278
278
|
},
|
|
279
279
|
{
|
|
280
280
|
displayName: 'Generar informe HTML',
|
|
@@ -288,14 +288,14 @@ class SeoScanner {
|
|
|
288
288
|
name: 'useJsRendering',
|
|
289
289
|
type: 'boolean',
|
|
290
290
|
default: false,
|
|
291
|
-
description: 'Si
|
|
291
|
+
description: 'Si está activado, usa un navegador invisible (Puppeteer) para cargar la página. Necesario para webs React, Vue o Angular que renderizan meta etiquetas y contenido dinámicamente. Es más lento que la petición HTTP normal.',
|
|
292
292
|
},
|
|
293
293
|
{
|
|
294
294
|
displayName: 'Enviar resultados a API externa',
|
|
295
295
|
name: 'sendToExternalApi',
|
|
296
296
|
type: 'boolean',
|
|
297
297
|
default: false,
|
|
298
|
-
description: 'Si
|
|
298
|
+
description: 'Si está activado, al terminar el escaneo se enviarán los resultados a una URL externa (API REST).',
|
|
299
299
|
},
|
|
300
300
|
{
|
|
301
301
|
displayName: 'Callback URL',
|
|
@@ -303,7 +303,7 @@ class SeoScanner {
|
|
|
303
303
|
type: 'string',
|
|
304
304
|
default: '',
|
|
305
305
|
placeholder: 'http://localhost:3000/api/reports',
|
|
306
|
-
description: 'URL a la que se
|
|
306
|
+
description: 'URL a la que se enviará un POST con los resultados del escaneo (JSON). Se enviará el reportId, data y htmlReport.',
|
|
307
307
|
displayOptions: { show: { sendToExternalApi: [true] } },
|
|
308
308
|
},
|
|
309
309
|
{
|
|
@@ -312,27 +312,27 @@ class SeoScanner {
|
|
|
312
312
|
type: 'string',
|
|
313
313
|
default: '',
|
|
314
314
|
placeholder: '',
|
|
315
|
-
description: 'Identificador del reporte en la
|
|
315
|
+
description: 'Identificador del reporte en la aplicación externa (se incluye en el JSON enviado). Útil si el escaneo se lanzó desde la web.',
|
|
316
316
|
displayOptions: { show: { sendToExternalApi: [true] } },
|
|
317
317
|
},
|
|
318
318
|
{
|
|
319
|
-
displayName: 'API
|
|
319
|
+
displayName: 'API Key',
|
|
320
320
|
name: 'apiToken',
|
|
321
321
|
type: 'string',
|
|
322
322
|
default: '',
|
|
323
323
|
typeOptions: { password: true },
|
|
324
|
-
description: '
|
|
324
|
+
description: 'API key de autenticación para la API externa (se envía como header X-API-Key).',
|
|
325
325
|
displayOptions: { show: { sendToExternalApi: [true] } },
|
|
326
326
|
},
|
|
327
327
|
{
|
|
328
328
|
displayName: 'Opciones de detalle',
|
|
329
329
|
name: 'detailOptions',
|
|
330
330
|
type: 'collection',
|
|
331
|
-
placeholder: '
|
|
331
|
+
placeholder: 'Añadir opción',
|
|
332
332
|
default: {},
|
|
333
333
|
options: [
|
|
334
334
|
{
|
|
335
|
-
displayName: 'Incluir detalle de
|
|
335
|
+
displayName: 'Incluir detalle de imágenes',
|
|
336
336
|
name: 'includeImageDetails',
|
|
337
337
|
type: 'boolean',
|
|
338
338
|
default: true,
|
|
@@ -367,11 +367,11 @@ class SeoScanner {
|
|
|
367
367
|
description: 'Hacer HEAD request a enlaces para detectar 404 u otros errores (puede ralentizar)',
|
|
368
368
|
},
|
|
369
369
|
{
|
|
370
|
-
displayName: '
|
|
370
|
+
displayName: 'Máximo enlaces a comprobar',
|
|
371
371
|
name: 'maxBrokenLinksToCheck',
|
|
372
372
|
type: 'number',
|
|
373
373
|
default: 15,
|
|
374
|
-
description: '
|
|
374
|
+
description: 'LÃmite de enlaces a verificar (internos + externos). Solo aplica si "Comprobar enlaces rotos" está activo.',
|
|
375
375
|
},
|
|
376
376
|
{
|
|
377
377
|
displayName: 'Comprobar contraste de colores',
|
|
@@ -388,7 +388,7 @@ class SeoScanner {
|
|
|
388
388
|
description: 'En cada fallo (p. ej. contraste) incluye el selector o etiqueta HTML del elemento afectado',
|
|
389
389
|
},
|
|
390
390
|
],
|
|
391
|
-
description: '
|
|
391
|
+
description: 'Qué detalles incluir en la salida',
|
|
392
392
|
},
|
|
393
393
|
],
|
|
394
394
|
};
|
|
@@ -403,16 +403,15 @@ class SeoScanner {
|
|
|
403
403
|
catch {
|
|
404
404
|
credentials = {};
|
|
405
405
|
}
|
|
406
|
-
const
|
|
406
|
+
const apiKey = incoming.apiKey || readString(credentials?.apiKey) || '';
|
|
407
407
|
const appBaseUrl = readString(credentials?.appBaseUrl) ||
|
|
408
408
|
getOriginFromUrl(incoming.callbackUrl || '') ||
|
|
409
409
|
DEFAULT_APP_BASE_URL;
|
|
410
410
|
let licenseWarning = '';
|
|
411
|
-
const apiKey = apiToken;
|
|
412
411
|
try {
|
|
413
412
|
await validateApiKeyWithApp({
|
|
414
413
|
appBaseUrl,
|
|
415
|
-
|
|
414
|
+
apiKey,
|
|
416
415
|
});
|
|
417
416
|
}
|
|
418
417
|
catch (e) {
|
|
@@ -421,7 +420,7 @@ class SeoScanner {
|
|
|
421
420
|
await notifyCallbackError({
|
|
422
421
|
callbackUrl: incoming.callbackUrl || '',
|
|
423
422
|
reportId: incoming.reportId || '',
|
|
424
|
-
|
|
423
|
+
apiKey,
|
|
425
424
|
message: errorMessage,
|
|
426
425
|
}).catch(() => undefined);
|
|
427
426
|
return [[{ json: { error: errorMessage, licenseValid: false }, pairedItem: { item: 0 } }]];
|
|
@@ -434,12 +433,12 @@ class SeoScanner {
|
|
|
434
433
|
headers: { 'Accept': 'application/json' }
|
|
435
434
|
});
|
|
436
435
|
if (!res.ok) {
|
|
437
|
-
throw new Error(`El servidor de licencias
|
|
436
|
+
throw new Error(`El servidor de licencias devolvió error HTTP ${res.status}`);
|
|
438
437
|
}
|
|
439
438
|
const jsonText = await res.text();
|
|
440
439
|
const data = JSON.parse(jsonText);
|
|
441
440
|
if (!data.hasOwnProperty(apiKey)) {
|
|
442
|
-
throw new Error('La licencia introducida no es
|
|
441
|
+
throw new Error('La licencia introducida no es válida.');
|
|
443
442
|
}
|
|
444
443
|
}
|
|
445
444
|
catch (e) {
|
|
@@ -520,14 +519,14 @@ class SeoScanner {
|
|
|
520
519
|
baseUrl = new URL(url).href;
|
|
521
520
|
}
|
|
522
521
|
catch {
|
|
523
|
-
return [[{ json: { error: 'URL no
|
|
522
|
+
return [[{ json: { error: 'URL no válida' }, pairedItem: { item: 0 } }]];
|
|
524
523
|
}
|
|
525
524
|
try {
|
|
526
525
|
const { html, finalUrl, statusCode, responseTimeMs, timeToFirstByteMs, responseHeaders } = await (0, networkUtils_1.fetchPage)(baseUrl, timeoutMs, fetchOpts);
|
|
527
526
|
if (statusCode >= 400) {
|
|
528
527
|
return [[{
|
|
529
528
|
json: {
|
|
530
|
-
error: `La
|
|
529
|
+
error: `La página devolvió código ${statusCode}`,
|
|
531
530
|
statusCode,
|
|
532
531
|
url: baseUrl,
|
|
533
532
|
},
|
|
@@ -619,7 +618,7 @@ class SeoScanner {
|
|
|
619
618
|
if (mainResult.brokenLinks.length > 0) {
|
|
620
619
|
const msg = `${mainResult.brokenLinks.length} enlace(s) roto(s) detectado(s)`;
|
|
621
620
|
if (!isIgnored(msg)) {
|
|
622
|
-
const blList = mainResult.brokenLinks.slice(0, 15).map(b => `${b.statusCode}: ${b.url}`).join('\n') + (mainResult.brokenLinks.length > 15 ? `\n...y ${mainResult.brokenLinks.length - 15}
|
|
621
|
+
const blList = mainResult.brokenLinks.slice(0, 15).map(b => `${b.statusCode}: ${b.url}`).join('\n') + (mainResult.brokenLinks.length > 15 ? `\n...y ${mainResult.brokenLinks.length - 15} más` : '');
|
|
623
622
|
mainResult.problemsBySeverity.important.push({
|
|
624
623
|
message: msg,
|
|
625
624
|
recommendation: 'Corregir o eliminar los enlaces que devuelven 404 u otros errores.',
|
|
@@ -664,7 +663,7 @@ class SeoScanner {
|
|
|
664
663
|
mainResult.sitemapUrlsChecked = robotsSitemap.sitemapUrlsChecked;
|
|
665
664
|
mainResult.sitemapError = robotsSitemap.sitemapError;
|
|
666
665
|
if (robotsSitemap.robotsTxtBlocked) {
|
|
667
|
-
const msg = 'La URL
|
|
666
|
+
const msg = 'La URL está bloqueada en robots.txt (los buscadores no la indexarán)';
|
|
668
667
|
if (!isIgnored(msg)) {
|
|
669
668
|
mainResult.problemsBySeverity.critical.push({
|
|
670
669
|
message: msg,
|
|
@@ -682,7 +681,7 @@ class SeoScanner {
|
|
|
682
681
|
if (!isIgnored(msg)) {
|
|
683
682
|
mainResult.problemsBySeverity.info.push({
|
|
684
683
|
message: msg,
|
|
685
|
-
recommendation: '
|
|
684
|
+
recommendation: 'Añadir la URL al sitemap.xml para facilitar el descubrimiento por buscadores.',
|
|
686
685
|
});
|
|
687
686
|
}
|
|
688
687
|
}
|
|
@@ -733,7 +732,7 @@ class SeoScanner {
|
|
|
733
732
|
sitemapInSitemap: mainResult.sitemapInSitemap,
|
|
734
733
|
},
|
|
735
734
|
scannedUrls: 1,
|
|
736
|
-
message: `Escaneada 1
|
|
735
|
+
message: `Escaneada 1 página: ${finalUrl}`,
|
|
737
736
|
pageTextContent: mainResult.pageTextContent ?? '',
|
|
738
737
|
};
|
|
739
738
|
let internalResults = [];
|
|
@@ -756,12 +755,12 @@ class SeoScanner {
|
|
|
756
755
|
internalResults.push(result);
|
|
757
756
|
}
|
|
758
757
|
catch {
|
|
759
|
-
internalResults.push((0, analyzeUtils_1.createEmptySeoResult)(link, 'Error al cargar la
|
|
758
|
+
internalResults.push((0, analyzeUtils_1.createEmptySeoResult)(link, 'Error al cargar la página (timeout o red)'));
|
|
760
759
|
}
|
|
761
760
|
}
|
|
762
761
|
output.internalPages = internalResults;
|
|
763
762
|
output.scannedUrls = 1 + internalResults.length;
|
|
764
|
-
output.message = `Escaneadas ${output.scannedUrls}
|
|
763
|
+
output.message = `Escaneadas ${output.scannedUrls} páginas (1 principal + ${internalResults.length} enlaces internos).`;
|
|
765
764
|
if (analyzeOpts.checkBrokenLinks) {
|
|
766
765
|
const maxSiteCheck = Math.min(100, Math.max(analyzeOpts.maxBrokenLinksToCheck ?? 15, 30));
|
|
767
766
|
const seenInternal = new Set();
|
|
@@ -833,24 +832,24 @@ class SeoScanner {
|
|
|
833
832
|
dupDesc.push(p.url);
|
|
834
833
|
}
|
|
835
834
|
if (dupTitle.length > 0 || dupDesc.length > 0) {
|
|
836
|
-
const msg = `Posible contenido duplicado:
|
|
835
|
+
const msg = `Posible contenido duplicado: tÃtulo o meta description iguales en ${dupTitle.length + dupDesc.length} página(s)`;
|
|
837
836
|
if (!isIgnored(msg)) {
|
|
838
837
|
mainResult.duplicateContent = [];
|
|
839
838
|
let elementText = '';
|
|
840
839
|
if (dupTitle.length > 0) {
|
|
841
840
|
mainResult.duplicateContent.push({ type: 'title', duplicateWith: dupTitle });
|
|
842
|
-
elementText += `[
|
|
841
|
+
elementText += `[TÃtulo duplicado]\n${mainResult.title}\n\nEncontrado también en:\n${dupTitle.map(u => '- ' + u).join('\n')}\n\n`;
|
|
843
842
|
}
|
|
844
843
|
if (dupDesc.length > 0) {
|
|
845
844
|
mainResult.duplicateContent.push({ type: 'description', duplicateWith: dupDesc });
|
|
846
|
-
elementText += `[Meta description duplicada]\n${mainResult.metaDescription}\n\nEncontrada
|
|
845
|
+
elementText += `[Meta description duplicada]\n${mainResult.metaDescription}\n\nEncontrada también en:\n${dupDesc.map(u => '- ' + u).join('\n')}\n`;
|
|
847
846
|
}
|
|
848
847
|
mainResult.problemsBySeverity.important.push({
|
|
849
848
|
message: msg,
|
|
850
|
-
recommendation: 'Usar
|
|
849
|
+
recommendation: 'Usar tÃtulos y meta descriptions únicos por página.',
|
|
851
850
|
element: elementText.trim()
|
|
852
851
|
});
|
|
853
|
-
mainResult.issues.push('Contenido duplicado detectado (
|
|
852
|
+
mainResult.issues.push('Contenido duplicado detectado (tÃtulo o description)');
|
|
854
853
|
mainResult.severitySummary.important = mainResult.problemsBySeverity.important.length;
|
|
855
854
|
}
|
|
856
855
|
}
|
|
@@ -875,12 +874,12 @@ class SeoScanner {
|
|
|
875
874
|
}
|
|
876
875
|
if (orphanPages.length > 0) {
|
|
877
876
|
mainResult.isOrphan = false;
|
|
878
|
-
const msg = `${orphanPages.length}
|
|
877
|
+
const msg = `${orphanPages.length} página(s) huérfana(s) detectada(s) en el sitemap`;
|
|
879
878
|
if (!isIgnored(msg)) {
|
|
880
|
-
const orphansList = orphanPages.slice(0, 15).join('\n') + (orphanPages.length > 15 ? `\n...y ${orphanPages.length - 15}
|
|
879
|
+
const orphansList = orphanPages.slice(0, 15).join('\n') + (orphanPages.length > 15 ? `\n...y ${orphanPages.length - 15} más` : '');
|
|
881
880
|
mainResult.problemsBySeverity.warning.push({
|
|
882
881
|
message: msg,
|
|
883
|
-
recommendation: '
|
|
882
|
+
recommendation: 'Asegúrate de que todas las páginas importantes del sitemap estén enlazadas desde otras páginas de tu sitio.',
|
|
884
883
|
element: orphansList
|
|
885
884
|
});
|
|
886
885
|
mainResult.warnings.push(msg);
|
|
@@ -889,7 +888,7 @@ class SeoScanner {
|
|
|
889
888
|
output.summary.orphanPagesCount = orphanPages.length;
|
|
890
889
|
}
|
|
891
890
|
else {
|
|
892
|
-
mainResult.passed.push('No se detectaron
|
|
891
|
+
mainResult.passed.push('No se detectaron páginas huérfanas en el sitemap');
|
|
893
892
|
output.summary.orphanPagesCount = 0;
|
|
894
893
|
}
|
|
895
894
|
}
|
|
@@ -941,12 +940,12 @@ class SeoScanner {
|
|
|
941
940
|
}
|
|
942
941
|
const invalidCross = resultObj.crossHreflang.filter(c => !c.valid);
|
|
943
942
|
if (invalidCross.length > 0) {
|
|
944
|
-
const msg = `Faltan etiquetas hreflang de retorno en ${invalidCross.length}
|
|
943
|
+
const msg = `Faltan etiquetas hreflang de retorno en ${invalidCross.length} página(s) alternativa(s)`;
|
|
945
944
|
if (!isIgnored(msg)) {
|
|
946
945
|
const crossList = invalidCross.map(c => `- ${c.targetUrl} (${c.error})`).join('\n');
|
|
947
946
|
resultObj.problemsBySeverity.important.push({
|
|
948
947
|
message: msg,
|
|
949
|
-
recommendation: '
|
|
948
|
+
recommendation: 'Asegúrate de que todas las páginas alternativas tengan una etiqueta hreflang que apunte de vuelta a esta página.',
|
|
950
949
|
element: crossList
|
|
951
950
|
});
|
|
952
951
|
resultObj.issues.push(msg);
|
|
@@ -954,7 +953,7 @@ class SeoScanner {
|
|
|
954
953
|
}
|
|
955
954
|
}
|
|
956
955
|
else if (resultObj.crossHreflang.length > 0) {
|
|
957
|
-
resultObj.passed.push('
|
|
956
|
+
resultObj.passed.push('Verificación hreflang cruzada correcta (todas devuelven enlace)');
|
|
958
957
|
}
|
|
959
958
|
};
|
|
960
959
|
await verifyCrossHreflang(mainResult);
|
|
@@ -989,7 +988,7 @@ class SeoScanner {
|
|
|
989
988
|
if (sendToExternalApi) {
|
|
990
989
|
let callbackUrl = '';
|
|
991
990
|
let reportId = '';
|
|
992
|
-
let
|
|
991
|
+
let apiKeyExt = '';
|
|
993
992
|
try {
|
|
994
993
|
callbackUrl = (this.getNodeParameter('callbackUrl', 0) || '').trim();
|
|
995
994
|
}
|
|
@@ -999,15 +998,15 @@ class SeoScanner {
|
|
|
999
998
|
}
|
|
1000
999
|
catch { }
|
|
1001
1000
|
try {
|
|
1002
|
-
|
|
1001
|
+
apiKeyExt = (this.getNodeParameter('apiToken', 0) || '').trim();
|
|
1003
1002
|
}
|
|
1004
1003
|
catch { }
|
|
1005
1004
|
if (!callbackUrl && incoming.callbackUrl)
|
|
1006
1005
|
callbackUrl = incoming.callbackUrl;
|
|
1007
1006
|
if (!reportId && incoming.reportId)
|
|
1008
1007
|
reportId = incoming.reportId;
|
|
1009
|
-
if (!
|
|
1010
|
-
|
|
1008
|
+
if (!apiKeyExt && incoming.apiKey)
|
|
1009
|
+
apiKeyExt = incoming.apiKey;
|
|
1011
1010
|
if (callbackUrl && reportId) {
|
|
1012
1011
|
try {
|
|
1013
1012
|
const callbackBody = {
|
|
@@ -1018,8 +1017,8 @@ class SeoScanner {
|
|
|
1018
1017
|
const headers = {
|
|
1019
1018
|
'Content-Type': 'application/json',
|
|
1020
1019
|
};
|
|
1021
|
-
if (
|
|
1022
|
-
headers['X-API-
|
|
1020
|
+
if (apiKeyExt)
|
|
1021
|
+
headers['X-API-Key'] = apiKeyExt;
|
|
1023
1022
|
const callbackResponse = await fetch(callbackUrl, {
|
|
1024
1023
|
method: 'POST',
|
|
1025
1024
|
headers,
|
|
@@ -1027,11 +1026,11 @@ class SeoScanner {
|
|
|
1027
1026
|
});
|
|
1028
1027
|
if (!callbackResponse.ok) {
|
|
1029
1028
|
const responseText = await callbackResponse.text().catch(() => '');
|
|
1030
|
-
throw new Error(`La API externa devolvi
|
|
1029
|
+
throw new Error(`La API externa devolvió HTTP ${callbackResponse.status}${responseText ? `: ${responseText.slice(0, 500)}` : ''}`);
|
|
1031
1030
|
}
|
|
1032
1031
|
output.externalApiSent = true;
|
|
1033
1032
|
output.externalApiUrl = callbackUrl;
|
|
1034
|
-
output.
|
|
1033
|
+
output.externalApiKeyUsed = Boolean(apiKeyExt);
|
|
1035
1034
|
}
|
|
1036
1035
|
catch (callbackErr) {
|
|
1037
1036
|
output.externalApiSent = false;
|
|
@@ -1045,7 +1044,7 @@ class SeoScanner {
|
|
|
1045
1044
|
const msg = e instanceof Error ? e.message : String(e);
|
|
1046
1045
|
return [[{
|
|
1047
1046
|
json: {
|
|
1048
|
-
error: 'No se pudo escanear la
|
|
1047
|
+
error: 'No se pudo escanear la página',
|
|
1049
1048
|
detail: msg,
|
|
1050
1049
|
url: baseUrl,
|
|
1051
1050
|
},
|