@rankcli/agent-runtime 0.0.36 → 0.0.38

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.js CHANGED
@@ -55,7 +55,8 @@ async function httpGet(url, config = {}) {
55
55
  timeout = DEFAULT_TIMEOUT,
56
56
  validateStatus = (status) => status >= 200 && status < 300,
57
57
  maxRedirects = 5,
58
- params
58
+ params,
59
+ maxBytes
59
60
  } = config;
60
61
  let finalUrl = url;
61
62
  if (params) {
@@ -82,7 +83,7 @@ async function httpGet(url, config = {}) {
82
83
  if (contentType.includes("application/json")) {
83
84
  data = await response.json();
84
85
  } else {
85
- data = await response.text();
86
+ data = await readText(response, maxBytes);
86
87
  }
87
88
  clearTimeout(timeoutId);
88
89
  if (!validateStatus(response.status)) {
@@ -109,6 +110,23 @@ async function httpGet(url, config = {}) {
109
110
  throw error;
110
111
  }
111
112
  }
113
+ async function readText(response, maxBytes) {
114
+ if (!maxBytes || !response.body) return response.text();
115
+ const reader = response.body.getReader();
116
+ const chunks = [];
117
+ let received = 0;
118
+ while (received < maxBytes) {
119
+ const { done, value } = await reader.read();
120
+ if (done) break;
121
+ chunks.push(value);
122
+ received += value.byteLength;
123
+ }
124
+ if (received >= maxBytes) await reader.cancel().catch(() => void 0);
125
+ const decoder = new TextDecoder();
126
+ let text = "";
127
+ for (const chunk of chunks) text += decoder.decode(chunk, { stream: true });
128
+ return text + decoder.decode();
129
+ }
112
130
  async function httpHead(url, config = {}) {
113
131
  const {
114
132
  headers = {},
@@ -9996,7 +10014,9 @@ function analyzeAnchorText(html, url) {
9996
10014
  } catch {
9997
10015
  internalLinks++;
9998
10016
  }
9999
- const hasAccessibleText = text.length > 0 || ariaLabel || title;
10017
+ const imgAlt = $(el).find("img[alt]").map((_2, img) => ($(img).attr("alt") || "").trim()).get().join(" ").trim();
10018
+ const svgTitle = $(el).find("svg title").text().trim();
10019
+ const hasAccessibleText = text.length > 0 || ariaLabel || title || imgAlt.length > 0 || svgTitle.length > 0;
10000
10020
  if (!hasAccessibleText) {
10001
10021
  emptyAnchors++;
10002
10022
  if (emptyAnchorUrls.length < 5) {
@@ -10401,6 +10421,121 @@ async function checkInternalRedirects(internalLinks, batchSize = 10) {
10401
10421
  return { redirectLinks };
10402
10422
  }
10403
10423
 
10424
+ // src/audit/checks/crawler-access.ts
10425
+ init_http();
10426
+ var CRAWLERS = [
10427
+ {
10428
+ name: "Googlebot",
10429
+ userAgent: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
10430
+ matters: "Google Search indexing",
10431
+ critical: true
10432
+ },
10433
+ {
10434
+ name: "bingbot",
10435
+ userAgent: "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
10436
+ matters: "Bing indexing, and Copilot answers",
10437
+ critical: true
10438
+ },
10439
+ {
10440
+ name: "GPTBot",
10441
+ userAgent: "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)",
10442
+ matters: "ChatGPT being able to read and cite the page",
10443
+ critical: false
10444
+ },
10445
+ {
10446
+ name: "ClaudeBot",
10447
+ userAgent: "Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)",
10448
+ matters: "Claude being able to read and cite the page",
10449
+ critical: false
10450
+ },
10451
+ {
10452
+ name: "PerplexityBot",
10453
+ userAgent: "Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)",
10454
+ matters: "Perplexity being able to read and cite the page",
10455
+ critical: false
10456
+ }
10457
+ ];
10458
+ var BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
10459
+ function isRefusal(status) {
10460
+ return status === 401 || status === 403 || status === 429 || status === 503;
10461
+ }
10462
+ async function statusFor(url, userAgent) {
10463
+ try {
10464
+ const response = await httpGet(url, {
10465
+ headers: { "User-Agent": userAgent },
10466
+ timeout: 15e3,
10467
+ // We want the status itself, not an exception on 4xx/5xx.
10468
+ validateStatus: () => true
10469
+ });
10470
+ return response.status;
10471
+ } catch {
10472
+ return null;
10473
+ }
10474
+ }
10475
+ async function analyzeCrawlerAccess(url) {
10476
+ const issues = [];
10477
+ const baselineStatus = await statusFor(url, BROWSER_UA);
10478
+ const data = { baselineStatus, crawlers: [] };
10479
+ if (baselineStatus === null || baselineStatus >= 400) {
10480
+ return { issues, data };
10481
+ }
10482
+ const results = await Promise.all(
10483
+ CRAWLERS.map(async (crawler) => ({
10484
+ crawler,
10485
+ status: await statusFor(url, crawler.userAgent)
10486
+ }))
10487
+ );
10488
+ const blocked = results.filter(({ status }) => status !== null && isRefusal(status));
10489
+ data.crawlers = results.map(({ crawler, status }) => ({
10490
+ name: crawler.name,
10491
+ status,
10492
+ blocked: status !== null && isRefusal(status)
10493
+ }));
10494
+ if (blocked.length === 0) return { issues, data };
10495
+ const search = blocked.filter(({ crawler }) => crawler.critical);
10496
+ const ai = blocked.filter(({ crawler }) => !crawler.critical);
10497
+ if (search.length > 0) {
10498
+ const names = search.map(({ crawler }) => crawler.name).join(", ");
10499
+ issues.push({
10500
+ code: "SEARCH_CRAWLER_BLOCKED_AT_EDGE",
10501
+ severity: "error",
10502
+ category: "crawlability",
10503
+ title: `${names} refused at the edge, not by robots.txt`,
10504
+ description: `A browser request to this URL returned ${baselineStatus}, but ${names} received ${search.map(({ status }) => status).join(", ")}. Something in front of the origin - a CDN, WAF or bot rule - is turning the crawler away, and robots.txt cannot show you this.`,
10505
+ impact: "Pages a search crawler cannot fetch fall out of the index. This is the failure mode behind Cloudflare\u2019s 2026-09-15 default, where blocking AI training also blocked Googlebot because it is classed as a mixed-use crawler.",
10506
+ howToFix: `Check your CDN or WAF bot rules rather than robots.txt.
10507
+
10508
+ On Cloudflare: the zone's Security > Bots settings, and any "Block AI Bots" or AI Crawler Control toggle. Because Googlebot crawls for both Search and AI training, blocking Training applies to all of its functions on ad-supported pages - allow Search crawlers explicitly, or opt out of the default.
10509
+
10510
+ Confirm with Search Console's URL Inspection, which fetches as the verified Googlebot.`,
10511
+ affectedUrls: [url],
10512
+ details: {
10513
+ baselineStatus,
10514
+ blocked: search.map(({ crawler, status }) => ({ crawler: crawler.name, status })),
10515
+ note: "Observed with the crawler\u2019s user-agent. Real crawlers are also verified by IP, so a site may treat them differently."
10516
+ }
10517
+ });
10518
+ }
10519
+ if (ai.length > 0) {
10520
+ const names = ai.map(({ crawler }) => crawler.name).join(", ");
10521
+ issues.push({
10522
+ code: "AI_CRAWLER_BLOCKED_AT_EDGE",
10523
+ severity: "warning",
10524
+ category: "ai-readiness",
10525
+ title: `${names} refused at the edge, not by robots.txt`,
10526
+ description: `A browser request returned ${baselineStatus}, but ${names} received ${ai.map(({ status }) => status).join(", ")}. The block is in front of the origin, so allowing these agents in robots.txt will not change it.`,
10527
+ impact: "An assistant that cannot fetch the page cannot quote it. This is invisible to a robots.txt check, which is where most AI-visibility tools stop.",
10528
+ howToFix: `If blocking them is deliberate, nothing to do. If not, the rule is in your CDN or WAF, not robots.txt - on Cloudflare see Security > Bots and any AI Crawler Control setting.`,
10529
+ affectedUrls: [url],
10530
+ details: {
10531
+ baselineStatus,
10532
+ blocked: ai.map(({ crawler, status }) => ({ crawler: crawler.name, status }))
10533
+ }
10534
+ });
10535
+ }
10536
+ return { issues, data };
10537
+ }
10538
+
10404
10539
  // src/audit/checks/ai-readiness.ts
10405
10540
  init_http();
10406
10541
  var cheerio13 = __toESM(require("cheerio"));
@@ -14450,6 +14585,107 @@ function analyzeResponsiveImages(html, url) {
14450
14585
  // src/audit/checks/bing-optimization.ts
14451
14586
  var cheerio28 = __toESM(require("cheerio"));
14452
14587
  init_http();
14588
+
14589
+ // src/audit/checks/site-verification.ts
14590
+ init_http();
14591
+ var UNCHECKED = {
14592
+ dnsChecked: false,
14593
+ googleDns: false,
14594
+ bingDns: false,
14595
+ bingSiteAuthFile: false
14596
+ };
14597
+ var DNS_TIMEOUT_MS = 2e3;
14598
+ var FILE_TIMEOUT_MS = 5e3;
14599
+ var CACHE_TTL_MS = 10 * 60 * 1e3;
14600
+ var cache = /* @__PURE__ */ new Map();
14601
+ function lookupSiteVerification(url) {
14602
+ let parsed;
14603
+ try {
14604
+ parsed = new URL(url);
14605
+ } catch {
14606
+ return Promise.resolve(UNCHECKED);
14607
+ }
14608
+ const host = parsed.hostname.toLowerCase();
14609
+ const now = Date.now();
14610
+ const cached = cache.get(host);
14611
+ if (cached && cached.expires > now) return cached.result;
14612
+ const result = Promise.all([readTxtRecords(host), fetchBingSiteAuth(parsed.origin)]).then(
14613
+ ([txt, bingSiteAuthFile]) => ({
14614
+ dnsChecked: txt.checked,
14615
+ googleDns: txt.records.some((record) => /^google-site-verification=/i.test(record)),
14616
+ bingDns: txt.records.some((record) => /^MS=/i.test(record)),
14617
+ bingSiteAuthFile
14618
+ })
14619
+ );
14620
+ cache.set(host, { expires: now + CACHE_TTL_MS, result });
14621
+ return result;
14622
+ }
14623
+ var resolveTxtLoader;
14624
+ function loadResolveTxt() {
14625
+ resolveTxtLoader ??= (async () => {
14626
+ if (typeof process === "undefined" || !process.versions?.node) return null;
14627
+ try {
14628
+ const specifier = "node:dns";
14629
+ const dns = await import(specifier);
14630
+ const resolveTxt = dns?.promises?.resolveTxt ?? dns?.default?.promises?.resolveTxt;
14631
+ return typeof resolveTxt === "function" ? resolveTxt : null;
14632
+ } catch {
14633
+ return null;
14634
+ }
14635
+ })();
14636
+ return resolveTxtLoader;
14637
+ }
14638
+ function domainCandidates(host) {
14639
+ if (/^[\d.]+$/.test(host) || host.includes(":")) return [];
14640
+ const labels = host.split(".").filter(Boolean);
14641
+ const names = [];
14642
+ for (let n = 2; n <= labels.length && names.length < 4; n++) {
14643
+ names.push(labels.slice(-n).join("."));
14644
+ }
14645
+ return names.length > 0 ? names : [host];
14646
+ }
14647
+ async function readTxtRecords(host) {
14648
+ const resolveTxt = await loadResolveTxt();
14649
+ if (!resolveTxt) return { checked: false, records: [] };
14650
+ const lookups = await Promise.all(domainCandidates(host).map((name) => resolveTxtWithin(resolveTxt, name)));
14651
+ return {
14652
+ checked: lookups.every((records) => records !== null),
14653
+ records: lookups.flatMap((records) => records ?? [])
14654
+ };
14655
+ }
14656
+ var NO_RECORD_CODES = /* @__PURE__ */ new Set(["ENODATA", "ENOTFOUND"]);
14657
+ async function resolveTxtWithin(resolveTxt, name) {
14658
+ let timer;
14659
+ const timeout = new Promise((resolve) => {
14660
+ timer = setTimeout(() => resolve(null), DNS_TIMEOUT_MS);
14661
+ });
14662
+ try {
14663
+ return await Promise.race([
14664
+ resolveTxt(name).then(
14665
+ (records) => records.map((chunks) => chunks.join("").trim()),
14666
+ (error) => NO_RECORD_CODES.has(error?.code ?? "") ? [] : null
14667
+ ),
14668
+ timeout
14669
+ ]);
14670
+ } catch {
14671
+ return null;
14672
+ } finally {
14673
+ clearTimeout(timer);
14674
+ }
14675
+ }
14676
+ async function fetchBingSiteAuth(origin) {
14677
+ try {
14678
+ const response = await httpGet(`${origin}/BingSiteAuth.xml`, {
14679
+ timeout: FILE_TIMEOUT_MS,
14680
+ maxBytes: 64 * 1024
14681
+ });
14682
+ return response.status === 200 && typeof response.data === "string" && !looksLikeHtmlFallback(response.data, response.headers["content-type"]) && /<users[\s>]/i.test(response.data);
14683
+ } catch {
14684
+ return false;
14685
+ }
14686
+ }
14687
+
14688
+ // src/audit/checks/bing-optimization.ts
14453
14689
  async function analyzeBingOptimization(html, url) {
14454
14690
  const issues = [];
14455
14691
  const $ = cheerio28.load(html);
@@ -14458,12 +14694,9 @@ async function analyzeBingOptimization(html, url) {
14458
14694
  const detectedIssues = [];
14459
14695
  const bingMetaTag = $('meta[name="msvalidate.01"]').attr("content");
14460
14696
  const hasBingMetaTag = !!bingMetaTag && bingMetaTag.length > 0;
14461
- let hasBingSiteAuth = false;
14462
- try {
14463
- const authResponse = await httpGet(`${baseUrl}/BingSiteAuth.xml`, { timeout: 5e3 });
14464
- hasBingSiteAuth = authResponse.status === 200 && authResponse.data.includes("<?xml") && authResponse.data.toLowerCase().includes("users");
14465
- } catch {
14466
- }
14697
+ const verification = await lookupSiteVerification(url);
14698
+ const hasBingSiteAuth = verification.bingSiteAuthFile;
14699
+ const hasBingDnsRecord = verification.bingDns;
14467
14700
  let hasBingIndexNowKey = false;
14468
14701
  try {
14469
14702
  const keyPatterns = [
@@ -14511,22 +14744,25 @@ async function analyzeBingOptimization(html, url) {
14511
14744
  bingSitemapAccessible = sitemapResponse.status === 200 && sitemapResponse.data.includes("<?xml");
14512
14745
  } catch {
14513
14746
  }
14514
- if (!hasBingMetaTag && !hasBingSiteAuth) {
14747
+ if (!hasBingMetaTag && !hasBingSiteAuth && !hasBingDnsRecord) {
14515
14748
  detectedIssues.push("No Bing site verification");
14516
14749
  issues.push({
14517
14750
  code: "BING_NOT_VERIFIED",
14518
14751
  severity: "warning",
14519
14752
  category: "ai-readiness",
14520
- title: "Site not verified with Bing Webmaster Tools",
14521
- description: "ChatGPT Search uses Bing's index. Verifying your site with Bing Webmaster Tools ensures proper indexing and access to Bing-specific SEO tools.",
14753
+ title: verification.dnsChecked ? "Site not verified with Bing Webmaster Tools" : "Bing Webmaster Tools verification not visible in the HTML",
14754
+ 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.",
14522
14755
  impact: "May reduce visibility in ChatGPT Search results and miss Bing-specific optimization opportunities.",
14523
- 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.",
14756
+ 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.",
14524
14757
  affectedUrls: [url],
14525
14758
  details: {
14526
14759
  hasBingMetaTag,
14527
14760
  hasBingSiteAuth,
14761
+ hasBingDnsRecord,
14762
+ dnsChecked: verification.dnsChecked,
14528
14763
  verificationMethods: [
14529
14764
  'Add <meta name="msvalidate.01" content="YOUR_CODE"> to <head>',
14765
+ "Add an MS=YOUR_CODE TXT record to the domain's DNS",
14530
14766
  "Upload BingSiteAuth.xml to root directory"
14531
14767
  ]
14532
14768
  }
@@ -14586,6 +14822,8 @@ async function analyzeBingOptimization(html, url) {
14586
14822
  data: {
14587
14823
  hasBingSiteAuth,
14588
14824
  hasBingMetaTag,
14825
+ hasBingDnsRecord,
14826
+ dnsChecked: verification.dnsChecked,
14589
14827
  hasBingIndexNowKey,
14590
14828
  bingPlacesDetected,
14591
14829
  bingSitemapAccessible,
@@ -16571,6 +16809,10 @@ function analyzePageResources(html, url) {
16571
16809
  // src/audit/checks/responsive-css.ts
16572
16810
  init_http();
16573
16811
  var cheerio35 = __toESM(require("cheerio"));
16812
+ var MAX_STYLESHEETS = 3;
16813
+ var MAX_STYLESHEET_BYTES = 1024 * 1024;
16814
+ var STYLESHEET_TIMEOUT_MS = 3e3;
16815
+ var RESPONSIVE_SIGNAL = /@media\b|@container\b|\bclamp\(|(?<![\w-])(?:min|max)\(/i;
16574
16816
  function extractMediaQueries(css) {
16575
16817
  const mediaQueries = [];
16576
16818
  const regex = /@media\s*([^{]+)/g;
@@ -16580,36 +16822,31 @@ function extractMediaQueries(css) {
16580
16822
  }
16581
16823
  return mediaQueries;
16582
16824
  }
16825
+ 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;
16583
16826
  function classifyBreakpoints(mediaQueries) {
16584
16827
  const breakpoints = /* @__PURE__ */ new Set();
16585
16828
  let hasMobile = false;
16586
16829
  let hasTablet = false;
16587
16830
  let hasDesktop = false;
16588
16831
  for (const query of mediaQueries) {
16589
- const widthMatches = query.match(/(?:min|max)-width\s*:\s*(\d+)(?:px|em|rem)?/gi);
16590
- if (widthMatches) {
16591
- for (const match of widthMatches) {
16592
- const valueMatch = match.match(/(\d+)/);
16593
- if (valueMatch) {
16594
- const value = parseInt(valueMatch[1], 10);
16595
- breakpoints.add(`${value}px`);
16596
- if (value <= 480) {
16597
- hasMobile = true;
16598
- } else if (value <= 768) {
16599
- hasMobile = true;
16600
- } else if (value <= 1024) {
16601
- hasTablet = true;
16602
- } else {
16603
- hasDesktop = true;
16604
- }
16605
- }
16606
- }
16607
- }
16608
- if (/screen\s+and/i.test(query)) {
16609
- }
16610
16832
  if (/print/i.test(query)) {
16611
16833
  continue;
16612
16834
  }
16835
+ for (const match of query.matchAll(WIDTH_RULE)) {
16836
+ const raw = match[1] ?? match[3];
16837
+ const unit = (match[2] ?? match[4] ?? "px").toLowerCase();
16838
+ const value = Math.round(parseFloat(raw) * (unit === "px" ? 1 : 16));
16839
+ breakpoints.add(`${value}px`);
16840
+ if (value <= 480) {
16841
+ hasMobile = true;
16842
+ } else if (value <= 768) {
16843
+ hasMobile = true;
16844
+ } else if (value <= 1024) {
16845
+ hasTablet = true;
16846
+ } else {
16847
+ hasDesktop = true;
16848
+ }
16849
+ }
16613
16850
  }
16614
16851
  return {
16615
16852
  hasMobile,
@@ -16627,6 +16864,7 @@ async function analyzeResponsiveCss(html, url) {
16627
16864
  const allMediaQueries = [];
16628
16865
  const externalSources = [];
16629
16866
  let inlineCount = 0;
16867
+ let hasResponsiveSignals = false;
16630
16868
  $("style").each((_, el) => {
16631
16869
  const css = $(el).text();
16632
16870
  const queries = extractMediaQueries(css);
@@ -16634,39 +16872,43 @@ async function analyzeResponsiveCss(html, url) {
16634
16872
  allMediaQueries.push(...queries);
16635
16873
  inlineCount += queries.length;
16636
16874
  }
16875
+ if (RESPONSIVE_SIGNAL.test(css)) hasResponsiveSignals = true;
16637
16876
  });
16638
- const cssLinks = $('link[rel="stylesheet"][href]').toArray().slice(0, 3).map((el) => $(el).attr("href")).filter((href) => {
16639
- if (!href || href.startsWith("data:")) return false;
16877
+ const sameOrigin = [];
16878
+ let crossOrigin = 0;
16879
+ $("link[rel][href]").each((_, el) => {
16880
+ if (!/\bstylesheet\b/i.test($(el).attr("rel") ?? "")) return;
16881
+ const href = $(el).attr("href");
16882
+ if (!href || href.startsWith("data:")) return;
16640
16883
  try {
16641
- const cssHostname = new URL(href, url).hostname;
16642
- return cssHostname === baseUrl.hostname;
16884
+ const resolved = new URL(href, url);
16885
+ if (resolved.hostname === baseUrl.hostname) sameOrigin.push(resolved.href);
16886
+ else crossOrigin++;
16643
16887
  } catch {
16644
- return false;
16888
+ crossOrigin++;
16645
16889
  }
16646
16890
  });
16647
- const cssPromises = cssLinks.map(async (cssUrl) => {
16648
- try {
16649
- const fullUrl = new URL(cssUrl, url).href;
16650
- const response = await httpGet(fullUrl, {
16651
- timeout: 3e3,
16652
- // Reduced timeout
16653
- validateStatus: () => true
16654
- });
16655
- if (response.status === 200 && typeof response.data === "string") {
16891
+ let fetchFailed = 0;
16892
+ await Promise.all(
16893
+ sameOrigin.slice(0, MAX_STYLESHEETS).map(async (cssUrl) => {
16894
+ try {
16895
+ const response = await httpGet(cssUrl, {
16896
+ timeout: STYLESHEET_TIMEOUT_MS,
16897
+ maxBytes: MAX_STYLESHEET_BYTES
16898
+ });
16899
+ if (typeof response.data !== "string") throw new Error("not text");
16656
16900
  const queries = extractMediaQueries(response.data);
16657
- if (queries.length > 0) {
16658
- allMediaQueries.push(...queries);
16901
+ if (queries.length > 0) allMediaQueries.push(...queries);
16902
+ if (RESPONSIVE_SIGNAL.test(response.data)) {
16903
+ hasResponsiveSignals = true;
16659
16904
  externalSources.push(cssUrl);
16660
16905
  }
16906
+ } catch {
16907
+ fetchFailed++;
16661
16908
  }
16662
- } catch {
16663
- }
16664
- });
16665
- await Promise.race([
16666
- Promise.all(cssPromises),
16667
- new Promise((resolve) => setTimeout(resolve, 6e3))
16668
- // 6s overall timeout
16669
- ]);
16909
+ })
16910
+ );
16911
+ const stylesheetsCovered = crossOrigin === 0 && fetchFailed === 0 && sameOrigin.length <= MAX_STYLESHEETS;
16670
16912
  const hasMediaQueries = allMediaQueries.length > 0;
16671
16913
  const { hasMobile, hasTablet, hasDesktop, breakpoints } = classifyBreakpoints(allMediaQueries);
16672
16914
  if (!hasViewport) {
@@ -16681,21 +16923,22 @@ async function analyzeResponsiveCss(html, url) {
16681
16923
  affectedUrls: [url]
16682
16924
  });
16683
16925
  }
16684
- if (!hasMediaQueries) {
16926
+ if (!hasResponsiveSignals && stylesheetsCovered) {
16685
16927
  issues.push({
16686
16928
  code: "RESPONSIVE_NO_MEDIA_QUERIES",
16687
16929
  severity: "warning",
16688
16930
  category: "mobile",
16689
16931
  title: "No CSS media queries detected",
16690
- description: "No @media queries found in inline styles or first-party stylesheets.",
16932
+ description: "No @media or @container queries and no fluid sizing (clamp, min, max) found in inline styles or first-party stylesheets.",
16691
16933
  impact: "Without media queries, the site likely uses fixed layouts that do not adapt to different screen sizes. This hurts mobile usability and SEO.",
16692
16934
  howToFix: "Add CSS media queries to adapt layout for different screen sizes. Consider using a mobile-first approach with min-width breakpoints.",
16693
16935
  affectedUrls: [url],
16694
16936
  details: {
16695
- suggestion: "Common breakpoints: 480px (mobile), 768px (tablet), 1024px (desktop)"
16937
+ suggestion: "Common breakpoints: 480px (mobile), 768px (tablet), 1024px (desktop)",
16938
+ stylesheetsScanned: sameOrigin.length
16696
16939
  }
16697
16940
  });
16698
- } else if (!hasMobile && !hasTablet) {
16941
+ } else if (hasMediaQueries && !hasMobile && !hasTablet) {
16699
16942
  issues.push({
16700
16943
  code: "RESPONSIVE_NO_MOBILE_BREAKPOINT",
16701
16944
  severity: "warning",
@@ -16721,6 +16964,8 @@ async function analyzeResponsiveCss(html, url) {
16721
16964
  inline: inlineCount,
16722
16965
  external: externalSources
16723
16966
  },
16967
+ hasResponsiveSignals,
16968
+ stylesheetsCovered,
16724
16969
  breakpoints,
16725
16970
  hasMobileBreakpoint: hasMobile,
16726
16971
  hasTabletBreakpoint: hasTablet,
@@ -17359,7 +17604,22 @@ var urlSafetyDatabase = {
17359
17604
 
17360
17605
  // src/audit/checks/tracking-verification.ts
17361
17606
  var cheerio36 = __toESM(require("cheerio"));
17362
- function analyzeTrackingVerification(html, url) {
17607
+ var OTHER_ANALYTICS = [
17608
+ { name: "Cloudflare Web Analytics", pattern: /cloudflareinsights\.com\/beacon(?:\.min)?\.js/i },
17609
+ { name: "Plausible", pattern: /plausible\.io\/js\/|<script[^>]*data-domain=[^>]*plausible|<script[^>]*plausible[^>]*data-domain=/i },
17610
+ { name: "Fathom", pattern: /cdn\.usefathom\.com/i },
17611
+ // data-website-id is Umami's attribute, on the hosted script and on self-hosted ones alike.
17612
+ { name: "Umami", pattern: /<script[^>]*\bdata-website-id=/i },
17613
+ { name: "PostHog", pattern: /posthog\.init\s*\(|\.posthog\.com\//i },
17614
+ { name: "Matomo", pattern: /matomo\.js|piwik\.js|_paq\s*(?:=|\.push)/i },
17615
+ { name: "Simple Analytics", pattern: /scripts\.simpleanalyticscdn\.com/i },
17616
+ { name: "Mixpanel", pattern: /cdn\.mxpnl\.com|mixpanel\.init\s*\(/i },
17617
+ { name: "Segment", pattern: /cdn\.segment\.com/i }
17618
+ ];
17619
+ function detectOtherAnalytics(html) {
17620
+ return OTHER_ANALYTICS.filter(({ pattern }) => pattern.test(html)).map(({ name }) => name);
17621
+ }
17622
+ async function analyzeTrackingVerification(html, url) {
17363
17623
  const issues = [];
17364
17624
  const $ = cheerio36.load(html);
17365
17625
  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);
@@ -17369,10 +17629,16 @@ function analyzeTrackingVerification(html, url) {
17369
17629
  const hasGtm = !!gtmMatch;
17370
17630
  const gtmContainerId = gtmMatch ? gtmMatch[1] : void 0;
17371
17631
  const hasGtmNoscript = /googletagmanager\.com\/ns\.html\?id=GTM-[A-Z0-9]+/i.test(html);
17632
+ const otherAnalytics = detectOtherAnalytics(html);
17633
+ const verification = await lookupSiteVerification(url);
17372
17634
  const gscMeta = $('meta[name="google-site-verification"]').attr("content");
17373
- const hasGscVerification = !!(gscMeta && gscMeta.length > 10);
17635
+ const hasGscMeta = !!(gscMeta && gscMeta.length > 10);
17636
+ const hasGscVerification = hasGscMeta || verification.googleDns;
17637
+ const gscSource = hasGscMeta ? "meta" : verification.googleDns ? "dns" : void 0;
17374
17638
  const bingMeta = $('meta[name="msvalidate.01"]').attr("content");
17375
- const hasBingVerification = !!(bingMeta && bingMeta.length > 10);
17639
+ const hasBingMeta = !!(bingMeta && bingMeta.length > 10);
17640
+ const hasBingVerification = hasBingMeta || verification.bingDns || verification.bingSiteAuthFile;
17641
+ const bingSource = hasBingMeta ? "meta" : verification.bingDns ? "dns" : verification.bingSiteAuthFile ? "file" : void 0;
17376
17642
  const jsonLdScripts = $('script[type="application/ld+json"]');
17377
17643
  const hasMicrodata = $('[itemtype*="schema.org"]').length > 0;
17378
17644
  const hasJsonLd = jsonLdScripts.length > 0;
@@ -17394,13 +17660,15 @@ function analyzeTrackingVerification(html, url) {
17394
17660
  const data = {
17395
17661
  ga4: { detected: hasGa4, measurementId: ga4MeasurementId },
17396
17662
  gtm: { detected: hasGtm, containerId: gtmContainerId, hasNoscript: hasGtmNoscript },
17397
- gsc: { verified: hasGscVerification, verificationCode: gscMeta },
17398
- bing: { verified: hasBingVerification, verificationCode: bingMeta },
17663
+ otherAnalytics,
17664
+ gsc: { verified: hasGscVerification, verificationCode: gscMeta, source: gscSource },
17665
+ bing: { verified: hasBingVerification, verificationCode: bingMeta, source: bingSource },
17666
+ dnsChecked: verification.dnsChecked,
17399
17667
  schemaOrg: { detected: hasJsonLd || hasMicrodata, types: schemaTypes, hasValidJson },
17400
17668
  sitemap: { inRobotsTxt: false }
17401
17669
  // Set later if we check robots.txt
17402
17670
  };
17403
- if (!hasGa4 && !hasGtm) {
17671
+ if (!hasGa4 && !hasGtm && otherAnalytics.length === 0) {
17404
17672
  issues.push({
17405
17673
  code: "GA4_MISSING",
17406
17674
  severity: "warning",
@@ -17413,40 +17681,46 @@ function analyzeTrackingVerification(html, url) {
17413
17681
  });
17414
17682
  }
17415
17683
  if (!hasGscVerification) {
17684
+ const dnsChecked = verification.dnsChecked;
17416
17685
  issues.push({
17417
17686
  code: "GSC_VERIFICATION_MISSING",
17418
17687
  severity: "warning",
17419
17688
  category: "on-page",
17420
- title: "Google Search Console verification not found",
17421
- description: "No GSC verification meta tag detected. GSC provides essential data about your search performance.",
17689
+ title: dnsChecked ? "Google Search Console verification not found" : "Google Search Console verification not visible in the HTML",
17690
+ 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.",
17422
17691
  impact: "Missing critical insights into Google search visibility, impressions, and clicks.",
17423
- howToFix: "Add your GSC verification meta tag to the <head> section. Get it from search.google.com/search-console.",
17424
- affectedUrls: [url]
17692
+ 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.",
17693
+ affectedUrls: [url],
17694
+ details: { dnsChecked }
17425
17695
  });
17426
17696
  }
17427
17697
  if (!hasBingVerification) {
17698
+ const dnsChecked = verification.dnsChecked;
17428
17699
  issues.push({
17429
17700
  code: "BING_VERIFICATION_MISSING",
17430
17701
  severity: "notice",
17431
17702
  category: "on-page",
17432
- title: "Bing Webmaster Tools verification not found",
17433
- description: "No Bing verification meta tag detected. Bing/Yahoo account for 5-8% of search traffic.",
17703
+ title: dnsChecked ? "Bing Webmaster Tools verification not found" : "Bing Webmaster Tools verification not visible in the HTML",
17704
+ 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.",
17434
17705
  impact: "Missing insights on Bing/Yahoo search traffic.",
17435
- howToFix: "Add Bing Webmaster Tools verification for insights on Bing/Yahoo traffic. Get it from bing.com/webmasters.",
17436
- affectedUrls: [url]
17706
+ 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.",
17707
+ affectedUrls: [url],
17708
+ details: { dnsChecked }
17437
17709
  });
17438
17710
  }
17439
17711
  if (!hasGtm) {
17440
- issues.push({
17441
- code: "GTM_MISSING",
17442
- severity: "notice",
17443
- category: "on-page",
17444
- title: "Google Tag Manager not detected",
17445
- description: "GTM not found. While not required, GTM makes it easier to manage tracking tags without code changes.",
17446
- impact: "Tag management requires developer involvement for each change.",
17447
- howToFix: "Consider using GTM for easier tag management. It allows marketing to add tags without developer help.",
17448
- affectedUrls: [url]
17449
- });
17712
+ if (otherAnalytics.length === 0) {
17713
+ issues.push({
17714
+ code: "GTM_MISSING",
17715
+ severity: "notice",
17716
+ category: "on-page",
17717
+ title: "Google Tag Manager not detected",
17718
+ description: "GTM not found. While not required, GTM makes it easier to manage tracking tags without code changes.",
17719
+ impact: "Tag management requires developer involvement for each change.",
17720
+ howToFix: "Consider using GTM for easier tag management. It allows marketing to add tags without developer help.",
17721
+ affectedUrls: [url]
17722
+ });
17723
+ }
17450
17724
  } else if (!hasGtmNoscript) {
17451
17725
  issues.push({
17452
17726
  code: "GTM_NOSCRIPT_MISSING",
@@ -17586,7 +17860,6 @@ async function runFullAudit(options) {
17586
17860
  const urlSafetyResult = analyzeUrlSafety(url);
17587
17861
  const domSizeResult = analyzeDomSize(html, url);
17588
17862
  const imageDimensionsResult = analyzeImageDimensions(html, url);
17589
- const trackingResult = analyzeTrackingVerification(html, url);
17590
17863
  const anchorResult = runExtendedChecks ? analyzeAnchorText(html, url) : { issues: [], data: {} };
17591
17864
  const localSeoResult = runExtendedChecks ? analyzeLocalSEO(html, url) : { issues: [], data: {} };
17592
17865
  const modernImageResult = runExtendedChecks ? analyzeModernImages(html, url) : { issues: [], data: {} };
@@ -17661,8 +17934,7 @@ async function runFullAudit(options) {
17661
17934
  ...colorContrastResult.issues,
17662
17935
  ...pageResourcesResult.issues,
17663
17936
  ...urlSafetyResult.issues,
17664
- ...paginationResult.issues,
17665
- ...trackingResult.issues
17937
+ ...paginationResult.issues
17666
17938
  );
17667
17939
  if (!structuredDataResult.data.hasSchema) {
17668
17940
  const suggestions = suggestSchemaTypes(html, url);
@@ -17708,6 +17980,8 @@ async function runFullAudit(options) {
17708
17980
  asyncChecks.push(safeAsyncIssues("Certificate", async () => (await checkCertificate(url)).issues));
17709
17981
  asyncChecks.push(safeAsync("Redirects", () => analyzeRedirects(url)));
17710
17982
  asyncChecks.push(safeAsync("Robots Validation", () => validateRobotsTxt(url)));
17983
+ asyncChecks.push(safeAsync("Tracking Verification", () => analyzeTrackingVerification(html, url)));
17984
+ asyncChecks.push(safeAsyncIssues("Crawler Access", async () => (await analyzeCrawlerAccess(url)).issues));
17711
17985
  asyncChecks.push(safeAsync("Canonical Domain", () => analyzeCanonicalDomain(url)));
17712
17986
  asyncChecks.push(safeAsyncIssues("Framework Detection", async () => {
17713
17987
  const frameworkInfo = detectFramework({ html, headers });
@@ -33095,9 +33369,37 @@ function jsonLdConcerns(html) {
33095
33369
  }
33096
33370
  return bad;
33097
33371
  }
33372
+ var PLACEHOLDERS = [
33373
+ { pattern: /\+?1[-\s.]?\(?800\)?[-\s.]?555[-\s.]?\d{4}/i, what: "a reserved 555 phone number" },
33374
+ { pattern: /\b555[-\s.]?01\d{2}\b/, what: "a reserved 555 phone number" },
33375
+ { pattern: /\bAnytown\b/i, what: '"Anytown"' },
33376
+ { pattern: /\b123 Main (Street|St)\b/i, what: '"123 Main Street"' },
33377
+ { pattern: /your_[a-z_]+/i, what: 'an unfilled "your_..." placeholder' },
33378
+ { pattern: /\b(yourdomain|your-domain|example)\.(com|org|net)\b/i, what: "a placeholder domain" },
33379
+ { pattern: /\blorem ipsum\b/i, what: "lorem ipsum" },
33380
+ { pattern: /\bXXX-XXX-XXXX\b/i, what: "an unfilled phone template" }
33381
+ ];
33382
+ function isSharedShell(html) {
33383
+ return /<div[^>]+id=["'](root|app|__next|__nuxt)["']/i.test(html);
33384
+ }
33098
33385
  function assessReplacement(path3, original, replacement) {
33099
33386
  const concerns = [];
33100
33387
  const add = (code, detail) => concerns.push({ code, detail });
33388
+ for (const { pattern, what } of PLACEHOLDERS) {
33389
+ const hit = replacement.match(pattern);
33390
+ if (hit && !(original && pattern.test(original))) {
33391
+ add("placeholder_content", `contains ${what} (${hit[0]})`);
33392
+ }
33393
+ }
33394
+ if (/\.html?$/i.test(path3) && isSharedShell(replacement)) {
33395
+ const addsCanonical = /<link[^>]+rel=["']canonical["']/i.test(replacement) && !(original && /<link[^>]+rel=["']canonical["']/i.test(original));
33396
+ if (addsCanonical) {
33397
+ add(
33398
+ "canonical_in_shared_shell",
33399
+ `${path3} mounts a single-page app, so a canonical here applies to every route`
33400
+ );
33401
+ }
33402
+ }
33101
33403
  if (/\.json$/i.test(path3) || /\.webmanifest$/i.test(path3)) {
33102
33404
  try {
33103
33405
  JSON.parse(replacement);