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.
@@ -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', ''].includes(normalized))
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.apiToken) {
112
- throw new Error('Falta el API token generado por la app.');
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-Token': params.apiToken,
119
+ 'X-API-Key': params.apiKey,
120
120
  },
121
121
  body: JSON.stringify({
122
- apiToken: params.apiToken,
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.apiToken)
141
- headers['X-API-Token'] = params.apiToken;
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
- apiToken: readString(source.apiToken) ||
163
- readHeader(headers, 'x-api-token') ||
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 Scanner',
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 técnico completo de una página web. Opción de escanear también enlaces internos.',
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 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.',
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 Scanner' },
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 página',
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 página a escanear (ej: https://example.com/pagina)',
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 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)',
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: 'Límite de páginas a escanear',
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: 'Número máximo de páginas internas a analizar (1-100). Se usan las URLs internas encontradas en la página principal.',
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 línea)',
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 Páginas (Una por línea)',
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. Útil para excluir /tags/, /author/, etc.',
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 página (segundos)',
235
+ displayName: 'Timeout por página (segundos)',
236
236
  name: 'timeoutSeconds',
237
237
  type: 'number',
238
238
  default: 15,
239
- description: 'Tiempo máximo de espera por cada página antes de cancelar (5-60)',
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 está activado, usa el User-Agent indicado en el campo siguiente en lugar del navegador por defecto',
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" está activado.',
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 página como la vería el crawler de Google',
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. Vacío = no enviar.',
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 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.',
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 está activado, al terminar el escaneo se enviarán los resultados a una URL externa (API REST).',
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 enviará un POST con los resultados del escaneo (JSON). Se enviará el reportId, data y htmlReport.',
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 aplicación externa (se incluye en el JSON enviado). Útil si el escaneo se lanzó desde la web.',
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 Token',
319
+ displayName: 'API Key',
320
320
  name: 'apiToken',
321
321
  type: 'string',
322
322
  default: '',
323
323
  typeOptions: { password: true },
324
- description: 'Token de autenticación para la API externa (se envía como header X-API-Token).',
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: 'Añadir opción',
331
+ placeholder: 'Añadir opción',
332
332
  default: {},
333
333
  options: [
334
334
  {
335
- displayName: 'Incluir detalle de imágenes',
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: 'Máximo enlaces a comprobar',
370
+ displayName: 'Máximo enlaces a comprobar',
371
371
  name: 'maxBrokenLinksToCheck',
372
372
  type: 'number',
373
373
  default: 15,
374
- description: 'Límite de enlaces a verificar (internos + externos). Solo aplica si "Comprobar enlaces rotos" está activo.',
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: 'Qué detalles incluir en la salida',
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 apiToken = incoming.apiToken || readString(credentials?.apiKey) || '';
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
- apiToken,
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
- apiToken,
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 devolvió error HTTP ${res.status}`);
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 válida.');
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 válida' }, pairedItem: { item: 0 } }]];
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 página devolvió código ${statusCode}`,
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} más` : '');
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 está bloqueada en robots.txt (los buscadores no la indexarán)';
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: 'Añadir la URL al sitemap.xml para facilitar el descubrimiento por buscadores.',
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 página: ${finalUrl}`,
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 página (timeout o red)'));
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} páginas (1 principal + ${internalResults.length} enlaces internos).`;
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: título o meta description iguales en ${dupTitle.length + dupDesc.length} página(s)`;
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 += `[Título duplicado]\n${mainResult.title}\n\nEncontrado también en:\n${dupTitle.map(u => '- ' + u).join('\n')}\n\n`;
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 también en:\n${dupDesc.map(u => '- ' + u).join('\n')}\n`;
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 títulos y meta descriptions únicos por página.',
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 (título o description)');
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} página(s) huérfana(s) detectada(s) en el sitemap`;
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} más` : '');
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: 'Asegúrate de que todas las páginas importantes del sitemap estén enlazadas desde otras páginas de tu sitio.',
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 páginas huérfanas en el sitemap');
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} página(s) alternativa(s)`;
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: 'Asegúrate de que todas las páginas alternativas tengan una etiqueta hreflang que apunte de vuelta a esta página.',
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('Verificación hreflang cruzada correcta (todas devuelven enlace)');
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 apiTokenExt = '';
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
- apiTokenExt = (this.getNodeParameter('apiToken', 0) || '').trim();
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 (!apiTokenExt && incoming.apiToken)
1010
- apiTokenExt = incoming.apiToken;
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 (apiTokenExt)
1022
- headers['X-API-Token'] = apiTokenExt;
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ó HTTP ${callbackResponse.status}${responseText ? `: ${responseText.slice(0, 500)}` : ''}`);
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.externalApiTokenUsed = Boolean(apiTokenExt);
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 página',
1047
+ error: 'No se pudo escanear la página',
1049
1048
  detail: msg,
1050
1049
  url: baseUrl,
1051
1050
  },