@rankcli/agent-runtime 0.0.37 → 0.0.39

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/index.mjs CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  httpHead,
16
16
  httpPost,
17
17
  looksLikeHtmlFallback
18
- } from "./chunk-O7B2LBKG.mjs";
18
+ } from "./chunk-ZLQQXIZ3.mjs";
19
19
  import {
20
20
  AI_CRAWLERS_INFO,
21
21
  analyzeCitationReadiness,
@@ -2824,7 +2824,7 @@ var ISSUE_DEFINITIONS = {
2824
2824
  severity: "warning",
2825
2825
  category: "performance",
2826
2826
  title: "HTML document too large",
2827
- description: "HTML size exceeds 100KB.",
2827
+ description: "HTML size exceeds 300KB.",
2828
2828
  impact: "Increases load time and memory usage.",
2829
2829
  howToFix: "Reduce inline CSS/JS, remove unnecessary HTML."
2830
2830
  },
@@ -5559,7 +5559,7 @@ async function analyzePerformance(url) {
5559
5559
  details: { ttfb: `${ttfbTime}ms` }
5560
5560
  });
5561
5561
  }
5562
- if (htmlSize > 100 * 1024) {
5562
+ if (htmlSize > 300 * 1024) {
5563
5563
  issues.push({
5564
5564
  ...ISSUE_DEFINITIONS.HTML_TOO_LARGE,
5565
5565
  affectedUrls: [url],
@@ -6459,7 +6459,9 @@ function analyzeAnchorText(html, url) {
6459
6459
  } catch {
6460
6460
  internalLinks++;
6461
6461
  }
6462
- const hasAccessibleText = text.length > 0 || ariaLabel || title;
6462
+ const imgAlt = $(el).find("img[alt]").map((_2, img) => ($(img).attr("alt") || "").trim()).get().join(" ").trim();
6463
+ const svgTitle = $(el).find("svg title").text().trim();
6464
+ const hasAccessibleText = text.length > 0 || ariaLabel || title || imgAlt.length > 0 || svgTitle.length > 0;
6463
6465
  if (!hasAccessibleText) {
6464
6466
  emptyAnchors++;
6465
6467
  if (emptyAnchorUrls.length < 5) {
@@ -7715,7 +7717,7 @@ function analyzeLocalSEO(html, url) {
7715
7717
  const detectedPhones = detectPhones(html);
7716
7718
  const detectedAddresses = detectAddresses(html);
7717
7719
  const socialProfiles = detectSocialProfiles(html);
7718
- if (!hasLocalBusiness) {
7720
+ if (!hasLocalBusiness && (detectedPhones.length > 0 || detectedAddresses.length > 0)) {
7719
7721
  issues.push({
7720
7722
  ...ISSUE_DEFINITIONS.LOCAL_BUSINESS_SCHEMA_MISSING,
7721
7723
  affectedUrls: [url]
@@ -8776,7 +8778,17 @@ function analyzeKeywords(html, url) {
8776
8778
  const sortPhrases = (phrases, n) => {
8777
8779
  return Array.from(phrases).filter(([_, count]) => count >= 2).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([phrase, count]) => ({ phrase, count, wordCount: n }));
8778
8780
  };
8779
- const potentialStuffing = topKeywords.filter((k) => k.density > 3).map((k) => k.word);
8781
+ const brandLabels = /* @__PURE__ */ new Set();
8782
+ try {
8783
+ const host = new URL(url).hostname.toLowerCase().replace(/^www\./, "");
8784
+ for (const label of host.split(".").slice(0, -1)) brandLabels.add(label);
8785
+ } catch {
8786
+ }
8787
+ for (const name of [$('meta[property="og:site_name"]').attr("content"), $('meta[name="application-name"]').attr("content")]) {
8788
+ for (const token of tokenize(name || "")) brandLabels.add(token);
8789
+ }
8790
+ const isBrandWord = (word) => [...brandLabels].some((label) => label.includes(word) || word.includes(label));
8791
+ const potentialStuffing = totalWords >= 200 ? topKeywords.filter((k) => k.density > 4 && !isBrandWord(k.word)).map((k) => k.word) : [];
8780
8792
  if (potentialStuffing.length > 0) {
8781
8793
  issues.push({
8782
8794
  ...ISSUE_DEFINITIONS.KEYWORD_STUFFING,
@@ -9002,13 +9014,6 @@ function checkAMP(html, url) {
9002
9014
  }
9003
9015
  };
9004
9016
  }
9005
- const isArticle = $("article").length > 0 || $('meta[property="og:type"][content="article"]').length > 0 || html.toLowerCase().includes("blog") || html.toLowerCase().includes("news");
9006
- if (isArticle) {
9007
- issues.push({
9008
- ...ISSUE_DEFINITIONS.AMP_MISSING,
9009
- affectedUrls: [url]
9010
- });
9011
- }
9012
9017
  return {
9013
9018
  issues,
9014
9019
  data: {
@@ -9717,13 +9722,13 @@ async function analyzeSiteMaturity(html, url) {
9717
9722
  contentToAuthorityRatio
9718
9723
  };
9719
9724
  const { strategy, recommendations } = determineStrategy(maturityLevel, authorityIndicators, contentMetrics);
9720
- if (maturityLevel === "new") {
9725
+ if (maturityLevel === "new" && ageSource === "wayback" && estimatedAgeDays !== null) {
9721
9726
  issues.push({
9722
9727
  code: "NEW_SITE_STRATEGY",
9723
9728
  severity: "notice",
9724
9729
  category: "content",
9725
9730
  title: "New website detected - Authority-first strategy recommended",
9726
- description: "Your site appears to be new (<90 days). Standard SEO advice won't work well for new sites.",
9731
+ description: `The Wayback Machine first saw this site ${estimatedAgeDays} days ago. Standard SEO advice won't work well for a site under 90 days old.`,
9727
9732
  impact: "Creating lots of content without authority is wasted effort. Content from new sites rarely ranks.",
9728
9733
  howToFix: "Focus on building 10+ quality backlinks to ONE great page before expanding content. See strategic recommendations.",
9729
9734
  affectedUrls: [url],
@@ -9734,24 +9739,7 @@ async function analyzeSiteMaturity(html, url) {
9734
9739
  }
9735
9740
  });
9736
9741
  }
9737
- if (contentToAuthorityRatio === "content-heavy") {
9738
- issues.push({
9739
- code: "CONTENT_AUTHORITY_IMBALANCE",
9740
- severity: "warning",
9741
- category: "content",
9742
- title: "Content-to-authority imbalance",
9743
- description: `Site has ~${indexedPages} pages but limited backlink signals. Content is unlikely to rank.`,
9744
- impact: "Most of your content may be invisible to search engines due to low domain authority.",
9745
- howToFix: "Stop publishing new content. Focus 100% on link building until you see ranking improvements.",
9746
- affectedUrls: [url],
9747
- details: {
9748
- indexedPages,
9749
- contentToAuthorityRatio,
9750
- maturityLevel
9751
- }
9752
- });
9753
- }
9754
- if (maturityLevel === "growing" && !htmlSignals.hasBlog) {
9742
+ if (maturityLevel === "growing" && ageSource === "wayback" && !htmlSignals.hasBlog) {
9755
9743
  issues.push({
9756
9744
  code: "GROWING_SITE_NO_BLOG",
9757
9745
  severity: "notice",
@@ -10245,8 +10233,8 @@ function analyzeRendering(html) {
10245
10233
  const hasSemanticStructure = hasH1 && (hasMain || hasArticle);
10246
10234
  const title = $("title").text().trim();
10247
10235
  const h1Text = $("h1").first().text().trim();
10248
- const firstParagraph = $("p").first().text().trim();
10249
- const keyContentInHTML = title.length > 10 && h1Text.length > 5 && firstParagraph.length > 50;
10236
+ const hasSubstantialParagraph = $("p").toArray().some((p) => $(p).text().trim().length > 50);
10237
+ const keyContentInHTML = title.length > 0 && h1Text.length > 0 && hasSubstantialParagraph;
10250
10238
  const EXPECTED_MIN_CONTENT = 1e3;
10251
10239
  const htmlContentRatio = Math.min(100, mainContentCharCount / EXPECTED_MIN_CONTENT * 100);
10252
10240
  let aiReadableScore = 50;
@@ -10466,9 +10454,9 @@ function analyzeClientRendering(html, url) {
10466
10454
  howToFix: "Ensure the initial HTML contains: meaningful <title>, descriptive <h1>, and a substantial first paragraph with your key message.",
10467
10455
  affectedUrls: [url],
10468
10456
  details: {
10469
- hasTitle: $("title").text().trim().length > 10,
10470
- hasH1: $("h1").text().trim().length > 5,
10471
- hasFirstParagraph: $("p").first().text().trim().length > 50
10457
+ hasTitle: $("title").text().trim().length > 0,
10458
+ hasH1: $("h1").text().trim().length > 0,
10459
+ hasSubstantialParagraph: $("p").toArray().some((p) => $(p).text().trim().length > 50)
10472
10460
  }
10473
10461
  });
10474
10462
  }
@@ -11015,6 +11003,106 @@ function analyzeResponsiveImages(html, url) {
11015
11003
 
11016
11004
  // src/audit/checks/bing-optimization.ts
11017
11005
  import * as cheerio28 from "cheerio";
11006
+
11007
+ // src/audit/checks/site-verification.ts
11008
+ var UNCHECKED = {
11009
+ dnsChecked: false,
11010
+ googleDns: false,
11011
+ bingDns: false,
11012
+ bingSiteAuthFile: false
11013
+ };
11014
+ var DNS_TIMEOUT_MS = 2e3;
11015
+ var FILE_TIMEOUT_MS = 5e3;
11016
+ var CACHE_TTL_MS = 10 * 60 * 1e3;
11017
+ var cache = /* @__PURE__ */ new Map();
11018
+ function lookupSiteVerification(url) {
11019
+ let parsed;
11020
+ try {
11021
+ parsed = new URL(url);
11022
+ } catch {
11023
+ return Promise.resolve(UNCHECKED);
11024
+ }
11025
+ const host = parsed.hostname.toLowerCase();
11026
+ const now = Date.now();
11027
+ const cached = cache.get(host);
11028
+ if (cached && cached.expires > now) return cached.result;
11029
+ const result = Promise.all([readTxtRecords(host), fetchBingSiteAuth(parsed.origin)]).then(
11030
+ ([txt, bingSiteAuthFile]) => ({
11031
+ dnsChecked: txt.checked,
11032
+ googleDns: txt.records.some((record) => /^google-site-verification=/i.test(record)),
11033
+ bingDns: txt.records.some((record) => /^MS=/i.test(record)),
11034
+ bingSiteAuthFile
11035
+ })
11036
+ );
11037
+ cache.set(host, { expires: now + CACHE_TTL_MS, result });
11038
+ return result;
11039
+ }
11040
+ var resolveTxtLoader;
11041
+ function loadResolveTxt() {
11042
+ resolveTxtLoader ??= (async () => {
11043
+ if (typeof process === "undefined" || !process.versions?.node) return null;
11044
+ try {
11045
+ const specifier = "node:dns";
11046
+ const dns = await import(specifier);
11047
+ const resolveTxt = dns?.promises?.resolveTxt ?? dns?.default?.promises?.resolveTxt;
11048
+ return typeof resolveTxt === "function" ? resolveTxt : null;
11049
+ } catch {
11050
+ return null;
11051
+ }
11052
+ })();
11053
+ return resolveTxtLoader;
11054
+ }
11055
+ function domainCandidates(host) {
11056
+ if (/^[\d.]+$/.test(host) || host.includes(":")) return [];
11057
+ const labels = host.split(".").filter(Boolean);
11058
+ const names = [];
11059
+ for (let n = 2; n <= labels.length && names.length < 4; n++) {
11060
+ names.push(labels.slice(-n).join("."));
11061
+ }
11062
+ return names.length > 0 ? names : [host];
11063
+ }
11064
+ async function readTxtRecords(host) {
11065
+ const resolveTxt = await loadResolveTxt();
11066
+ if (!resolveTxt) return { checked: false, records: [] };
11067
+ const lookups = await Promise.all(domainCandidates(host).map((name) => resolveTxtWithin(resolveTxt, name)));
11068
+ return {
11069
+ checked: lookups.every((records) => records !== null),
11070
+ records: lookups.flatMap((records) => records ?? [])
11071
+ };
11072
+ }
11073
+ var NO_RECORD_CODES = /* @__PURE__ */ new Set(["ENODATA", "ENOTFOUND"]);
11074
+ async function resolveTxtWithin(resolveTxt, name) {
11075
+ let timer;
11076
+ const timeout = new Promise((resolve) => {
11077
+ timer = setTimeout(() => resolve(null), DNS_TIMEOUT_MS);
11078
+ });
11079
+ try {
11080
+ return await Promise.race([
11081
+ resolveTxt(name).then(
11082
+ (records) => records.map((chunks) => chunks.join("").trim()),
11083
+ (error) => NO_RECORD_CODES.has(error?.code ?? "") ? [] : null
11084
+ ),
11085
+ timeout
11086
+ ]);
11087
+ } catch {
11088
+ return null;
11089
+ } finally {
11090
+ clearTimeout(timer);
11091
+ }
11092
+ }
11093
+ async function fetchBingSiteAuth(origin) {
11094
+ try {
11095
+ const response = await httpGet(`${origin}/BingSiteAuth.xml`, {
11096
+ timeout: FILE_TIMEOUT_MS,
11097
+ maxBytes: 64 * 1024
11098
+ });
11099
+ return response.status === 200 && typeof response.data === "string" && !looksLikeHtmlFallback(response.data, response.headers["content-type"]) && /<users[\s>]/i.test(response.data);
11100
+ } catch {
11101
+ return false;
11102
+ }
11103
+ }
11104
+
11105
+ // src/audit/checks/bing-optimization.ts
11018
11106
  async function analyzeBingOptimization(html, url) {
11019
11107
  const issues = [];
11020
11108
  const $ = cheerio28.load(html);
@@ -11023,12 +11111,9 @@ async function analyzeBingOptimization(html, url) {
11023
11111
  const detectedIssues = [];
11024
11112
  const bingMetaTag = $('meta[name="msvalidate.01"]').attr("content");
11025
11113
  const hasBingMetaTag = !!bingMetaTag && bingMetaTag.length > 0;
11026
- let hasBingSiteAuth = false;
11027
- try {
11028
- const authResponse = await httpGet(`${baseUrl}/BingSiteAuth.xml`, { timeout: 5e3 });
11029
- hasBingSiteAuth = authResponse.status === 200 && authResponse.data.includes("<?xml") && authResponse.data.toLowerCase().includes("users");
11030
- } catch {
11031
- }
11114
+ const verification = await lookupSiteVerification(url);
11115
+ const hasBingSiteAuth = verification.bingSiteAuthFile;
11116
+ const hasBingDnsRecord = verification.bingDns;
11032
11117
  let hasBingIndexNowKey = false;
11033
11118
  try {
11034
11119
  const keyPatterns = [
@@ -11076,22 +11161,27 @@ async function analyzeBingOptimization(html, url) {
11076
11161
  bingSitemapAccessible = sitemapResponse.status === 200 && sitemapResponse.data.includes("<?xml");
11077
11162
  } catch {
11078
11163
  }
11079
- if (!hasBingMetaTag && !hasBingSiteAuth) {
11164
+ if (!hasBingMetaTag && !hasBingSiteAuth && !hasBingDnsRecord) {
11080
11165
  detectedIssues.push("No Bing site verification");
11081
11166
  issues.push({
11082
11167
  code: "BING_NOT_VERIFIED",
11083
- severity: "warning",
11168
+ // A notice: verification done through Search Console import or a DNS record this run
11169
+ // could not see leaves no trace in the HTML, so this cannot be a warning.
11170
+ severity: "notice",
11084
11171
  category: "ai-readiness",
11085
- title: "Site not verified with Bing Webmaster Tools",
11086
- description: "ChatGPT Search uses Bing's index. Verifying your site with Bing Webmaster Tools ensures proper indexing and access to Bing-specific SEO tools.",
11172
+ title: verification.dnsChecked ? "Site not verified with Bing Webmaster Tools" : "Bing Webmaster Tools verification not visible in the HTML",
11173
+ description: verification.dnsChecked ? "No msvalidate.01 meta tag in the HTML, no MS= TXT record on the domain and no /BingSiteAuth.xml file. ChatGPT Search uses Bing's index. Verifying your site with Bing Webmaster Tools ensures proper indexing and access to Bing-specific SEO tools." : "No msvalidate.01 meta tag in the HTML and no /BingSiteAuth.xml file. The site may be verified through a DNS record instead, which this run could not check. ChatGPT Search uses Bing's index. Verifying your site with Bing Webmaster Tools ensures proper indexing and access to Bing-specific SEO tools.",
11087
11174
  impact: "May reduce visibility in ChatGPT Search results and miss Bing-specific optimization opportunities.",
11088
- howToFix: "Add Bing verification: 1) Go to Bing Webmaster Tools, 2) Add your site, 3) Choose verification method (meta tag or XML file), 4) Add the verification code to your site.",
11175
+ howToFix: "If the site is not yet in Bing Webmaster Tools: 1) Go to Bing Webmaster Tools, 2) Add your site (or import it from Google Search Console), 3) Choose a verification method (meta tag, DNS record or XML file), 4) Add the verification code to your site.",
11089
11176
  affectedUrls: [url],
11090
11177
  details: {
11091
11178
  hasBingMetaTag,
11092
11179
  hasBingSiteAuth,
11180
+ hasBingDnsRecord,
11181
+ dnsChecked: verification.dnsChecked,
11093
11182
  verificationMethods: [
11094
11183
  'Add <meta name="msvalidate.01" content="YOUR_CODE"> to <head>',
11184
+ "Add an MS=YOUR_CODE TXT record to the domain's DNS",
11095
11185
  "Upload BingSiteAuth.xml to root directory"
11096
11186
  ]
11097
11187
  }
@@ -11128,7 +11218,7 @@ async function analyzeBingOptimization(html, url) {
11128
11218
  });
11129
11219
  }
11130
11220
  const hasLocalBusinessSchema = bingPlacesDetected;
11131
- const hasAddressOnPage = $("address").length > 0 || html.toLowerCase().includes("street") || /\d{5}(-\d{4})?/.test(html);
11221
+ const hasAddressOnPage = $("address").text().trim().length > 0 || detectAddresses(html).length > 0;
11132
11222
  if (hasAddressOnPage && !hasLocalBusinessSchema) {
11133
11223
  detectedIssues.push("Local business may not be on Bing Places");
11134
11224
  issues.push({
@@ -11151,6 +11241,8 @@ async function analyzeBingOptimization(html, url) {
11151
11241
  data: {
11152
11242
  hasBingSiteAuth,
11153
11243
  hasBingMetaTag,
11244
+ hasBingDnsRecord,
11245
+ dnsChecked: verification.dnsChecked,
11154
11246
  hasBingIndexNowKey,
11155
11247
  bingPlacesDetected,
11156
11248
  bingSitemapAccessible,
@@ -12652,23 +12744,6 @@ function analyzeColorContrast(html, url) {
12652
12744
  });
12653
12745
  }
12654
12746
  }
12655
- if (cannotDetermine > elementsAnalyzed * 0.8) {
12656
- issues.push({
12657
- code: "A11Y_CONTRAST_UNDETERMINED",
12658
- severity: "notice",
12659
- category: "accessibility",
12660
- title: "Color contrast could not be fully analyzed",
12661
- description: "Most text colors are set via CSS stylesheets rather than inline styles. Full contrast checking requires browser rendering.",
12662
- impact: "Potential contrast issues may exist that were not detected.",
12663
- howToFix: "Use browser DevTools (Lighthouse accessibility audit) or dedicated tools like axe-core for comprehensive contrast checking.",
12664
- affectedUrls: [url],
12665
- details: {
12666
- elementsAnalyzed,
12667
- cannotDetermine,
12668
- recommendation: "Run Lighthouse accessibility audit for complete contrast analysis."
12669
- }
12670
- });
12671
- }
12672
12747
  return {
12673
12748
  issues,
12674
12749
  data: {
@@ -13129,6 +13204,10 @@ function analyzePageResources(html, url) {
13129
13204
 
13130
13205
  // src/audit/checks/responsive-css.ts
13131
13206
  import * as cheerio35 from "cheerio";
13207
+ var MAX_STYLESHEETS = 3;
13208
+ var MAX_STYLESHEET_BYTES = 1024 * 1024;
13209
+ var STYLESHEET_TIMEOUT_MS = 3e3;
13210
+ var RESPONSIVE_SIGNAL = /@media\b|@container\b|\bclamp\(|(?<![\w-])(?:min|max)\(/i;
13132
13211
  function extractMediaQueries(css) {
13133
13212
  const mediaQueries = [];
13134
13213
  const regex = /@media\s*([^{]+)/g;
@@ -13138,36 +13217,31 @@ function extractMediaQueries(css) {
13138
13217
  }
13139
13218
  return mediaQueries;
13140
13219
  }
13220
+ var WIDTH_RULE = /(?:(?:min|max)-width\s*:|width\s*(?:<=|>=|<|>))\s*(\d+(?:\.\d+)?)\s*(px|em|rem)?|(\d+(?:\.\d+)?)\s*(px|em|rem)?\s*(?:<=|>=|<|>)\s*width/gi;
13141
13221
  function classifyBreakpoints(mediaQueries) {
13142
13222
  const breakpoints = /* @__PURE__ */ new Set();
13143
13223
  let hasMobile = false;
13144
13224
  let hasTablet = false;
13145
13225
  let hasDesktop = false;
13146
13226
  for (const query of mediaQueries) {
13147
- const widthMatches = query.match(/(?:min|max)-width\s*:\s*(\d+)(?:px|em|rem)?/gi);
13148
- if (widthMatches) {
13149
- for (const match of widthMatches) {
13150
- const valueMatch = match.match(/(\d+)/);
13151
- if (valueMatch) {
13152
- const value = parseInt(valueMatch[1], 10);
13153
- breakpoints.add(`${value}px`);
13154
- if (value <= 480) {
13155
- hasMobile = true;
13156
- } else if (value <= 768) {
13157
- hasMobile = true;
13158
- } else if (value <= 1024) {
13159
- hasTablet = true;
13160
- } else {
13161
- hasDesktop = true;
13162
- }
13163
- }
13164
- }
13165
- }
13166
- if (/screen\s+and/i.test(query)) {
13167
- }
13168
13227
  if (/print/i.test(query)) {
13169
13228
  continue;
13170
13229
  }
13230
+ for (const match of query.matchAll(WIDTH_RULE)) {
13231
+ const raw = match[1] ?? match[3];
13232
+ const unit = (match[2] ?? match[4] ?? "px").toLowerCase();
13233
+ const value = Math.round(parseFloat(raw) * (unit === "px" ? 1 : 16));
13234
+ breakpoints.add(`${value}px`);
13235
+ if (value <= 480) {
13236
+ hasMobile = true;
13237
+ } else if (value <= 768) {
13238
+ hasMobile = true;
13239
+ } else if (value <= 1024) {
13240
+ hasTablet = true;
13241
+ } else {
13242
+ hasDesktop = true;
13243
+ }
13244
+ }
13171
13245
  }
13172
13246
  return {
13173
13247
  hasMobile,
@@ -13185,6 +13259,7 @@ async function analyzeResponsiveCss(html, url) {
13185
13259
  const allMediaQueries = [];
13186
13260
  const externalSources = [];
13187
13261
  let inlineCount = 0;
13262
+ let hasResponsiveSignals = false;
13188
13263
  $("style").each((_, el) => {
13189
13264
  const css = $(el).text();
13190
13265
  const queries = extractMediaQueries(css);
@@ -13192,39 +13267,43 @@ async function analyzeResponsiveCss(html, url) {
13192
13267
  allMediaQueries.push(...queries);
13193
13268
  inlineCount += queries.length;
13194
13269
  }
13270
+ if (RESPONSIVE_SIGNAL.test(css)) hasResponsiveSignals = true;
13195
13271
  });
13196
- const cssLinks = $('link[rel="stylesheet"][href]').toArray().slice(0, 3).map((el) => $(el).attr("href")).filter((href) => {
13197
- if (!href || href.startsWith("data:")) return false;
13272
+ const sameOrigin = [];
13273
+ let crossOrigin = 0;
13274
+ $("link[rel][href]").each((_, el) => {
13275
+ if (!/\bstylesheet\b/i.test($(el).attr("rel") ?? "")) return;
13276
+ const href = $(el).attr("href");
13277
+ if (!href || href.startsWith("data:")) return;
13198
13278
  try {
13199
- const cssHostname = new URL(href, url).hostname;
13200
- return cssHostname === baseUrl.hostname;
13279
+ const resolved = new URL(href, url);
13280
+ if (resolved.hostname === baseUrl.hostname) sameOrigin.push(resolved.href);
13281
+ else crossOrigin++;
13201
13282
  } catch {
13202
- return false;
13283
+ crossOrigin++;
13203
13284
  }
13204
13285
  });
13205
- const cssPromises = cssLinks.map(async (cssUrl) => {
13206
- try {
13207
- const fullUrl = new URL(cssUrl, url).href;
13208
- const response = await httpGet(fullUrl, {
13209
- timeout: 3e3,
13210
- // Reduced timeout
13211
- validateStatus: () => true
13212
- });
13213
- if (response.status === 200 && typeof response.data === "string") {
13286
+ let fetchFailed = 0;
13287
+ await Promise.all(
13288
+ sameOrigin.slice(0, MAX_STYLESHEETS).map(async (cssUrl) => {
13289
+ try {
13290
+ const response = await httpGet(cssUrl, {
13291
+ timeout: STYLESHEET_TIMEOUT_MS,
13292
+ maxBytes: MAX_STYLESHEET_BYTES
13293
+ });
13294
+ if (typeof response.data !== "string") throw new Error("not text");
13214
13295
  const queries = extractMediaQueries(response.data);
13215
- if (queries.length > 0) {
13216
- allMediaQueries.push(...queries);
13296
+ if (queries.length > 0) allMediaQueries.push(...queries);
13297
+ if (RESPONSIVE_SIGNAL.test(response.data)) {
13298
+ hasResponsiveSignals = true;
13217
13299
  externalSources.push(cssUrl);
13218
13300
  }
13301
+ } catch {
13302
+ fetchFailed++;
13219
13303
  }
13220
- } catch {
13221
- }
13222
- });
13223
- await Promise.race([
13224
- Promise.all(cssPromises),
13225
- new Promise((resolve) => setTimeout(resolve, 6e3))
13226
- // 6s overall timeout
13227
- ]);
13304
+ })
13305
+ );
13306
+ const stylesheetsCovered = crossOrigin === 0 && fetchFailed === 0 && sameOrigin.length <= MAX_STYLESHEETS;
13228
13307
  const hasMediaQueries = allMediaQueries.length > 0;
13229
13308
  const { hasMobile, hasTablet, hasDesktop, breakpoints } = classifyBreakpoints(allMediaQueries);
13230
13309
  if (!hasViewport) {
@@ -13239,21 +13318,22 @@ async function analyzeResponsiveCss(html, url) {
13239
13318
  affectedUrls: [url]
13240
13319
  });
13241
13320
  }
13242
- if (!hasMediaQueries) {
13321
+ if (!hasResponsiveSignals && stylesheetsCovered) {
13243
13322
  issues.push({
13244
13323
  code: "RESPONSIVE_NO_MEDIA_QUERIES",
13245
13324
  severity: "warning",
13246
13325
  category: "mobile",
13247
13326
  title: "No CSS media queries detected",
13248
- description: "No @media queries found in inline styles or first-party stylesheets.",
13327
+ description: "No @media or @container queries and no fluid sizing (clamp, min, max) found in inline styles or first-party stylesheets.",
13249
13328
  impact: "Without media queries, the site likely uses fixed layouts that do not adapt to different screen sizes. This hurts mobile usability and SEO.",
13250
13329
  howToFix: "Add CSS media queries to adapt layout for different screen sizes. Consider using a mobile-first approach with min-width breakpoints.",
13251
13330
  affectedUrls: [url],
13252
13331
  details: {
13253
- suggestion: "Common breakpoints: 480px (mobile), 768px (tablet), 1024px (desktop)"
13332
+ suggestion: "Common breakpoints: 480px (mobile), 768px (tablet), 1024px (desktop)",
13333
+ stylesheetsScanned: sameOrigin.length
13254
13334
  }
13255
13335
  });
13256
- } else if (!hasMobile && !hasTablet) {
13336
+ } else if (hasMediaQueries && !hasMobile && !hasTablet) {
13257
13337
  issues.push({
13258
13338
  code: "RESPONSIVE_NO_MOBILE_BREAKPOINT",
13259
13339
  severity: "warning",
@@ -13279,6 +13359,8 @@ async function analyzeResponsiveCss(html, url) {
13279
13359
  inline: inlineCount,
13280
13360
  external: externalSources
13281
13361
  },
13362
+ hasResponsiveSignals,
13363
+ stylesheetsCovered,
13282
13364
  breakpoints,
13283
13365
  hasMobileBreakpoint: hasMobile,
13284
13366
  hasTabletBreakpoint: hasTablet,
@@ -13916,7 +13998,22 @@ var urlSafetyDatabase = {
13916
13998
 
13917
13999
  // src/audit/checks/tracking-verification.ts
13918
14000
  import * as cheerio36 from "cheerio";
13919
- function analyzeTrackingVerification(html, url) {
14001
+ var OTHER_ANALYTICS = [
14002
+ { name: "Cloudflare Web Analytics", pattern: /cloudflareinsights\.com\/beacon(?:\.min)?\.js/i },
14003
+ { name: "Plausible", pattern: /plausible\.io\/js\/|<script[^>]*data-domain=[^>]*plausible|<script[^>]*plausible[^>]*data-domain=/i },
14004
+ { name: "Fathom", pattern: /cdn\.usefathom\.com/i },
14005
+ // data-website-id is Umami's attribute, on the hosted script and on self-hosted ones alike.
14006
+ { name: "Umami", pattern: /<script[^>]*\bdata-website-id=/i },
14007
+ { name: "PostHog", pattern: /posthog\.init\s*\(|\.posthog\.com\//i },
14008
+ { name: "Matomo", pattern: /matomo\.js|piwik\.js|_paq\s*(?:=|\.push)/i },
14009
+ { name: "Simple Analytics", pattern: /scripts\.simpleanalyticscdn\.com/i },
14010
+ { name: "Mixpanel", pattern: /cdn\.mxpnl\.com|mixpanel\.init\s*\(/i },
14011
+ { name: "Segment", pattern: /cdn\.segment\.com/i }
14012
+ ];
14013
+ function detectOtherAnalytics(html) {
14014
+ return OTHER_ANALYTICS.filter(({ pattern }) => pattern.test(html)).map(({ name }) => name);
14015
+ }
14016
+ async function analyzeTrackingVerification(html, url) {
13920
14017
  const issues = [];
13921
14018
  const $ = cheerio36.load(html);
13922
14019
  const ga4Match = html.match(/gtag\s*\(\s*['"]config['"]\s*,\s*['"](G-[A-Z0-9]+)['"]/i) || html.match(/googletagmanager\.com\/gtag\/js\?id=(G-[A-Z0-9]+)/i);
@@ -13926,10 +14023,16 @@ function analyzeTrackingVerification(html, url) {
13926
14023
  const hasGtm = !!gtmMatch;
13927
14024
  const gtmContainerId = gtmMatch ? gtmMatch[1] : void 0;
13928
14025
  const hasGtmNoscript = /googletagmanager\.com\/ns\.html\?id=GTM-[A-Z0-9]+/i.test(html);
14026
+ const otherAnalytics = detectOtherAnalytics(html);
14027
+ const verification = await lookupSiteVerification(url);
13929
14028
  const gscMeta = $('meta[name="google-site-verification"]').attr("content");
13930
- const hasGscVerification = !!(gscMeta && gscMeta.length > 10);
14029
+ const hasGscMeta = !!(gscMeta && gscMeta.length > 10);
14030
+ const hasGscVerification = hasGscMeta || verification.googleDns;
14031
+ const gscSource = hasGscMeta ? "meta" : verification.googleDns ? "dns" : void 0;
13931
14032
  const bingMeta = $('meta[name="msvalidate.01"]').attr("content");
13932
- const hasBingVerification = !!(bingMeta && bingMeta.length > 10);
14033
+ const hasBingMeta = !!(bingMeta && bingMeta.length > 10);
14034
+ const hasBingVerification = hasBingMeta || verification.bingDns || verification.bingSiteAuthFile;
14035
+ const bingSource = hasBingMeta ? "meta" : verification.bingDns ? "dns" : verification.bingSiteAuthFile ? "file" : void 0;
13933
14036
  const jsonLdScripts = $('script[type="application/ld+json"]');
13934
14037
  const hasMicrodata = $('[itemtype*="schema.org"]').length > 0;
13935
14038
  const hasJsonLd = jsonLdScripts.length > 0;
@@ -13951,16 +14054,19 @@ function analyzeTrackingVerification(html, url) {
13951
14054
  const data = {
13952
14055
  ga4: { detected: hasGa4, measurementId: ga4MeasurementId },
13953
14056
  gtm: { detected: hasGtm, containerId: gtmContainerId, hasNoscript: hasGtmNoscript },
13954
- gsc: { verified: hasGscVerification, verificationCode: gscMeta },
13955
- bing: { verified: hasBingVerification, verificationCode: bingMeta },
14057
+ otherAnalytics,
14058
+ gsc: { verified: hasGscVerification, verificationCode: gscMeta, source: gscSource },
14059
+ bing: { verified: hasBingVerification, verificationCode: bingMeta, source: bingSource },
14060
+ dnsChecked: verification.dnsChecked,
13956
14061
  schemaOrg: { detected: hasJsonLd || hasMicrodata, types: schemaTypes, hasValidJson },
13957
14062
  sitemap: { inRobotsTxt: false }
13958
14063
  // Set later if we check robots.txt
13959
14064
  };
13960
- if (!hasGa4 && !hasGtm) {
14065
+ if (!hasGa4 && !hasGtm && otherAnalytics.length === 0) {
13961
14066
  issues.push({
13962
14067
  code: "GA4_MISSING",
13963
- severity: "warning",
14068
+ // A notice: which analytics a site runs is a choice, not an SEO defect.
14069
+ severity: "notice",
13964
14070
  category: "on-page",
13965
14071
  title: "Google Analytics 4 not detected",
13966
14072
  description: "No GA4 tracking code found. GA4 is essential for measuring organic traffic and conversions.",
@@ -13970,40 +14076,47 @@ function analyzeTrackingVerification(html, url) {
13970
14076
  });
13971
14077
  }
13972
14078
  if (!hasGscVerification) {
14079
+ const dnsChecked = verification.dnsChecked;
13973
14080
  issues.push({
13974
14081
  code: "GSC_VERIFICATION_MISSING",
13975
- severity: "warning",
14082
+ // A notice: a site verified by DNS, Google Analytics or Tag Manager leaves no trace here.
14083
+ severity: "notice",
13976
14084
  category: "on-page",
13977
- title: "Google Search Console verification not found",
13978
- description: "No GSC verification meta tag detected. GSC provides essential data about your search performance.",
14085
+ title: dnsChecked ? "Google Search Console verification not found" : "Google Search Console verification not visible in the HTML",
14086
+ description: dnsChecked ? "No google-site-verification meta tag in the HTML and no google-site-verification TXT record on the domain. GSC provides essential data about your search performance." : "No google-site-verification meta tag in the HTML. The site may be verified through a DNS TXT record instead, which this run could not check. GSC provides essential data about your search performance.",
13979
14087
  impact: "Missing critical insights into Google search visibility, impressions, and clicks.",
13980
- howToFix: "Add your GSC verification meta tag to the <head> section. Get it from search.google.com/search-console.",
13981
- affectedUrls: [url]
14088
+ howToFix: dnsChecked ? "Verify the site in Google Search Console: add the meta tag to <head>, or a TXT record to the domain's DNS. Get it from search.google.com/search-console." : "If the site is not yet verified in Google Search Console, add the meta tag to <head> or a TXT record to the domain's DNS. Get it from search.google.com/search-console.",
14089
+ affectedUrls: [url],
14090
+ details: { dnsChecked }
13982
14091
  });
13983
14092
  }
13984
14093
  if (!hasBingVerification) {
14094
+ const dnsChecked = verification.dnsChecked;
13985
14095
  issues.push({
13986
14096
  code: "BING_VERIFICATION_MISSING",
13987
14097
  severity: "notice",
13988
14098
  category: "on-page",
13989
- title: "Bing Webmaster Tools verification not found",
13990
- description: "No Bing verification meta tag detected. Bing/Yahoo account for 5-8% of search traffic.",
14099
+ title: dnsChecked ? "Bing Webmaster Tools verification not found" : "Bing Webmaster Tools verification not visible in the HTML",
14100
+ description: dnsChecked ? "No msvalidate.01 meta tag in the HTML, no MS= TXT record on the domain and no /BingSiteAuth.xml file. Bing/Yahoo account for 5-8% of search traffic." : "No msvalidate.01 meta tag in the HTML and no /BingSiteAuth.xml file. The site may be verified through a DNS record instead, which this run could not check. Bing/Yahoo account for 5-8% of search traffic.",
13991
14101
  impact: "Missing insights on Bing/Yahoo search traffic.",
13992
- howToFix: "Add Bing Webmaster Tools verification for insights on Bing/Yahoo traffic. Get it from bing.com/webmasters.",
13993
- affectedUrls: [url]
14102
+ howToFix: "If the site is not yet in Bing Webmaster Tools, add it there: import it from Google Search Console, or verify with the meta tag, a DNS record or BingSiteAuth.xml. Get it from bing.com/webmasters.",
14103
+ affectedUrls: [url],
14104
+ details: { dnsChecked }
13994
14105
  });
13995
14106
  }
13996
14107
  if (!hasGtm) {
13997
- issues.push({
13998
- code: "GTM_MISSING",
13999
- severity: "notice",
14000
- category: "on-page",
14001
- title: "Google Tag Manager not detected",
14002
- description: "GTM not found. While not required, GTM makes it easier to manage tracking tags without code changes.",
14003
- impact: "Tag management requires developer involvement for each change.",
14004
- howToFix: "Consider using GTM for easier tag management. It allows marketing to add tags without developer help.",
14005
- affectedUrls: [url]
14006
- });
14108
+ if (otherAnalytics.length === 0) {
14109
+ issues.push({
14110
+ code: "GTM_MISSING",
14111
+ severity: "notice",
14112
+ category: "on-page",
14113
+ title: "Google Tag Manager not detected",
14114
+ description: "GTM not found. While not required, GTM makes it easier to manage tracking tags without code changes.",
14115
+ impact: "Tag management requires developer involvement for each change.",
14116
+ howToFix: "Consider using GTM for easier tag management. It allows marketing to add tags without developer help.",
14117
+ affectedUrls: [url]
14118
+ });
14119
+ }
14007
14120
  } else if (!hasGtmNoscript) {
14008
14121
  issues.push({
14009
14122
  code: "GTM_NOSCRIPT_MISSING",
@@ -14042,6 +14155,113 @@ function analyzeTrackingVerification(html, url) {
14042
14155
  return { issues, data };
14043
14156
  }
14044
14157
 
14158
+ // src/audit/meta-refresh.ts
14159
+ import * as cheerio37 from "cheerio";
14160
+ function metaRefreshTarget(html, pageUrl) {
14161
+ if (!/http-equiv\s*=\s*["']?refresh/i.test(html)) return null;
14162
+ const $ = cheerio37.load(html);
14163
+ for (const el of $("meta[http-equiv]").toArray()) {
14164
+ if (($(el).attr("http-equiv") || "").trim().toLowerCase() !== "refresh") continue;
14165
+ const content = $(el).attr("content") || "";
14166
+ const match = content.match(/^\s*(\d+(?:\.\d+)?)\s*[;,]\s*(?:url\s*=\s*)?['"]?\s*([^'"\s>]+)/i);
14167
+ if (!match) continue;
14168
+ if (Number(match[1]) > 5) continue;
14169
+ try {
14170
+ const target = new URL(match[2], pageUrl);
14171
+ if (target.protocol !== "http:" && target.protocol !== "https:") continue;
14172
+ const strip = (u) => u.origin + (u.pathname.replace(/\/+$/, "") || "/") + u.search;
14173
+ if (strip(target) === strip(new URL(pageUrl))) continue;
14174
+ return target.toString();
14175
+ } catch {
14176
+ continue;
14177
+ }
14178
+ }
14179
+ return null;
14180
+ }
14181
+
14182
+ // src/audit/dedupe.ts
14183
+ var REDUNDANT_WHEN_PRESENT = [
14184
+ // The same tag, reported by three checks.
14185
+ { keep: "VIEWPORT_MISSING", drop: ["HTML_NO_VIEWPORT", "RESPONSIVE_NO_VIEWPORT"] },
14186
+ { keep: "LANG_ATTR_MISSING", drop: ["HTML_NO_LANG"] },
14187
+ { keep: "APPLE_TOUCH_ICON_MISSING", drop: ["HTML_NO_APPLE_TOUCH_ICON"] },
14188
+ { keep: "LLMS_TXT_MISSING", drop: ["AI_NO_LLMS_TXT"] },
14189
+ { keep: "INDEXNOW_NOT_IMPLEMENTED", drop: ["BING_NO_INDEXNOW"] },
14190
+ { keep: "BING_VERIFICATION_MISSING", drop: ["BING_NOT_VERIFIED"] },
14191
+ { keep: "SITEMAP_MISSING", drop: ["BING_SITEMAP_MISSING", "SITEMAP_NOT_IN_ROBOTS"] },
14192
+ { keep: "ROBOTS_TXT_MISSING", drop: ["SITEMAP_NOT_IN_ROBOTS"] },
14193
+ { keep: "IMAGES_MISSING_DIMENSIONS", drop: ["IMG_NO_DIMENSIONS"] },
14194
+ { keep: "NO_MODERN_IMAGE_FORMATS", drop: ["IMAGE_NOT_WEBP_AVIF"] },
14195
+ { keep: "SOCIAL_PROFILES_NOT_LINKED", drop: ["NO_SOCIAL_PROFILES"] },
14196
+ { keep: "NO_EXPERIENCE_SIGNALS", drop: ["AI_NO_EXPERIENCE_SIGNALS"] },
14197
+ // No structured data at all makes every "your schema lacks X" finding moot.
14198
+ {
14199
+ keep: "SCHEMA_MISSING",
14200
+ drop: [
14201
+ "SCHEMA_ORG_MISSING",
14202
+ "NO_ORGANIZATION_SCHEMA",
14203
+ "ORGANIZATION_SCHEMA_MISSING",
14204
+ "NO_ENTITY_SCHEMA",
14205
+ "NO_DATE_MODIFIED_SCHEMA",
14206
+ "NO_MAIN_ENTITY_OF_PAGE",
14207
+ "AUTHOR_NO_SCHEMA",
14208
+ "AI_NO_REVIEW_SCHEMA",
14209
+ "NO_SCHEMA_SAMEAS"
14210
+ ]
14211
+ },
14212
+ { keep: "NO_ORGANIZATION_SCHEMA", drop: ["ORGANIZATION_SCHEMA_MISSING"] },
14213
+ // No title, no H1: the findings about their wording are implied.
14214
+ { keep: "TITLE_MISSING", drop: ["LOW_CTR_POTENTIAL", "NO_KEYWORDS_IN_TITLE", "TITLE_TOO_SHORT"] },
14215
+ { keep: "META_DESC_MISSING", drop: ["LOW_CTR_POTENTIAL"] },
14216
+ { keep: "H1_MISSING", drop: ["NO_VISIBLE_HEADLINE", "NO_KEYWORDS_IN_H1", "H1_MISSING_KEYWORD"] },
14217
+ // Client-side rendering, diagnosed once. CLIENT_SIDE_RENDERING carries the per-framework fix.
14218
+ {
14219
+ keep: "CLIENT_SIDE_RENDERING",
14220
+ drop: [
14221
+ "REACT_CSR_DETECTED",
14222
+ "VUE_CSR_DETECTED",
14223
+ "ANGULAR_NO_UNIVERSAL",
14224
+ "SPA_WITHOUT_SSR",
14225
+ "EMPTY_BODY_CONTENT",
14226
+ "HIGH_JS_RENDERING_RATIO",
14227
+ "AI_LOW_HTML_READABILITY"
14228
+ ]
14229
+ },
14230
+ // An empty body has no vocabulary to be undiverse.
14231
+ { keep: "EMPTY_BODY_CONTENT", drop: ["THIN_CONTENT", "LOW_CONTENT_ENTROPY", "HIGH_CONTENT_REPETITION", "LOW_DWELL_TIME_CONTENT", "LOW_TEXT_HTML_RATIO"] },
14232
+ { keep: "THIN_CONTENT", drop: ["LOW_DWELL_TIME_CONTENT"] },
14233
+ { keep: "NO_INTERNAL_LINKS", drop: ["NO_CONTENT_INTERNAL_LINKS", "HIGH_ORPHAN_RISK", "LINKS_NO_INTERNAL"] },
14234
+ // No testimonials on the page, said once instead of six times.
14235
+ { keep: "NO_SOCIAL_PROOF", drop: ["BOFU_NO_SOCIAL_PROOF", "AI_NO_TESTIMONIALS", "AI_NO_REVIEW_INTEGRATION", "AI_NO_REVIEW_PLATFORMS", "AI_WEAK_REVIEW_ECOSYSTEM"] },
14236
+ { keep: "AI_NO_TESTIMONIALS", drop: ["AI_NO_REVIEW_INTEGRATION", "AI_NO_REVIEW_PLATFORMS", "AI_WEAK_REVIEW_ECOSYSTEM"] },
14237
+ // The brand-score composite restates the brand-definition finding it is built from.
14238
+ { keep: "AI_NO_BRAND_DEFINITION", drop: ["AI_LOW_BRAND_SCORE", "NO_ENTITY_DEFINITION"] }
14239
+ ];
14240
+ function dedupeIssues(issues) {
14241
+ const present = new Set(issues.map((i) => i.code));
14242
+ const redundant = /* @__PURE__ */ new Set();
14243
+ for (const rule of REDUNDANT_WHEN_PRESENT) {
14244
+ const fires = typeof rule.keep === "string" ? present.has(rule.keep) : [...present].some((code) => rule.keep.test(code));
14245
+ if (!fires) continue;
14246
+ for (const code of rule.drop) redundant.add(code);
14247
+ }
14248
+ const kept = /* @__PURE__ */ new Map();
14249
+ const out = [];
14250
+ for (const issue of issues) {
14251
+ if (redundant.has(issue.code)) continue;
14252
+ const earlier = kept.get(issue.code);
14253
+ if (earlier) {
14254
+ const urls = /* @__PURE__ */ new Set([...earlier.affectedUrls || [], ...issue.affectedUrls || []]);
14255
+ earlier.affectedUrls = [...urls];
14256
+ continue;
14257
+ }
14258
+ const copy = { ...issue };
14259
+ kept.set(issue.code, copy);
14260
+ out.push(copy);
14261
+ }
14262
+ return out;
14263
+ }
14264
+
14045
14265
  // src/audit/engine.ts
14046
14266
  function sameUrl(a, b) {
14047
14267
  try {
@@ -14054,15 +14274,15 @@ function sameUrl(a, b) {
14054
14274
  }
14055
14275
  }
14056
14276
  async function runFullAudit(options) {
14057
- const { url, checkBrokenLinks = false, checkHreflangUrls = false, checkCanonicalChain = false, cruxApiKey, checksLimit = 280, premiumChecks } = options;
14277
+ const { checkBrokenLinks = false, checkHreflangUrls = false, checkCanonicalChain = false, cruxApiKey, checksLimit = 280, premiumChecks } = options;
14278
+ let url = options.url;
14058
14279
  const auditRoot = isSiteRoot(url) || sameUrl(url, options.auditRootUrl ?? url);
14059
14280
  const allIssues = [];
14060
14281
  const pages = [];
14061
14282
  const tier = checksLimit <= 50 ? "free" : checksLimit <= 100 ? "registered" : "paid";
14062
14283
  const runPremiumChecks = tier === "paid" && !!premiumChecks;
14063
14284
  const runExtendedChecks = tier !== "free";
14064
- const parsedUrl = new URL(url);
14065
- const domain = parsedUrl.hostname;
14285
+ let domain = new URL(url).hostname;
14066
14286
  console.error(`
14067
14287
  \u{1F50D} Running comprehensive SEO audit on ${url}...
14068
14288
  `);
@@ -14089,7 +14309,9 @@ async function runFullAudit(options) {
14089
14309
  timeout: 3e4,
14090
14310
  validateStatus: () => true
14091
14311
  }).catch((err) => {
14092
- console.error("Main page fetch failed:", err instanceof Error ? { message: err.message, cause: err.cause } : err);
14312
+ const cause = err instanceof Error ? err.cause : void 0;
14313
+ const causeText = cause instanceof Error ? ` (${cause.code || cause.message})` : "";
14314
+ console.error(`Main page fetch failed: ${err instanceof Error ? err.message : String(err)}${causeText}`);
14093
14315
  return { error: err, data: "", headers: {} };
14094
14316
  })
14095
14317
  ]);
@@ -14112,7 +14334,7 @@ async function runFullAudit(options) {
14112
14334
  });
14113
14335
  return createReport(url, domain, allIssues, pages, { kind: "unreachable" });
14114
14336
  }
14115
- const statusCode = fetchResult.status;
14337
+ let statusCode = fetchResult.status;
14116
14338
  if (statusCode >= 400) {
14117
14339
  const blocked = statusCode === 401 || statusCode === 403 || statusCode === 429;
14118
14340
  allIssues.push({
@@ -14125,10 +14347,42 @@ async function runFullAudit(options) {
14125
14347
  pages.push({ url, statusCode, wordCount: 0, loadTime: 0, issues: allIssues.map((i) => i.code) });
14126
14348
  return createReport(url, domain, allIssues, pages, { kind: "http-error", statusCode });
14127
14349
  }
14128
- const body = typeof fetchResult.data === "string" ? fetchResult.data : JSON.stringify(fetchResult.data ?? "");
14350
+ const asText = (data) => typeof data === "string" ? data : JSON.stringify(data ?? "");
14351
+ let body = asText(fetchResult.data);
14352
+ let headers = fetchResult.headers;
14353
+ const requestedUrl = url;
14354
+ const refreshHops = [];
14355
+ for (let hop = 0; hop < 3; hop++) {
14356
+ const target = metaRefreshTarget(body, url);
14357
+ if (!target) break;
14358
+ try {
14359
+ const next = await httpGet(target, { timeout: 3e4, validateStatus: () => true });
14360
+ if (next.status >= 400) break;
14361
+ refreshHops.push(target);
14362
+ url = target;
14363
+ body = asText(next.data);
14364
+ headers = next.headers;
14365
+ statusCode = next.status;
14366
+ } catch {
14367
+ break;
14368
+ }
14369
+ }
14370
+ if (refreshHops.length > 0) {
14371
+ domain = new URL(url).hostname;
14372
+ allIssues.push({
14373
+ code: "META_REFRESH_REDIRECT",
14374
+ severity: "notice",
14375
+ category: "crawlability",
14376
+ title: "Page redirects with a meta refresh",
14377
+ description: `${requestedUrl} answers HTTP 200 with a <meta http-equiv="refresh"> to ${url}, so that page was audited. Search engines follow an instant meta refresh, but a server redirect is the documented way to move a URL and the only one every crawler and tool honours.`,
14378
+ impact: "Some crawlers and link-checking tools stop at the stub; link signals to the original URL may not all pass to the target.",
14379
+ howToFix: "Return a 301 (or 308) redirect from the server or CDN for this URL instead of a meta refresh.",
14380
+ affectedUrls: [requestedUrl],
14381
+ details: { from: requestedUrl, to: url, hops: refreshHops }
14382
+ });
14383
+ }
14129
14384
  const html = body.replace(/<\/?noscript[^>]*>/gi, "");
14130
- const headers = fetchResult.headers;
14131
- console.error(`\u{1F4DD} Phase 2: Running synchronous HTML checks (tier: ${tier}, limit: ${checksLimit})...`);
14385
+ console.error("\u{1F4DD} Phase 2: Running HTML checks...");
14132
14386
  const onPageResult = analyzeOnPage(html, url, headers, { auditRoot });
14133
14387
  const structuredDataResult = analyzeStructuredData2(html, url);
14134
14388
  const mobileResult = analyzeMobile(html, url);
@@ -14143,7 +14397,6 @@ async function runFullAudit(options) {
14143
14397
  const urlSafetyResult = analyzeUrlSafety(url);
14144
14398
  const domSizeResult = analyzeDomSize(html, url);
14145
14399
  const imageDimensionsResult = analyzeImageDimensions(html, url);
14146
- const trackingResult = analyzeTrackingVerification(html, url);
14147
14400
  const anchorResult = runExtendedChecks ? analyzeAnchorText(html, url) : { issues: [], data: {} };
14148
14401
  const localSeoResult = runExtendedChecks ? analyzeLocalSEO(html, url) : { issues: [], data: {} };
14149
14402
  const modernImageResult = runExtendedChecks ? analyzeModernImages(html, url) : { issues: [], data: {} };
@@ -14218,8 +14471,7 @@ async function runFullAudit(options) {
14218
14471
  ...colorContrastResult.issues,
14219
14472
  ...pageResourcesResult.issues,
14220
14473
  ...urlSafetyResult.issues,
14221
- ...paginationResult.issues,
14222
- ...trackingResult.issues
14474
+ ...paginationResult.issues
14223
14475
  );
14224
14476
  if (!structuredDataResult.data.hasSchema) {
14225
14477
  const suggestions = suggestSchemaTypes(html, url);
@@ -14265,6 +14517,7 @@ async function runFullAudit(options) {
14265
14517
  asyncChecks.push(safeAsyncIssues("Certificate", async () => (await checkCertificate(url)).issues));
14266
14518
  asyncChecks.push(safeAsync("Redirects", () => analyzeRedirects(url)));
14267
14519
  asyncChecks.push(safeAsync("Robots Validation", () => validateRobotsTxt(url)));
14520
+ asyncChecks.push(safeAsync("Tracking Verification", () => analyzeTrackingVerification(html, url)));
14268
14521
  asyncChecks.push(safeAsyncIssues("Crawler Access", async () => (await analyzeCrawlerAccess(url)).issues));
14269
14522
  asyncChecks.push(safeAsync("Canonical Domain", () => analyzeCanonicalDomain(url)));
14270
14523
  asyncChecks.push(safeAsyncIssues("Framework Detection", async () => {
@@ -14296,10 +14549,10 @@ async function runFullAudit(options) {
14296
14549
  if (cruxApiKey) {
14297
14550
  asyncChecks.push(safeAsyncIssues("Core Web Vitals", async () => {
14298
14551
  const cwv = await fetchCoreWebVitals(url, cruxApiKey);
14299
- const issues = [];
14552
+ const issues2 = [];
14300
14553
  if (cwv) {
14301
14554
  if (cwv.lcp?.rating === "poor") {
14302
- issues.push({
14555
+ issues2.push({
14303
14556
  code: "LCP_POOR",
14304
14557
  severity: "error",
14305
14558
  category: "performance",
@@ -14312,7 +14565,7 @@ async function runFullAudit(options) {
14312
14565
  });
14313
14566
  }
14314
14567
  if (cwv.cls?.rating === "poor") {
14315
- issues.push({
14568
+ issues2.push({
14316
14569
  code: "CLS_POOR",
14317
14570
  severity: "error",
14318
14571
  category: "performance",
@@ -14325,14 +14578,15 @@ async function runFullAudit(options) {
14325
14578
  });
14326
14579
  }
14327
14580
  }
14328
- return issues;
14581
+ return issues2;
14329
14582
  }));
14330
14583
  }
14331
14584
  }
14332
14585
  const asyncResults = await Promise.all(asyncChecks);
14333
- for (const issues of asyncResults) {
14334
- allIssues.push(...issues);
14586
+ for (const issues2 of asyncResults) {
14587
+ allIssues.push(...issues2);
14335
14588
  }
14589
+ const issues = dedupeIssues(allIssues);
14336
14590
  pages.push({
14337
14591
  url,
14338
14592
  statusCode,
@@ -14342,10 +14596,10 @@ async function runFullAudit(options) {
14342
14596
  h1: onPageResult.data.h1s,
14343
14597
  wordCount: onPageResult.data.wordCount,
14344
14598
  loadTime: perfData.loadTime,
14345
- issues: allIssues.map((i) => i.code)
14599
+ issues: issues.map((i) => i.code)
14346
14600
  });
14347
14601
  console.error("\n\u2705 Audit complete!\n");
14348
- return createReport(url, domain, allIssues, pages);
14602
+ return createReport(url, domain, issues, pages);
14349
14603
  }
14350
14604
  function createReport(url, domain, issues, pages, pageFailure) {
14351
14605
  const healthScore = calculateHealthScore(issues);
@@ -14510,10 +14764,10 @@ function groupIssuesByCategory(issues) {
14510
14764
  }
14511
14765
 
14512
14766
  // src/audit/checks/duplicate-content.ts
14513
- import * as cheerio37 from "cheerio";
14767
+ import * as cheerio38 from "cheerio";
14514
14768
  import { createHash } from "crypto";
14515
14769
  function extractContentHash(html, url) {
14516
- const $ = cheerio37.load(html);
14770
+ const $ = cheerio38.load(html);
14517
14771
  $("script, style, nav, header, footer, aside, .nav, .header, .footer, .sidebar").remove();
14518
14772
  const title = $("title").text().trim();
14519
14773
  const bodyText = $("body").text().replace(/\s+/g, " ").trim();
@@ -15741,7 +15995,7 @@ var PRIORITY_WEIGHTS = {
15741
15995
  };
15742
15996
 
15743
15997
  // src/keywords/engine.ts
15744
- import * as cheerio39 from "cheerio";
15998
+ import * as cheerio40 from "cheerio";
15745
15999
 
15746
16000
  // src/keywords/prioritizer.ts
15747
16001
  function prioritizeKeywords(keywords, siteProfile, existingMeta) {
@@ -16035,7 +16289,7 @@ function enrichKeywordsWithEstimates(keywords) {
16035
16289
  }
16036
16290
 
16037
16291
  // src/keywords/sources/free-sources.ts
16038
- import * as cheerio38 from "cheerio";
16292
+ import * as cheerio39 from "cheerio";
16039
16293
  var USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
16040
16294
  async function getPeopleAlsoAsk(query) {
16041
16295
  try {
@@ -16044,7 +16298,7 @@ async function getPeopleAlsoAsk(query) {
16044
16298
  headers: { "User-Agent": USER_AGENT },
16045
16299
  timeout: 1e4
16046
16300
  });
16047
- const $ = cheerio38.load(response.data);
16301
+ const $ = cheerio39.load(response.data);
16048
16302
  const questions = [];
16049
16303
  $("[data-sgrd]").each((_, el) => {
16050
16304
  const text = $(el).text().trim();
@@ -16070,7 +16324,7 @@ async function getRelatedSearches(query) {
16070
16324
  headers: { "User-Agent": USER_AGENT },
16071
16325
  timeout: 1e4
16072
16326
  });
16073
- const $ = cheerio38.load(response.data);
16327
+ const $ = cheerio39.load(response.data);
16074
16328
  const related = [];
16075
16329
  $("div[data-ved] a").each((_, el) => {
16076
16330
  const href = $(el).attr("href");
@@ -16160,7 +16414,7 @@ async function analyzeCompetitorTitles(query) {
16160
16414
  headers: { "User-Agent": USER_AGENT },
16161
16415
  timeout: 1e4
16162
16416
  });
16163
- const $ = cheerio38.load(response.data);
16417
+ const $ = cheerio39.load(response.data);
16164
16418
  const titles = [];
16165
16419
  const keywords = /* @__PURE__ */ new Set();
16166
16420
  $("h3").each((_, el) => {
@@ -16492,7 +16746,7 @@ async function fetchPageMeta(url) {
16492
16746
  const response = await httpGet(url, {
16493
16747
  timeout: 1e4
16494
16748
  });
16495
- const $ = cheerio39.load(response.data);
16749
+ const $ = cheerio40.load(response.data);
16496
16750
  return {
16497
16751
  url,
16498
16752
  title: $("title").text().trim() || void 0,
@@ -16623,20 +16877,17 @@ async function extractSeedKeywords(url) {
16623
16877
  const response = await httpGet(url, {
16624
16878
  timeout: 1e4
16625
16879
  });
16626
- const $ = cheerio39.load(response.data);
16880
+ const $ = cheerio40.load(response.data);
16627
16881
  const seeds = /* @__PURE__ */ new Set();
16628
- const title = $("title").text().toLowerCase();
16629
- const titleWords = title.split(/[\s\-|:]+/).filter((w) => w.length > 3);
16630
- titleWords.forEach((w) => seeds.add(w));
16631
- const h1 = $("h1").first().text().toLowerCase();
16632
- const h1Words = h1.split(/[\s\-|:]+/).filter((w) => w.length > 3);
16633
- h1Words.forEach((w) => seeds.add(w));
16882
+ const words = (text) => text.toLowerCase().split(/[\s\-|:]+/).map((w) => w.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "")).filter((w) => w.length > 3);
16883
+ words($("title").text()).forEach((w) => seeds.add(w));
16884
+ words($("h1").first().text()).forEach((w) => seeds.add(w));
16634
16885
  const metaKeywords = $('meta[name="keywords"]').attr("content");
16635
16886
  if (metaKeywords) {
16636
16887
  metaKeywords.split(",").forEach((k) => seeds.add(k.trim().toLowerCase()));
16637
16888
  }
16638
16889
  const desc = $('meta[name="description"]').attr("content")?.toLowerCase() || "";
16639
- const descWords = desc.split(/\s+/).filter((w) => w.length > 5);
16890
+ const descWords = words(desc).filter((w) => w.length > 5);
16640
16891
  descWords.slice(0, 5).forEach((w) => seeds.add(w));
16641
16892
  return Array.from(seeds).slice(0, 10);
16642
16893
  } catch {
@@ -16689,7 +16940,7 @@ function parseJsonReply(text) {
16689
16940
  }
16690
16941
 
16691
16942
  // src/keywords/site-crawler.ts
16692
- import * as cheerio40 from "cheerio";
16943
+ import * as cheerio41 from "cheerio";
16693
16944
  var EXCLUDED_PATHS = [
16694
16945
  "/cdn-cgi/",
16695
16946
  "/wp-admin/",
@@ -16801,7 +17052,7 @@ async function crawlPage(url, timeout) {
16801
17052
  validateStatus: (status) => status === 200
16802
17053
  });
16803
17054
  const html = response.data;
16804
- const $ = cheerio40.load(html);
17055
+ const $ = cheerio41.load(html);
16805
17056
  $('script, style, noscript, iframe, nav, footer, header, aside, [role="navigation"]').remove();
16806
17057
  const title = $("title").text().trim();
16807
17058
  const description = $('meta[name="description"]').attr("content")?.trim() || "";
@@ -19436,7 +19687,7 @@ function getDateRange(days = 28) {
19436
19687
  }
19437
19688
 
19438
19689
  // src/keywords/sources/competitor-analysis.ts
19439
- import * as cheerio41 from "cheerio";
19690
+ import * as cheerio42 from "cheerio";
19440
19691
  var USER_AGENT2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
19441
19692
  async function discoverCompetitorKeywords(yourDomain, seedKeywords, competitors) {
19442
19693
  const yourKeywords = /* @__PURE__ */ new Set();
@@ -19533,7 +19784,7 @@ async function analyzeSERP(query) {
19533
19784
  headers: { "User-Agent": USER_AGENT2 },
19534
19785
  timeout: 1e4
19535
19786
  });
19536
- const $ = cheerio41.load(response.data);
19787
+ const $ = cheerio42.load(response.data);
19537
19788
  const results = [];
19538
19789
  const relatedSearches = [];
19539
19790
  const peopleAlsoAsk = [];
@@ -19986,7 +20237,7 @@ function formatTopicReport(result) {
19986
20237
 
19987
20238
  // src/content/readability.ts
19988
20239
  function analyzeReadability(text) {
19989
- const cleanText = text.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
20240
+ const cleanText = text.replace(/<[^>]*>/g, " ").replace(/[ \t\r\f\v]+/g, " ").replace(/ ?\n ?/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
19990
20241
  const words = getWords(cleanText);
19991
20242
  const sentences = getSentences(cleanText);
19992
20243
  const paragraphs = getParagraphs(cleanText);
@@ -20055,7 +20306,7 @@ function getSentences(text) {
20055
20306
  return text.split(/[.!?]+/).filter((s) => s.trim().length > 0);
20056
20307
  }
20057
20308
  function getParagraphs(text) {
20058
- return text.split(/\n\n+/).filter((p) => p.trim().length > 0);
20309
+ return text.split(/\n\n+/).filter((p) => p.trim().split(/\s+/).length >= 3);
20059
20310
  }
20060
20311
  function countSyllables(word) {
20061
20312
  word = word.toLowerCase().replace(/[^a-z]/g, "");
@@ -23143,7 +23394,7 @@ async function executeAgent(agent, options = {}) {
23143
23394
  }
23144
23395
  }
23145
23396
  async function runDirectAnalysis(url) {
23146
- const { analyzeUrl: analyzeUrl2 } = await import("./analyzer-BYAFIO33.mjs");
23397
+ const { analyzeUrl: analyzeUrl2 } = await import("./analyzer-HHRJODCF.mjs");
23147
23398
  const result = await analyzeUrl2({ url });
23148
23399
  if (!result.success || !result.data) {
23149
23400
  throw new Error(result.error || "Analysis failed");
@@ -32323,6 +32574,7 @@ export {
32323
32574
  LOCATION_CODES,
32324
32575
  OG_IMAGE_SPECS,
32325
32576
  PRIORITY_WEIGHTS,
32577
+ REDUNDANT_WHEN_PRESENT,
32326
32578
  RankTracker,
32327
32579
  SEO_SCOPES,
32328
32580
  SITE_PROFILE_QUESTIONS,
@@ -32406,6 +32658,7 @@ export {
32406
32658
  createPullRequest,
32407
32659
  createSEOCommit,
32408
32660
  createSEOCommits,
32661
+ dedupeIssues,
32409
32662
  describeConcerns,
32410
32663
  detectDuplicates,
32411
32664
  detectFramework4 as detectFramework,
@@ -32542,6 +32795,7 @@ export {
32542
32795
  loadAgent,
32543
32796
  loadAgentByName,
32544
32797
  mergePages,
32798
+ metaRefreshTarget,
32545
32799
  openAIKeywordLLM,
32546
32800
  optimizeForAI,
32547
32801
  optimizeReadme,