n8n-nodes-seo-scanner 0.1.8 → 0.2.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.
@@ -1650,10 +1650,18 @@ function generateSeoReportHtml(main, internalPages) {
1650
1650
  const scoreLabel = main.score >= 70 ? 'Puntuación buena' : main.score >= 40 ? 'Puntuación regular' : 'Puntuación baja';
1651
1651
  const date = new Date().toISOString().slice(0, 19).replace('T', ' ');
1652
1652
  const multiPage = internalPages && internalPages.length > 0;
1653
+ const allPagesList = multiPage ? [main, ...internalPages] : [main];
1654
+ const totalHtmlSizeKb = (allPagesList.reduce((acc, p) => acc + (p.htmlSizeBytes ?? 0), 0) / 1024).toFixed(1);
1655
+ const totalScripts = allPagesList.reduce((acc, p) => acc + (p.resourcesCount?.scripts ?? 0), 0);
1656
+ const totalStylesheets = allPagesList.reduce((acc, p) => acc + (p.resourcesCount?.stylesheets ?? 0), 0);
1657
+ const totalImages = allPagesList.reduce((acc, p) => acc + (p.resourcesCount?.images ?? 0), 0);
1658
+ const totalWordCount = allPagesList.reduce((acc, p) => acc + (p.wordCount ?? 0), 0);
1659
+ const totalLinksInternal = allPagesList.reduce((acc, p) => acc + (p.linksInternal ?? 0), 0);
1660
+ const totalLinksExternal = allPagesList.reduce((acc, p) => acc + (p.linksExternal ?? 0), 0);
1653
1661
  const totalSeveritySummary = multiPage
1654
1662
  ? (() => {
1655
- let tc = severitySummary.critical, ti = severitySummary.important, tw = severitySummary.warning, tinfo = severitySummary.info, tp = passed.length;
1656
- for (const p of internalPages) {
1663
+ let tc = 0, ti = 0, tw = 0, tinfo = 0, tp = 0;
1664
+ for (const p of allPagesList) {
1657
1665
  const sp = p.severitySummary ?? {};
1658
1666
  const ppb = p.problemsBySeverity ?? {};
1659
1667
  tc += (Array.isArray(ppb.critical) ? ppb.critical.length : 0) || (sp.critical ?? 0);
@@ -1669,6 +1677,8 @@ function generateSeoReportHtml(main, internalPages) {
1669
1677
  const passedForBars = totalSeveritySummary ? totalSeveritySummary.passed : passed.length;
1670
1678
  const maxVal = Math.max(1, summaryForBars.critical, summaryForBars.important, summaryForBars.warning, summaryForBars.info, passedForBars);
1671
1679
  const distHeightPct = (n) => Math.max(2, (n / maxVal) * 100);
1680
+ const avgTtfb = Math.round(allPagesList.reduce((acc, p) => acc + (p.timeToFirstByteMs ?? 0), 0) / allPagesList.length);
1681
+ const avgTtfbPct = Math.min(100, Math.round((avgTtfb / 800) * 100));
1672
1682
  const ttfbPct = Math.min(100, Math.round(((main.timeToFirstByteMs ?? 0) / 800) * 100));
1673
1683
  const htmlSizeKb = ((main.htmlSizeBytes ?? 0) / 1024).toFixed(1);
1674
1684
  const host = main.finalUrl ? (() => { try {
@@ -1686,12 +1696,29 @@ function generateSeoReportHtml(main, internalPages) {
1686
1696
  return 'background:#6b7280';
1687
1697
  };
1688
1698
  const renderIssueRow = (it, rowClass, tagClass, labelText) => {
1689
- let s = `<div class="issue-row ${rowClass}"><details><summary><span class="issue-tag ${tagClass}">${labelText}</span><span>${escapeHtml(it.message)}</span><span class="issue-chevron">›</span></summary>`;
1699
+ let s = `<div class="issue-row ${rowClass}"><details><summary><span class="issue-tag ${tagClass}">${labelText}</span><span>${escapeHtml(it.message)}</span>`;
1700
+ if (it.urls && it.urls.length > 1) {
1701
+ s += `<span style="margin-left:8px;font-size:0.65rem;color:var(--text3);background:var(--bg3);padding:2px 6px;border-radius:10px;">${it.urls.length} págs</span>`;
1702
+ }
1703
+ s += `<span class="issue-chevron">›</span></summary>`;
1690
1704
  s += `<div class="issue-detail">`;
1691
- if (it.recommendation)
1692
- s += `<div class="issue-rec">${escapeHtml(it.recommendation)}</div>`;
1705
+ if (it.recommendation) {
1706
+ s += `<div class="issue-rec-important" style="display:flex; flex-direction:column; gap:4px;">
1707
+ <span style="font-family:var(--mono);font-size:0.65rem;color:var(--text3);letter-spacing:0.05em;text-transform:uppercase;"># Solución recomendada</span>
1708
+ <span style="font-family:var(--sans);font-size:0.78rem;color:var(--text2);">${escapeHtml(it.recommendation)}</span>
1709
+ </div>`;
1710
+ }
1693
1711
  if (it.element)
1694
1712
  s += `<div class="issue-code">${escapeHtml(it.element)}</div>`;
1713
+ if (it.urls && it.urls.length > 0) {
1714
+ s += `<div style="margin-top:12px;font-family:var(--mono);font-size:0.65rem;color:var(--text3);border-top:1px dashed var(--line);padding-top:8px;">`;
1715
+ s += `<div style="margin-bottom:6px;text-transform:uppercase;letter-spacing:0.05em;">Afecta a los siguientes sitios:</div>`;
1716
+ s += `<div style="max-height:150px; overflow-y:auto; padding-right:6px;">`;
1717
+ it.urls.forEach(u => {
1718
+ s += `<div style="padding:4px 6px;margin-bottom:2px;background:rgba(255,255,255,0.02);border-radius:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--blue);"><a href="${escapeHtml(u)}" target="_blank" rel="noopener" style="color:inherit;text-decoration:none;">${escapeHtml(u.replace(/^https?:\/\/[^/]+/, '') || '/')}</a></div>`;
1719
+ });
1720
+ s += `</div></div>`;
1721
+ }
1695
1722
  s += `</div></details></div>`;
1696
1723
  return s;
1697
1724
  };
@@ -1699,16 +1726,23 @@ function generateSeoReportHtml(main, internalPages) {
1699
1726
  const statusClass = main.statusCode === 200 ? 'status-200' : '';
1700
1727
  const ttfbVal = main.timeToFirstByteMs ?? 0;
1701
1728
  const ttfbStyle = ttfbVal > 600 ? 'color:var(--yellow)' : '';
1729
+ const dispHtmlSize = multiPage ? totalHtmlSizeKb : htmlSizeKb;
1730
+ const dispScripts = multiPage ? totalScripts : resourcesCount.scripts;
1731
+ const dispStyles = multiPage ? totalStylesheets : resourcesCount.stylesheets;
1732
+ const dispImages = multiPage ? totalImages : resourcesCount.images;
1733
+ const dispWords = multiPage ? totalWordCount : (main.wordCount ?? 0);
1734
+ const dispTtfb = multiPage ? avgTtfb : ttfbVal;
1735
+ const ttfbStyleFixed = dispTtfb > 600 ? 'color:var(--yellow)' : '';
1702
1736
  let s = `<div class="crawl-bar"><div class="crawl-bar-top"><span class="crawl-method">GET</span><span class="crawl-url">${escapeHtml(main.finalUrl || main.url)}</span><span class="${statusClass}">${main.statusCode} OK</span></div>`;
1703
1737
  s += `<div class="crawl-bar-bottom">`;
1704
- s += `<div class="crawl-stat"><span class="crawl-stat-label">TTFB</span><span class="crawl-stat-val" style="${ttfbStyle}">${ttfbVal}ms${ttfbVal > 600 ? ' ⚠' : ''}</span></div>`;
1705
- s += `<div class="crawl-stat"><span class="crawl-stat-label">Size</span><span class="crawl-stat-val">${htmlSizeKb} KB</span></div>`;
1738
+ s += `<div class="crawl-stat"><span class="crawl-stat-label">TTFB</span><span class="crawl-stat-val" style="${ttfbStyleFixed}">${dispTtfb}ms${dispTtfb > 600 ? ' ⚠' : ''}</span></div>`;
1739
+ s += `<div class="crawl-stat"><span class="crawl-stat-label">Size</span><span class="crawl-stat-val">${dispHtmlSize} KB</span></div>`;
1706
1740
  s += `<div class="crawl-stat"><span class="crawl-stat-label">Lang</span><span class="crawl-stat-val">${escapeHtml(main.htmlLang || '—')}</span></div>`;
1707
1741
  s += `<div class="crawl-stat"><span class="crawl-stat-label">HTTPS</span><span class="crawl-stat-val" style="color:var(--${main.https ? 'green' : 'text'})">${main.https ? '✓' : '✗'}</span></div>`;
1708
- s += `<div class="crawl-stat"><span class="crawl-stat-label">Scripts</span><span class="crawl-stat-val">${resourcesCount.scripts}</span></div>`;
1709
- s += `<div class="crawl-stat"><span class="crawl-stat-label">Styles</span><span class="crawl-stat-val">${resourcesCount.stylesheets}</span></div>`;
1710
- s += `<div class="crawl-stat"><span class="crawl-stat-label">Images</span><span class="crawl-stat-val">${resourcesCount.images}</span></div>`;
1711
- s += `<div class="crawl-stat"><span class="crawl-stat-label">Words</span><span class="crawl-stat-val">${main.wordCount ?? 0}</span></div>`;
1742
+ s += `<div class="crawl-stat"><span class="crawl-stat-label">Scripts</span><span class="crawl-stat-val">${dispScripts}</span></div>`;
1743
+ s += `<div class="crawl-stat"><span class="crawl-stat-label">Styles</span><span class="crawl-stat-val">${dispStyles}</span></div>`;
1744
+ s += `<div class="crawl-stat"><span class="crawl-stat-label">Images</span><span class="crawl-stat-val">${dispImages}</span></div>`;
1745
+ s += `<div class="crawl-stat"><span class="crawl-stat-label">Words</span><span class="crawl-stat-val">${dispWords}</span></div>`;
1712
1746
  if (extraStats)
1713
1747
  s += extraStats;
1714
1748
  s += `</div></div>`;
@@ -1768,33 +1802,6 @@ ${reportTemplate_1.REPORT_CSS}
1768
1802
  <div class="site-scan-stat"><span class="site-scan-stat-val">${minS}</span><span class="site-scan-stat-lbl">Score mín.</span></div>
1769
1803
  <div class="site-scan-stat"><span class="site-scan-stat-val">${maxS}</span><span class="site-scan-stat-lbl">Score máx.</span></div>
1770
1804
  </div>
1771
- <div class="site-scan-pages">
1772
- <div class="site-scan-page-item site-scan-page-header" style="padding:10px 14px;font-weight:600;color:var(--green);border-bottom:1px solid var(--line);font-family:var(--mono);font-size:0.72rem">01 · Página principal</div>`;
1773
- internalPages.forEach((p, i) => {
1774
- const pathPart = (p.finalUrl || p.url || '').replace(/^https?:\/\/[^/]+/, '') || '/';
1775
- const sc = p.score ?? 0;
1776
- const scoreClass = sc >= 70 ? 'ok' : sc >= 40 ? 'warn' : 'bad';
1777
- const issuesCount = (p.issues?.length ?? 0) + (p.warnings?.length ?? 0);
1778
- block += `
1779
- <details class="site-scan-page-item">
1780
- <summary>
1781
- <span class="site-scan-page-idx">${String(i + 2).padStart(2, '0')}</span>
1782
- <span class="site-scan-page-url" title="${escapeHtml(p.finalUrl || p.url || '')}">${escapeHtml(pathPart)}</span>
1783
- <span class="site-scan-page-score ${scoreClass}">${sc}</span>
1784
- <span class="site-scan-page-meta">${p.statusCode ?? '—'} · ${issuesCount} fallos</span>
1785
- </summary>
1786
- <div class="site-scan-page-detail">
1787
- <div class="metric-row"><span class="metric-k">URL</span><span style="word-break:break-all">${escapeHtml(p.finalUrl || p.url || '')}</span></div>
1788
- <div class="metric-row"><span class="metric-k">Título</span><span>${escapeHtml((p.title || '—').slice(0, 80))}${(p.title || '').length > 80 ? '…' : ''}</span></div>
1789
- <div class="metric-row"><span class="metric-k">Score</span><span class="${scoreClass}">${sc}/100</span></div>
1790
- <div class="metric-row"><span class="metric-k">Críticos</span><span>${(p.problemsBySeverity?.critical?.length ?? 0)}</span></div>
1791
- <div class="metric-row"><span class="metric-k">Importantes</span><span>${(p.problemsBySeverity?.important?.length ?? 0)}</span></div>
1792
- <div class="metric-row"><span class="metric-k">Warnings</span><span>${(p.problemsBySeverity?.warning?.length ?? 0)}</span></div>
1793
- </div>
1794
- </details>`;
1795
- });
1796
- block += `
1797
- </div>
1798
1805
  </div>`;
1799
1806
  return block;
1800
1807
  })() : ''}
@@ -1874,9 +1881,19 @@ ${reportTemplate_1.REPORT_CSS}
1874
1881
  });
1875
1882
  html += `
1876
1883
  <div class="panel" id="panel-fallos-sitio">
1877
- <div class="panel-head">
1878
- <span class="panel-title warn">Fallos por página</span>
1879
- <span class="panel-n">${allPages.length} páginas</span>
1884
+ <div class="panel-head" style="flex-wrap: wrap; gap: 10px;">
1885
+ <div style="display:flex; align-items:center; gap:8px;">
1886
+ <span class="panel-title warn">Fallos por página</span>
1887
+ <span class="panel-n" id="page-count-display">${allPages.length} páginas</span>
1888
+ </div>
1889
+ <div class="page-filters" style="display:flex; gap:6px; font-family:var(--mono); font-size:0.65rem;">
1890
+ <button class="page-filter active" data-filter="all" style="color:var(--text)">All</button>
1891
+ <button class="page-filter" data-filter="crit" style="color:var(--red); border-color:var(--red-d)">Crit</button>
1892
+ <button class="page-filter" data-filter="imp" style="color:var(--orange); border-color:rgba(240,136,62,0.3)">Imp</button>
1893
+ <button class="page-filter" data-filter="warn" style="color:var(--yellow); border-color:var(--yellow-d)">Warn</button>
1894
+ <button class="page-filter" data-filter="info" style="color:var(--blue); border-color:var(--blue-d)">Info</button>
1895
+ <button class="page-filter" data-filter="pass" style="color:var(--green); border-color:var(--green-d)">Pass</button>
1896
+ </div>
1880
1897
  </div>
1881
1898
  <div class="panel-body open fallos-por-pagina">`;
1882
1899
  allPages.forEach(({ label, result: r }) => {
@@ -1889,10 +1906,23 @@ ${reportTemplate_1.REPORT_CSS}
1889
1906
  const totalFallos = rcrit.length + rimp.length + rwarn.length + rinfo.length;
1890
1907
  const sc = r.score ?? 0;
1891
1908
  const scoreCls = sc >= 70 ? 'ok' : sc >= 40 ? 'warn' : 'bad';
1909
+ const totalItems = totalFallos + rpassed.length;
1910
+ const pctCrit = totalItems ? (rcrit.length / totalItems) * 100 : 0;
1911
+ const pctImp = totalItems ? (rimp.length / totalItems) * 100 : 0;
1912
+ const pctWarn = totalItems ? (rwarn.length / totalItems) * 100 : 0;
1913
+ const pctInfo = totalItems ? (rinfo.length / totalItems) * 100 : 0;
1914
+ const pctPass = totalItems ? (rpassed.length / totalItems) * 100 : 0;
1892
1915
  html += `
1893
- <details class="fallos-pagina-details">
1916
+ <details class="fallos-pagina-details" data-crit="${rcrit.length}" data-imp="${rimp.length}" data-warn="${rwarn.length}" data-info="${rinfo.length}" data-pass="${rpassed.length}">
1894
1917
  <summary class="fallos-pagina-summary">
1895
1918
  <span class="fallos-pagina-label">${escapeHtml(label)}</span>
1919
+ <div style="display:flex; height:6px; width:60px; background:var(--bg3); border-radius:3px; overflow:hidden; margin-right:12px;">
1920
+ <div style="width:${pctCrit}%; background:var(--red);"></div>
1921
+ <div style="width:${pctImp}%; background:var(--orange);"></div>
1922
+ <div style="width:${pctWarn}%; background:var(--yellow);"></div>
1923
+ <div style="width:${pctInfo}%; background:var(--blue);"></div>
1924
+ <div style="width:${pctPass}%; background:var(--green);"></div>
1925
+ </div>
1896
1926
  <span class="fallos-pagina-score ${scoreCls}">${sc}</span>
1897
1927
  <span class="fallos-pagina-count">${totalFallos} fallos</span>
1898
1928
  </summary>
@@ -1985,31 +2015,38 @@ ${reportTemplate_1.REPORT_CSS}
1985
2015
  <div class="metric-head">// page_metrics</div>
1986
2016
  <div class="metric-row">
1987
2017
  <span class="metric-k">ttfb</span>
1988
- <div class="metric-v ${ttfbPct > 75 ? 'warn' : ''} ttfb-mini">
1989
- ${main.timeToFirstByteMs ?? 0}ms
1990
- <div class="ttfb-track"><div class="ttfb-fill" style="width:${ttfbPct}%"></div></div>
2018
+ <div class="metric-v ${avgTtfbPct > 75 ? 'warn' : ''} ttfb-mini">
2019
+ ${avgTtfb}ms
2020
+ <div class="ttfb-track"><div class="ttfb-fill" style="width:${avgTtfbPct}%"></div></div>
1991
2021
  </div>
1992
2022
  </div>
1993
- <div class="metric-row"><span class="metric-k">html_size</span><span class="metric-v">${htmlSizeKb} KB</span></div>
1994
- <div class="metric-row"><span class="metric-k">scripts</span><span class="metric-v">${resourcesCount.scripts}</span></div>
1995
- <div class="metric-row"><span class="metric-k">stylesheets</span><span class="metric-v">${resourcesCount.stylesheets}</span></div>`;
1996
- const imageDetails = main.imageDetails ?? [];
1997
- if (imageDetails.length > 0) {
2023
+ <div class="metric-row"><span class="metric-k">html_size</span><span class="metric-v">${totalHtmlSizeKb} KB</span></div>
2024
+ <div class="metric-row"><span class="metric-k">scripts</span><span class="metric-v">${totalScripts}</span></div>
2025
+ <div class="metric-row"><span class="metric-k">stylesheets</span><span class="metric-v">${totalStylesheets}</span></div>`;
2026
+ if (totalImages > 0) {
1998
2027
  html += `
1999
2028
  <details class="metric-accordion">
2000
2029
  <summary class="metric-row metric-row--accordion">
2001
2030
  <span class="metric-k">images</span>
2002
2031
  <div style="display:flex;align-items:center;gap:8px">
2003
- <span class="metric-v">${resourcesCount.images}</span>
2032
+ <span class="metric-v">${totalImages}</span>
2004
2033
  <span class="metric-acc-chevron">›</span>
2005
2034
  </div>
2006
2035
  </summary>
2007
2036
  <div class="metric-acc-body" style="max-height:280px;overflow-y:auto">`;
2008
- imageDetails.forEach((img, i) => {
2009
- const altStr = img.alt != null ? escapeHtml(String(img.alt).slice(0, 80)) : '—';
2010
- const srcShort = img.src ? escapeHtml(img.src.replace(/^https?:\/\/[^/]+/, '').slice(0, 60)) : '—';
2011
- const dims = [img.width, img.height].filter(Boolean).join('×') || '—';
2012
- html += `
2037
+ allPagesList.forEach((pPage) => {
2038
+ const pImgDetails = pPage.imageDetails ?? [];
2039
+ if (pImgDetails.length === 0)
2040
+ return;
2041
+ const pPath = (pPage.finalUrl || pPage.url || '').replace(/^https?:\/\/[^/]+/, '') || '/';
2042
+ if (multiPage) {
2043
+ html += `<div style="padding:6px 14px;font-family:var(--mono);font-size:0.65rem;color:var(--blue);border-bottom:1px solid var(--line);margin-top:4px;background:rgba(255,255,255,0.02)">${escapeHtml(pPath)}</div>`;
2044
+ }
2045
+ pImgDetails.forEach((img, i) => {
2046
+ const altStr = img.alt != null ? escapeHtml(String(img.alt).slice(0, 80)) : '—';
2047
+ const srcShort = img.src ? escapeHtml(img.src.replace(/^https?:\/\/[^/]+/, '').slice(0, 60)) : '—';
2048
+ const dims = [img.width, img.height].filter(Boolean).join('×') || '—';
2049
+ html += `
2013
2050
  <details class="metric-url-row" style="margin:6px 0;padding:8px 10px;background:var(--bg2);border-radius:4px">
2014
2051
  <summary style="cursor:pointer;font-family:var(--mono);font-size:0.72rem">
2015
2052
  <span class="metric-url-idx">${String(i + 1).padStart(2, '0')}</span>
@@ -2022,6 +2059,7 @@ ${reportTemplate_1.REPORT_CSS}
2022
2059
  <div class="metric-row"><span class="metric-k">dimensions</span><span>${escapeHtml(dims)}</span></div>
2023
2060
  </div>
2024
2061
  </details>`;
2062
+ });
2025
2063
  });
2026
2064
  html += `
2027
2065
  </div>
@@ -2029,37 +2067,37 @@ ${reportTemplate_1.REPORT_CSS}
2029
2067
  }
2030
2068
  else {
2031
2069
  html += `
2032
- <div class="metric-row"><span class="metric-k">images</span><span class="metric-v">${resourcesCount.images}</span></div>`;
2070
+ <div class="metric-row"><span class="metric-k">images</span><span class="metric-v">0</span></div>`;
2033
2071
  }
2034
2072
  html += `
2035
- <div class="metric-row"><span class="metric-k">word_count</span><span class="metric-v">${main.wordCount ?? 0}</span></div>`;
2036
- const internalLinkDetails = main.internalLinkDetails ?? [];
2037
- const externalLinkDetails = main.externalLinkDetails ?? [];
2038
- const hasInternalDetails = internalLinkDetails.length > 0;
2039
- const hasExternalDetails = externalLinkDetails.length > 0;
2040
- const internalList = hasInternalDetails ? internalLinkDetails : ((main.internalLinksWithAnchor && main.internalLinksWithAnchor.length > 0) ? main.internalLinksWithAnchor : linksInternalUrls.map((u) => ({ url: u, anchorText: '' })));
2041
- const externalList = hasExternalDetails ? externalLinkDetails : externalLinksSample;
2042
- const externalUrls = externalList.map((e) => (e && typeof e === 'object' && 'url' in e ? e.url : String(e)));
2073
+ <div class="metric-row"><span class="metric-k">word_count</span><span class="metric-v">${totalWordCount}</span></div>`;
2043
2074
  const maxShow = 10;
2044
- if (internalList.length > 0) {
2045
- const rest = internalList.length - maxShow;
2075
+ if (totalLinksInternal > 0) {
2046
2076
  html += `
2047
2077
  <details class="metric-accordion">
2048
2078
  <summary class="metric-row metric-row--accordion">
2049
2079
  <span class="metric-k">links_internal</span>
2050
2080
  <div style="display:flex;align-items:center;gap:8px">
2051
- <span class="metric-v">${main.linksInternal ?? 0}</span>
2081
+ <span class="metric-v">${totalLinksInternal}</span>
2052
2082
  <span class="metric-acc-chevron">›</span>
2053
2083
  </div>
2054
2084
  </summary>
2055
2085
  <div class="metric-acc-body">`;
2056
- internalList.forEach((item, i) => {
2057
- const hidden = i >= maxShow;
2058
- const pathPart = item.url.replace(/^https?:\/\/[^/]+/, '') || '/';
2059
- const hasMeta = hasInternalDetails && 'title' in item;
2060
- if (hasMeta) {
2061
- const linkItem = item;
2062
- html += `<details class="metric-url-row${hidden ? ' metric-url-row--hidden' : ''}" style="margin:4px 0">
2086
+ allPagesList.forEach((pPage) => {
2087
+ const pIntLinks = pPage.internalLinkDetails?.length ? pPage.internalLinkDetails : ((pPage.internalLinksWithAnchor?.length) ? pPage.internalLinksWithAnchor : (pPage.linksInternalUrls || []).map((u) => ({ url: u, anchorText: '' })));
2088
+ if (pIntLinks.length === 0)
2089
+ return;
2090
+ const pPath = (pPage.finalUrl || pPage.url || '').replace(/^https?:\/\/[^/]+/, '') || '/';
2091
+ if (multiPage) {
2092
+ html += `<div style="padding:6px 14px;font-family:var(--mono);font-size:0.65rem;color:var(--blue);border-bottom:1px solid var(--line);margin-top:4px;background:rgba(255,255,255,0.02)">${escapeHtml(pPath)}</div>`;
2093
+ }
2094
+ const hasInternalDetails = pPage.internalLinkDetails?.length > 0;
2095
+ pIntLinks.slice(0, maxShow).forEach((item, i) => {
2096
+ const pathPart = item.url.replace(/^https?:\/\/[^/]+/, '') || '/';
2097
+ const hasMeta = hasInternalDetails && 'title' in item;
2098
+ if (hasMeta) {
2099
+ const linkItem = item;
2100
+ html += `<details class="metric-url-row" style="margin:4px 0">
2063
2101
  <summary style="cursor:pointer;display:flex;align-items:center;gap:6px">
2064
2102
  <span class="metric-url-idx">${String(i + 1).padStart(2, '0')}</span>
2065
2103
  <a class="metric-url" href="${escapeHtml(item.url)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">${escapeHtml(pathPart)}</a>
@@ -2072,36 +2110,44 @@ ${reportTemplate_1.REPORT_CSS}
2072
2110
  <div class="metric-row"><span class="metric-k">meta</span><span>${linkItem.metaDescription ? escapeHtml(linkItem.metaDescription.slice(0, 120)) + (linkItem.metaDescription.length > 120 ? '…' : '') : '—'}</span></div>
2073
2111
  </div>
2074
2112
  </details>`;
2075
- }
2076
- else {
2077
- html += `<div class="metric-url-row${hidden ? ' metric-url-row--hidden' : ''}"><span class="metric-url-idx">${String(i + 1).padStart(2, '0')}</span><a class="metric-url" href="${escapeHtml(item.url)}" target="_blank" rel="noopener">${escapeHtml(pathPart)}</a><span class="metric-url-type">int</span></div>`;
2078
- }
2113
+ }
2114
+ else {
2115
+ html += `<div class="metric-url-row"><span class="metric-url-idx">${String(i + 1).padStart(2, '0')}</span><a class="metric-url" href="${escapeHtml(item.url)}" target="_blank" rel="noopener">${escapeHtml(pathPart)}</a><span class="metric-url-type">int</span></div>`;
2116
+ }
2117
+ });
2118
+ if (pIntLinks.length > maxShow)
2119
+ html += `<div class="metric-url-row metric-url-row--more" role="button" tabindex="0">+ ${pIntLinks.length - maxShow} más en esta pág.</div>`;
2079
2120
  });
2080
- if (rest > 0)
2081
- html += `<div class="metric-url-row metric-url-row--more" role="button" tabindex="0">ver todos</div>`;
2082
2121
  html += `
2083
2122
  </div>
2084
2123
  </details>`;
2085
2124
  }
2086
- if (externalUrls.length > 0) {
2087
- const rest = externalUrls.length - maxShow;
2125
+ if (totalLinksExternal > 0) {
2088
2126
  html += `
2089
2127
  <details class="metric-accordion">
2090
2128
  <summary class="metric-row metric-row--accordion">
2091
2129
  <span class="metric-k">links_external</span>
2092
2130
  <div style="display:flex;align-items:center;gap:8px">
2093
- <span class="metric-v">${main.linksExternal ?? 0}</span>
2131
+ <span class="metric-v">${totalLinksExternal}</span>
2094
2132
  <span class="metric-acc-chevron">›</span>
2095
2133
  </div>
2096
2134
  </summary>
2097
2135
  <div class="metric-acc-body">`;
2098
- externalList.forEach((item, i) => {
2099
- const hidden = i >= maxShow;
2100
- const hasMeta = hasExternalDetails && 'title' in item;
2101
- if (hasMeta) {
2102
- const linkItem = item;
2103
- const urlShort = linkItem.url.length > 50 ? linkItem.url.slice(0, 50) + '…' : linkItem.url;
2104
- html += `<details class="metric-url-row${hidden ? ' metric-url-row--hidden' : ''}" style="margin:4px 0">
2136
+ allPagesList.forEach((pPage) => {
2137
+ const pExtLinks = pPage.externalLinkDetails?.length ? pPage.externalLinkDetails : (pPage.externalLinksSample || []);
2138
+ if (pExtLinks.length === 0)
2139
+ return;
2140
+ const pPath = (pPage.finalUrl || pPage.url || '').replace(/^https?:\/\/[^/]+/, '') || '/';
2141
+ if (multiPage) {
2142
+ html += `<div style="padding:6px 14px;font-family:var(--mono);font-size:0.65rem;color:var(--blue);border-bottom:1px solid var(--line);margin-top:4px;background:rgba(255,255,255,0.02)">${escapeHtml(pPath)}</div>`;
2143
+ }
2144
+ const hasExternalDetails = pPage.externalLinkDetails?.length > 0;
2145
+ pExtLinks.slice(0, maxShow).forEach((item, i) => {
2146
+ const hasMeta = hasExternalDetails && 'title' in item;
2147
+ if (hasMeta) {
2148
+ const linkItem = item;
2149
+ const urlShort = linkItem.url.length > 50 ? linkItem.url.slice(0, 50) + '…' : linkItem.url;
2150
+ html += `<details class="metric-url-row" style="margin:4px 0">
2105
2151
  <summary style="cursor:pointer;display:flex;align-items:center;gap:6px;flex-wrap:wrap">
2106
2152
  <span class="metric-url-idx">${String(i + 1).padStart(2, '0')}</span>
2107
2153
  <a class="metric-url" href="${escapeHtml(linkItem.url)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">${escapeHtml(urlShort)}</a>
@@ -2114,14 +2160,15 @@ ${reportTemplate_1.REPORT_CSS}
2114
2160
  <div class="metric-row"><span class="metric-k">meta</span><span>${linkItem.metaDescription ? escapeHtml(linkItem.metaDescription.slice(0, 120)) + (linkItem.metaDescription.length > 120 ? '…' : '') : '—'}</span></div>
2115
2161
  </div>
2116
2162
  </details>`;
2117
- }
2118
- else {
2119
- const u = typeof item === 'object' && item && 'url' in item ? item.url : String(item);
2120
- html += `<div class="metric-url-row${hidden ? ' metric-url-row--hidden' : ''}"><span class="metric-url-idx">${String(i + 1).padStart(2, '0')}</span><a class="metric-url" href="${escapeHtml(u)}" target="_blank" rel="noopener">${escapeHtml(String(u).length > 50 ? String(u).slice(0, 50) + '…' : u)}</a><span class="metric-url-type">ext</span></div>`;
2121
- }
2163
+ }
2164
+ else {
2165
+ const u = typeof item === 'object' && item && 'url' in item ? item.url : String(item);
2166
+ html += `<div class="metric-url-row"><span class="metric-url-idx">${String(i + 1).padStart(2, '0')}</span><a class="metric-url" href="${escapeHtml(u)}" target="_blank" rel="noopener">${escapeHtml(String(u).length > 50 ? String(u).slice(0, 50) + '…' : u)}</a><span class="metric-url-type">ext</span></div>`;
2167
+ }
2168
+ });
2169
+ if (pExtLinks.length > maxShow)
2170
+ html += `<div class="metric-url-row metric-url-row--more" role="button" tabindex="0">+ ${pExtLinks.length - maxShow} más en esta pág.</div>`;
2122
2171
  });
2123
- if (rest > 0)
2124
- html += `<div class="metric-url-row metric-url-row--more" role="button" tabindex="0">ver todos</div>`;
2125
2172
  html += `
2126
2173
  </div>
2127
2174
  </details>`;
@@ -2162,26 +2209,115 @@ ${reportTemplate_1.REPORT_CSS}
2162
2209
  </div>
2163
2210
  </div>
2164
2211
  </div><!-- /tab-overview -->`;
2212
+ let aggCrit = new Map();
2213
+ let aggImp = new Map();
2214
+ let aggWarn = new Map();
2215
+ let aggInfo = new Map();
2216
+ let aggPass = new Map();
2217
+ const normalizeMessage = (msg) => {
2218
+ let m = msg;
2219
+ m = m.replace(/\(\d+ms\)/, '');
2220
+ m = m.replace(/^\d+\s+/, '');
2221
+ m = m.replace(/\(\d+\s+caracteres\)/, '');
2222
+ m = m.replace(/en \d+ página\(s\)/, '');
2223
+ return m.trim();
2224
+ };
2225
+ allPagesList.forEach(p => {
2226
+ const u = p.finalUrl || p.url || '';
2227
+ const addIssue = (map, item) => {
2228
+ const key = normalizeMessage(item.message);
2229
+ if (!map.has(key))
2230
+ map.set(key, { ...item, urls: [], originalMessages: new Set() });
2231
+ const existing = map.get(key);
2232
+ if (!existing.urls.includes(u))
2233
+ existing.urls.push(u);
2234
+ existing.originalMessages.add(item.message);
2235
+ };
2236
+ const pb = p.problemsBySeverity ?? { critical: [], important: [], warning: [], info: [] };
2237
+ (Array.isArray(pb.critical) ? pb.critical : []).forEach(it => addIssue(aggCrit, it));
2238
+ (Array.isArray(pb.important) ? pb.important : []).forEach(it => addIssue(aggImp, it));
2239
+ (Array.isArray(pb.warning) ? pb.warning : []).forEach(it => addIssue(aggWarn, it));
2240
+ (Array.isArray(pb.info) ? pb.info : []).forEach(it => addIssue(aggInfo, it));
2241
+ (p.passed ?? []).forEach(msg => {
2242
+ const key = normalizeMessage(msg);
2243
+ if (!aggPass.has(key))
2244
+ aggPass.set(key, { message: msg, urls: [] });
2245
+ const existing = aggPass.get(key);
2246
+ if (!existing.urls.includes(u))
2247
+ existing.urls.push(u);
2248
+ });
2249
+ });
2250
+ const formatAggMessage = (key, item) => {
2251
+ if (item.originalMessages && item.originalMessages.size > 1) {
2252
+ return `${key} (múltiples valores detectados)`;
2253
+ }
2254
+ return item.message;
2255
+ };
2256
+ const totalIssues = aggCrit.size + aggImp.size + aggWarn.size + aggInfo.size + aggPass.size;
2165
2257
  html += `
2166
2258
  <!-- TAB: ISSUES -->
2167
2259
  <div class="tab-section" id="tab-issues" style="display:none;max-width:1280px;margin:0 auto;padding:20px 20px 60px">
2168
- ${crawlBarSnippet(`<div class="crawl-stat"><span class="crawl-stat-label">Warnings</span><span class="crawl-stat-val" style="color:var(--yellow)">${severitySummary.warning}</span></div><div class="crawl-stat"><span class="crawl-stat-label">Passed</span><span class="crawl-stat-val" style="color:var(--green)">${passed.length}</span></div><div class="crawl-stat"><span class="crawl-stat-label">Critical</span><span class="crawl-stat-val" style="color:var(--text3)">${severitySummary.critical}</span></div>`)}
2260
+ ${crawlBarSnippet(`<div class="crawl-stat"><span class="crawl-stat-label">Warnings</span><span class="crawl-stat-val" style="color:var(--yellow)">${aggWarn.size}</span></div><div class="crawl-stat"><span class="crawl-stat-label">Passed</span><span class="crawl-stat-val" style="color:var(--green)">${aggPass.size}</span></div><div class="crawl-stat"><span class="crawl-stat-label">Critical</span><span class="crawl-stat-val" style="color:var(--text3)">${aggCrit.size}</span></div>`)}
2169
2261
  <div class="panel">
2170
- <div class="panel-head"><span class="panel-title warn">All Issues</span><span class="panel-n">${crit.length + imp.length + warn.length + info.length + passed.length} total</span></div>`;
2171
- crit.forEach((it) => { html += renderIssueRow(it, 'crit', 'tag-crit', 'CRIT'); });
2172
- imp.forEach((it) => { html += renderIssueRow(it, 'warn', 'tag-warn', 'IMP'); });
2173
- warn.forEach((it) => { html += renderIssueRow(it, 'warn', 'tag-warn', 'WARN'); });
2174
- info.forEach((it) => { html += renderIssueRow(it, 'warn', 'tag-warn', 'INFO'); });
2175
- passed.forEach((p) => { html += `<div class="issue-row pass"><details><summary><span class="issue-tag tag-pass">PASS</span><span>${escapeHtml(p)}</span><span class="issue-chevron">›</span></summary></details></div>`; });
2262
+ <div class="panel-head"><span class="panel-title warn">All Issues</span><span class="panel-n">${totalIssues} total</span></div>`;
2263
+ Array.from(aggCrit.entries()).forEach(([k, it]) => { html += renderIssueRow({ ...it, message: formatAggMessage(k, it) }, 'crit', 'tag-crit', 'CRIT'); });
2264
+ Array.from(aggImp.entries()).forEach(([k, it]) => { html += renderIssueRow({ ...it, message: formatAggMessage(k, it) }, 'warn', 'tag-warn', 'IMP'); });
2265
+ Array.from(aggWarn.entries()).forEach(([k, it]) => { html += renderIssueRow({ ...it, message: formatAggMessage(k, it) }, 'warn', 'tag-warn', 'WARN'); });
2266
+ Array.from(aggInfo.entries()).forEach(([k, it]) => { html += renderIssueRow({ ...it, message: formatAggMessage(k, it) }, 'warn', 'tag-warn', 'INFO'); });
2267
+ Array.from(aggPass.values()).forEach((p) => {
2268
+ html += `<div class="issue-row pass"><details><summary><span class="issue-tag tag-pass">PASS</span><span>${escapeHtml(p.message)}</span>`;
2269
+ if (p.urls.length > 1) {
2270
+ html += `<span style="margin-left:8px;font-size:0.65rem;color:var(--text3);background:var(--bg3);padding:2px 6px;border-radius:10px;">${p.urls.length} págs</span>`;
2271
+ }
2272
+ html += `<span class="issue-chevron">›</span></summary>`;
2273
+ if (p.urls.length > 0) {
2274
+ html += `<div class="issue-detail"><div style="margin-top:2px;font-family:var(--mono);font-size:0.65rem;color:var(--text3);"><div style="margin-bottom:6px;text-transform:uppercase;letter-spacing:0.05em;">Afecta a los siguientes sitios:</div>`;
2275
+ html += `<div style="max-height:150px; overflow-y:auto; padding-right:6px;">`;
2276
+ p.urls.forEach(u => {
2277
+ html += `<div style="padding:4px 6px;margin-bottom:2px;background:rgba(255,255,255,0.02);border-radius:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--blue);"><a href="${escapeHtml(u)}" target="_blank" rel="noopener" style="color:inherit;text-decoration:none;">${escapeHtml(u.replace(/^https?:\/\/[^/]+/, '') || '/')}</a></div>`;
2278
+ });
2279
+ html += `</div></div></div>`;
2280
+ }
2281
+ html += `</details></div>`;
2282
+ });
2176
2283
  html += `
2177
2284
  </div>
2178
2285
  </div>`;
2179
- const ratioIntExt = (main.linksInternal ?? 0) > 0 && (main.linksExternal ?? 0) > 0 ? `${Math.round((main.linksInternal ?? 0) / (main.linksExternal ?? 1))}:1` : '—';
2286
+ const ratioIntExt = totalLinksInternal > 0 && totalLinksExternal > 0 ? `${Math.round(totalLinksInternal / totalLinksExternal)}:1` : '—';
2287
+ const statusCounts = { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0, 'other': 0 };
2288
+ const seenUrlsForStatus = new Set();
2289
+ const addStatus = (u, code) => {
2290
+ if (!u || !code || seenUrlsForStatus.has(u))
2291
+ return;
2292
+ seenUrlsForStatus.add(u);
2293
+ if (code >= 200 && code < 300)
2294
+ statusCounts['2xx']++;
2295
+ else if (code >= 300 && code < 400)
2296
+ statusCounts['3xx']++;
2297
+ else if (code >= 400 && code < 500)
2298
+ statusCounts['4xx']++;
2299
+ else if (code >= 500 && code < 600)
2300
+ statusCounts['5xx']++;
2301
+ else
2302
+ statusCounts['other']++;
2303
+ };
2304
+ allPagesList.forEach(p => {
2305
+ addStatus(p.finalUrl || p.url, p.statusCode);
2306
+ (p.internalLinkDetails || []).forEach(l => addStatus(l.url, l.statusCode));
2307
+ (p.externalLinkDetails || []).forEach(l => addStatus(l.url, l.statusCode));
2308
+ });
2180
2309
  html += `
2181
2310
  <!-- TAB: CRAWL -->
2182
2311
  <div class="tab-section" id="tab-crawl" style="display:none;max-width:1280px;margin:0 auto;padding:20px 20px 60px">
2183
- ${crawlBarSnippet(`<div class="crawl-stat"><span class="crawl-stat-label">Crawled</span><span class="crawl-stat-val">${date.slice(0, 10)}</span></div><div class="crawl-stat"><span class="crawl-stat-label">Links int.</span><span class="crawl-stat-val">${main.linksInternal ?? 0}</span></div><div class="crawl-stat"><span class="crawl-stat-label">Links ext.</span><span class="crawl-stat-val">${main.linksExternal ?? 0}</span></div><div class="crawl-stat"><span class="crawl-stat-label">Broken</span><span class="crawl-stat-val" style="color:var(--green)">${brokenLinks.length}</span></div>`)}
2312
+ ${crawlBarSnippet(`<div class="crawl-stat"><span class="crawl-stat-label">Crawled</span><span class="crawl-stat-val">${date.slice(0, 10)}</span></div><div class="crawl-stat"><span class="crawl-stat-label">Links int.</span><span class="crawl-stat-val">${totalLinksInternal}</span></div><div class="crawl-stat"><span class="crawl-stat-label">Links ext.</span><span class="crawl-stat-val">${totalLinksExternal}</span></div><div class="crawl-stat"><span class="crawl-stat-label">Broken</span><span class="crawl-stat-val" style="color:var(--green)">${brokenLinks.length}</span></div>`)}
2184
2313
  <div class="two-col-grid">
2314
+ <div class="panel">
2315
+ <div class="panel-head"><span class="panel-title neutral">// server &amp; network</span></div>
2316
+ <div class="metric-row"><span class="metric-k">Host</span><span class="metric-v">${escapeHtml(host)}</span></div>
2317
+ <div class="metric-row"><span class="metric-k">IP</span><span class="metric-v">${hostIp ? escapeHtml(hostIp) : '—'}</span></div>
2318
+ <div class="metric-row"><span class="metric-k">Server</span><span class="metric-v">${responseHeaders['server'] ? escapeHtml(responseHeaders['server']) : '—'}</span></div>
2319
+ <div class="metric-row"><span class="metric-k">X-Powered-By</span><span class="metric-v">${responseHeaders['x-powered-by'] ? escapeHtml(responseHeaders['x-powered-by']) : '—'}</span></div>
2320
+ </div>
2185
2321
  <div class="panel">
2186
2322
  <div class="panel-head"><span class="panel-title neutral">// request_headers</span></div>
2187
2323
  <div style="padding:14px;font-family:var(--mono);font-size:0.72rem;line-height:2.2">
@@ -2197,37 +2333,38 @@ ${reportTemplate_1.REPORT_CSS}
2197
2333
  <div style="padding:14px;font-family:var(--mono);font-size:0.72rem;line-height:2.2">
2198
2334
  <div><span style="color:var(--green)">HTTP/1.1 ${main.statusCode} OK</span></div>
2199
2335
  <div><span style="color:var(--text3)">Content-Type: </span><span style="color:var(--text2)">text/html; charset=UTF-8</span></div>
2200
- <div><span style="color:var(--text3)">Content-Length: </span><span style="color:var(--text2)">${main.htmlSizeBytes ?? 0}</span></div>
2336
+ <div><span style="color:var(--text3)">Content-Length: </span><span style="color:var(--text2)">${totalHtmlSizeKb} KB</span></div>
2201
2337
  </div>
2202
2338
  </div>
2203
2339
  <div class="panel">
2204
2340
  <div class="panel-head"><span class="panel-title neutral">// page_resources</span></div>
2205
- <div class="metric-row"><span class="metric-k">Scripts</span><span class="metric-v">${resourcesCount.scripts}</span></div>
2206
- <div class="metric-row"><span class="metric-k">Stylesheets</span><span class="metric-v">${resourcesCount.stylesheets}</span></div>
2207
- <div class="metric-row"><span class="metric-k">Images</span><span class="metric-v">${resourcesCount.images}</span></div>
2341
+ <div class="metric-row"><span class="metric-k">Scripts</span><span class="metric-v">${totalScripts}</span></div>
2342
+ <div class="metric-row"><span class="metric-k">Stylesheets</span><span class="metric-v">${totalStylesheets}</span></div>
2343
+ <div class="metric-row"><span class="metric-k">Images</span><span class="metric-v">${totalImages}</span></div>
2208
2344
  <div class="metric-row"><span class="metric-k">Fuentes externas</span><span class="metric-v">0</span></div>
2209
2345
  <div class="metric-row"><span class="metric-k">iFrames</span><span class="metric-v">0</span></div>
2210
2346
  </div>
2211
2347
  <div class="panel">
2212
2348
  <div class="panel-head"><span class="panel-title neutral">// link_analysis</span></div>
2213
- <div class="metric-row"><span class="metric-k">Links internos</span><span class="metric-v">${main.linksInternal ?? 0}</span></div>
2214
- <div class="metric-row"><span class="metric-k">Links externos</span><span class="metric-v">${main.linksExternal ?? 0}</span></div>
2349
+ <div class="metric-row"><span class="metric-k">Links internos</span><span class="metric-v">${totalLinksInternal}</span></div>
2350
+ <div class="metric-row"><span class="metric-k">Links externos</span><span class="metric-v">${totalLinksExternal}</span></div>
2215
2351
  <div class="metric-row"><span class="metric-k">Links rotos</span><span class="metric-v ok">${brokenLinks.length}</span></div>
2216
- <div class="metric-row"><span class="metric-k">Nofollow</span><span class="metric-v">${main.linksNofollow ?? 0}</span></div>
2352
+ <div class="metric-row"><span class="metric-k">Nofollow</span><span class="metric-v">${allPagesList.reduce((acc, p) => acc + (p.linksNofollow ?? 0), 0)}</span></div>
2217
2353
  <div class="metric-row"><span class="metric-k">Ratio int/ext</span><span class="metric-v">${ratioIntExt}</span></div>
2218
2354
  </div>
2355
+ <div class="panel">
2356
+ <div class="panel-head"><span class="panel-title neutral">// status_codes</span></div>
2357
+ <div class="metric-row"><span class="metric-k">2xx (OK)</span><span class="metric-v ${statusCounts['2xx'] > 0 ? 'ok' : ''}">${statusCounts['2xx']} páginas</span></div>
2358
+ <div class="metric-row"><span class="metric-k">3xx (Redirects)</span><span class="metric-v">${statusCounts['3xx']} páginas</span></div>
2359
+ <div class="metric-row"><span class="metric-k">4xx (Client Errors)</span><span class="metric-v ${statusCounts['4xx'] > 0 ? 'bad' : ''}">${statusCounts['4xx']} páginas</span></div>
2360
+ <div class="metric-row"><span class="metric-k">5xx (Server Errors)</span><span class="metric-v ${statusCounts['5xx'] > 0 ? 'bad' : ''}">${statusCounts['5xx']} páginas</span></div>
2361
+ ${statusCounts['other'] > 0 ? `<div class="metric-row"><span class="metric-k">Otros / Desconocido</span><span class="metric-v">${statusCounts['other']} páginas</span></div>` : ''}
2362
+ </div>
2219
2363
  <div class="panel">
2220
2364
  <div class="panel-head"><span class="panel-title neutral">// cms</span></div>
2221
2365
  <div class="metric-row"><span class="metric-k">CMS</span><span class="metric-v">${cmsDetected ? escapeHtml(cmsDetected) : '—'}</span></div>
2222
2366
  <div class="metric-row"><span class="metric-k">Versión</span><span class="metric-v">${cmsVersion ? escapeHtml(cmsVersion) : '—'}</span></div>
2223
2367
  </div>
2224
- <div class="panel">
2225
- <div class="panel-head"><span class="panel-title neutral">// server &amp; network</span></div>
2226
- <div class="metric-row"><span class="metric-k">Host</span><span class="metric-v">${escapeHtml(host)}</span></div>
2227
- <div class="metric-row"><span class="metric-k">IP</span><span class="metric-v">${hostIp ? escapeHtml(hostIp) : '—'}</span></div>
2228
- <div class="metric-row"><span class="metric-k">Server</span><span class="metric-v">${responseHeaders['server'] ? escapeHtml(responseHeaders['server']) : '—'}</span></div>
2229
- <div class="metric-row"><span class="metric-k">X-Powered-By</span><span class="metric-v">${responseHeaders['x-powered-by'] ? escapeHtml(responseHeaders['x-powered-by']) : '—'}</span></div>
2230
- </div>
2231
2368
  <div class="panel">
2232
2369
  <div class="panel-head"><span class="panel-title neutral">// hreflang</span></div>
2233
2370
  <div class="metric-row"><span class="metric-k">Lang página</span><span class="metric-v">${main.htmlLang ? escapeHtml(main.htmlLang) : '—'}</span></div>
@@ -2472,6 +2609,33 @@ ${reportTemplate_1.REPORT_CSS}
2472
2609
  });
2473
2610
  });
2474
2611
 
2612
+ // Page filtering in "Fallos por página"
2613
+ var pageFilters = document.querySelectorAll('.page-filter');
2614
+ pageFilters.forEach(function(btn) {
2615
+ btn.addEventListener('click', function() {
2616
+ var filter = this.dataset.filter;
2617
+ pageFilters.forEach(function(b) { b.classList.remove('active'); });
2618
+ this.classList.add('active');
2619
+
2620
+ var count = 0;
2621
+ document.querySelectorAll('.fallos-pagina-details').forEach(function(row) {
2622
+ var show = false;
2623
+ if (filter === 'all') show = true;
2624
+ else if (filter === 'crit' && parseInt(row.dataset.crit || '0') > 0) show = true;
2625
+ else if (filter === 'imp' && parseInt(row.dataset.imp || '0') > 0) show = true;
2626
+ else if (filter === 'warn' && parseInt(row.dataset.warn || '0') > 0) show = true;
2627
+ else if (filter === 'info' && parseInt(row.dataset.info || '0') > 0) show = true;
2628
+ else if (filter === 'pass' && parseInt(row.dataset.pass || '0') > 0) show = true;
2629
+
2630
+ row.style.display = show ? 'block' : 'none';
2631
+ if (show) count++;
2632
+ });
2633
+
2634
+ var display = document.getElementById('page-count-display');
2635
+ if (display) display.textContent = count + ' páginas';
2636
+ });
2637
+ });
2638
+
2475
2639
  // Tab navigation
2476
2640
  var tabIds = ['overview', 'issues', 'crawl', 'performance'];
2477
2641
  document.querySelectorAll('.tab[data-tab]').forEach(function (tab) {
@@ -2773,30 +2937,11 @@ class SeoScanner {
2773
2937
  mainResult.crawlExtras = undefined;
2774
2938
  }
2775
2939
  const isHttp = (u) => /^https?:\/\//i.test(u);
2776
- if (analyzeOpts.checkBrokenLinks) {
2777
- const maxCheck = analyzeOpts.maxBrokenLinksToCheck ?? 15;
2940
+ const runBrokenLinkCheck = async (sources) => {
2778
2941
  const ua = fetchOpts.userAgent || USER_AGENT;
2779
- const toCheck = [];
2780
- if (mainResult.internalLinksWithAnchor.length > 0) {
2781
- for (const e of mainResult.internalLinksWithAnchor) {
2782
- if (toCheck.length >= maxCheck || !isHttp(e.url))
2783
- continue;
2784
- toCheck.push({ url: e.url, anchorText: e.anchorText || '', internal: true });
2785
- }
2786
- }
2787
- else {
2788
- for (const u of mainResult.linksInternalUrls) {
2789
- if (toCheck.length >= maxCheck || !isHttp(u))
2790
- continue;
2791
- toCheck.push({ url: u, anchorText: '', internal: true });
2792
- }
2793
- }
2794
- for (const e of mainResult.externalLinksSample) {
2795
- if (toCheck.length >= maxCheck || !isHttp(e.url))
2942
+ for (const item of sources) {
2943
+ if (!isHttp(item.url))
2796
2944
  continue;
2797
- toCheck.push({ url: e.url, anchorText: e.anchorText || '', internal: false });
2798
- }
2799
- for (const item of toCheck) {
2800
2945
  const { text, statusCode: linkStatus } = await fetchText(item.url, 8000, ua);
2801
2946
  const detail = {
2802
2947
  url: item.url,
@@ -2833,6 +2978,30 @@ class SeoScanner {
2833
2978
  mainResult.issues.push(`${mainResult.brokenLinks.length} enlace(s) roto(s)`);
2834
2979
  mainResult.severitySummary.important = mainResult.problemsBySeverity.important.length;
2835
2980
  }
2981
+ };
2982
+ if (analyzeOpts.checkBrokenLinks && !scanInternalLinks) {
2983
+ const maxCheck = analyzeOpts.maxBrokenLinksToCheck ?? 15;
2984
+ const toCheck = [];
2985
+ if (mainResult.internalLinksWithAnchor.length > 0) {
2986
+ for (const e of mainResult.internalLinksWithAnchor) {
2987
+ if (toCheck.length >= maxCheck || !isHttp(e.url))
2988
+ continue;
2989
+ toCheck.push({ url: e.url, anchorText: e.anchorText || '', internal: true });
2990
+ }
2991
+ }
2992
+ else {
2993
+ for (const u of mainResult.linksInternalUrls) {
2994
+ if (toCheck.length >= maxCheck || !isHttp(u))
2995
+ continue;
2996
+ toCheck.push({ url: u, anchorText: '', internal: true });
2997
+ }
2998
+ }
2999
+ for (const e of mainResult.externalLinksSample) {
3000
+ if (toCheck.length >= maxCheck || !isHttp(e.url))
3001
+ continue;
3002
+ toCheck.push({ url: e.url, anchorText: e.anchorText || '', internal: false });
3003
+ }
3004
+ await runBrokenLinkCheck(toCheck);
2836
3005
  }
2837
3006
  try {
2838
3007
  const robotsSitemap = await checkRobotsAndSitemap(mainResult.finalUrl, Math.min(10000, timeoutMs), fetchOpts.userAgent || USER_AGENT);
@@ -2927,6 +3096,53 @@ class SeoScanner {
2927
3096
  output.internalPages = internalResults;
2928
3097
  output.scannedUrls = 1 + internalResults.length;
2929
3098
  output.message = `Escaneadas ${output.scannedUrls} páginas (1 principal + ${internalResults.length} enlaces internos).`;
3099
+ if (analyzeOpts.checkBrokenLinks) {
3100
+ const maxSiteCheck = Math.min(100, Math.max(analyzeOpts.maxBrokenLinksToCheck ?? 15, 30));
3101
+ const seenInternal = new Set();
3102
+ const seenExternal = new Set();
3103
+ const toCheckSite = [];
3104
+ const addInternal = (url, anchor) => {
3105
+ const norm = normalizeUrlForDedupe(url);
3106
+ if (!seenInternal.has(norm) && isHttp(url)) {
3107
+ seenInternal.add(norm);
3108
+ if (toCheckSite.length < maxSiteCheck)
3109
+ toCheckSite.push({ url, anchorText: anchor, internal: true });
3110
+ }
3111
+ };
3112
+ const addExternal = (url, anchor) => {
3113
+ const norm = url.toLowerCase();
3114
+ if (!seenExternal.has(norm) && isHttp(url)) {
3115
+ seenExternal.add(norm);
3116
+ if (toCheckSite.length < maxSiteCheck)
3117
+ toCheckSite.push({ url, anchorText: anchor, internal: false });
3118
+ }
3119
+ };
3120
+ if (mainResult.internalLinksWithAnchor.length > 0) {
3121
+ for (const e of mainResult.internalLinksWithAnchor)
3122
+ addInternal(e.url, e.anchorText || '');
3123
+ }
3124
+ else {
3125
+ for (const u of mainResult.linksInternalUrls)
3126
+ addInternal(u, '');
3127
+ }
3128
+ for (const e of mainResult.externalLinksSample)
3129
+ addExternal(e.url, e.anchorText || '');
3130
+ for (const r of internalResults) {
3131
+ if (toCheckSite.length >= maxSiteCheck)
3132
+ break;
3133
+ if (r.internalLinksWithAnchor?.length) {
3134
+ for (const e of r.internalLinksWithAnchor)
3135
+ addInternal(e.url, e.anchorText || '');
3136
+ }
3137
+ else {
3138
+ for (const u of r.linksInternalUrls ?? [])
3139
+ addInternal(u, '');
3140
+ }
3141
+ for (const e of r.externalLinksSample ?? [])
3142
+ addExternal(e.url, e.anchorText || '');
3143
+ }
3144
+ await runBrokenLinkCheck(toCheckSite);
3145
+ }
2930
3146
  const scores = internalResults.map((r) => r.score).filter((s) => s > 0);
2931
3147
  const avgScore = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : 0;
2932
3148
  output.mainPageScore = mainResult.score;