@rankcli/agent-runtime 0.0.38 → 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
@@ -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],
@@ -7717,7 +7717,7 @@ function analyzeLocalSEO(html, url) {
7717
7717
  const detectedPhones = detectPhones(html);
7718
7718
  const detectedAddresses = detectAddresses(html);
7719
7719
  const socialProfiles = detectSocialProfiles(html);
7720
- if (!hasLocalBusiness) {
7720
+ if (!hasLocalBusiness && (detectedPhones.length > 0 || detectedAddresses.length > 0)) {
7721
7721
  issues.push({
7722
7722
  ...ISSUE_DEFINITIONS.LOCAL_BUSINESS_SCHEMA_MISSING,
7723
7723
  affectedUrls: [url]
@@ -8778,7 +8778,17 @@ function analyzeKeywords(html, url) {
8778
8778
  const sortPhrases = (phrases, n) => {
8779
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 }));
8780
8780
  };
8781
- 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) : [];
8782
8792
  if (potentialStuffing.length > 0) {
8783
8793
  issues.push({
8784
8794
  ...ISSUE_DEFINITIONS.KEYWORD_STUFFING,
@@ -9004,13 +9014,6 @@ function checkAMP(html, url) {
9004
9014
  }
9005
9015
  };
9006
9016
  }
9007
- const isArticle = $("article").length > 0 || $('meta[property="og:type"][content="article"]').length > 0 || html.toLowerCase().includes("blog") || html.toLowerCase().includes("news");
9008
- if (isArticle) {
9009
- issues.push({
9010
- ...ISSUE_DEFINITIONS.AMP_MISSING,
9011
- affectedUrls: [url]
9012
- });
9013
- }
9014
9017
  return {
9015
9018
  issues,
9016
9019
  data: {
@@ -9719,13 +9722,13 @@ async function analyzeSiteMaturity(html, url) {
9719
9722
  contentToAuthorityRatio
9720
9723
  };
9721
9724
  const { strategy, recommendations } = determineStrategy(maturityLevel, authorityIndicators, contentMetrics);
9722
- if (maturityLevel === "new") {
9725
+ if (maturityLevel === "new" && ageSource === "wayback" && estimatedAgeDays !== null) {
9723
9726
  issues.push({
9724
9727
  code: "NEW_SITE_STRATEGY",
9725
9728
  severity: "notice",
9726
9729
  category: "content",
9727
9730
  title: "New website detected - Authority-first strategy recommended",
9728
- 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.`,
9729
9732
  impact: "Creating lots of content without authority is wasted effort. Content from new sites rarely ranks.",
9730
9733
  howToFix: "Focus on building 10+ quality backlinks to ONE great page before expanding content. See strategic recommendations.",
9731
9734
  affectedUrls: [url],
@@ -9736,24 +9739,7 @@ async function analyzeSiteMaturity(html, url) {
9736
9739
  }
9737
9740
  });
9738
9741
  }
9739
- if (contentToAuthorityRatio === "content-heavy") {
9740
- issues.push({
9741
- code: "CONTENT_AUTHORITY_IMBALANCE",
9742
- severity: "warning",
9743
- category: "content",
9744
- title: "Content-to-authority imbalance",
9745
- description: `Site has ~${indexedPages} pages but limited backlink signals. Content is unlikely to rank.`,
9746
- impact: "Most of your content may be invisible to search engines due to low domain authority.",
9747
- howToFix: "Stop publishing new content. Focus 100% on link building until you see ranking improvements.",
9748
- affectedUrls: [url],
9749
- details: {
9750
- indexedPages,
9751
- contentToAuthorityRatio,
9752
- maturityLevel
9753
- }
9754
- });
9755
- }
9756
- if (maturityLevel === "growing" && !htmlSignals.hasBlog) {
9742
+ if (maturityLevel === "growing" && ageSource === "wayback" && !htmlSignals.hasBlog) {
9757
9743
  issues.push({
9758
9744
  code: "GROWING_SITE_NO_BLOG",
9759
9745
  severity: "notice",
@@ -10247,8 +10233,8 @@ function analyzeRendering(html) {
10247
10233
  const hasSemanticStructure = hasH1 && (hasMain || hasArticle);
10248
10234
  const title = $("title").text().trim();
10249
10235
  const h1Text = $("h1").first().text().trim();
10250
- const firstParagraph = $("p").first().text().trim();
10251
- 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;
10252
10238
  const EXPECTED_MIN_CONTENT = 1e3;
10253
10239
  const htmlContentRatio = Math.min(100, mainContentCharCount / EXPECTED_MIN_CONTENT * 100);
10254
10240
  let aiReadableScore = 50;
@@ -10468,9 +10454,9 @@ function analyzeClientRendering(html, url) {
10468
10454
  howToFix: "Ensure the initial HTML contains: meaningful <title>, descriptive <h1>, and a substantial first paragraph with your key message.",
10469
10455
  affectedUrls: [url],
10470
10456
  details: {
10471
- hasTitle: $("title").text().trim().length > 10,
10472
- hasH1: $("h1").text().trim().length > 5,
10473
- 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)
10474
10460
  }
10475
10461
  });
10476
10462
  }
@@ -11179,7 +11165,9 @@ async function analyzeBingOptimization(html, url) {
11179
11165
  detectedIssues.push("No Bing site verification");
11180
11166
  issues.push({
11181
11167
  code: "BING_NOT_VERIFIED",
11182
- 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",
11183
11171
  category: "ai-readiness",
11184
11172
  title: verification.dnsChecked ? "Site not verified with Bing Webmaster Tools" : "Bing Webmaster Tools verification not visible in the HTML",
11185
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.",
@@ -11230,7 +11218,7 @@ async function analyzeBingOptimization(html, url) {
11230
11218
  });
11231
11219
  }
11232
11220
  const hasLocalBusinessSchema = bingPlacesDetected;
11233
- 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;
11234
11222
  if (hasAddressOnPage && !hasLocalBusinessSchema) {
11235
11223
  detectedIssues.push("Local business may not be on Bing Places");
11236
11224
  issues.push({
@@ -12756,23 +12744,6 @@ function analyzeColorContrast(html, url) {
12756
12744
  });
12757
12745
  }
12758
12746
  }
12759
- if (cannotDetermine > elementsAnalyzed * 0.8) {
12760
- issues.push({
12761
- code: "A11Y_CONTRAST_UNDETERMINED",
12762
- severity: "notice",
12763
- category: "accessibility",
12764
- title: "Color contrast could not be fully analyzed",
12765
- description: "Most text colors are set via CSS stylesheets rather than inline styles. Full contrast checking requires browser rendering.",
12766
- impact: "Potential contrast issues may exist that were not detected.",
12767
- howToFix: "Use browser DevTools (Lighthouse accessibility audit) or dedicated tools like axe-core for comprehensive contrast checking.",
12768
- affectedUrls: [url],
12769
- details: {
12770
- elementsAnalyzed,
12771
- cannotDetermine,
12772
- recommendation: "Run Lighthouse accessibility audit for complete contrast analysis."
12773
- }
12774
- });
12775
- }
12776
12747
  return {
12777
12748
  issues,
12778
12749
  data: {
@@ -14094,7 +14065,8 @@ async function analyzeTrackingVerification(html, url) {
14094
14065
  if (!hasGa4 && !hasGtm && otherAnalytics.length === 0) {
14095
14066
  issues.push({
14096
14067
  code: "GA4_MISSING",
14097
- severity: "warning",
14068
+ // A notice: which analytics a site runs is a choice, not an SEO defect.
14069
+ severity: "notice",
14098
14070
  category: "on-page",
14099
14071
  title: "Google Analytics 4 not detected",
14100
14072
  description: "No GA4 tracking code found. GA4 is essential for measuring organic traffic and conversions.",
@@ -14107,7 +14079,8 @@ async function analyzeTrackingVerification(html, url) {
14107
14079
  const dnsChecked = verification.dnsChecked;
14108
14080
  issues.push({
14109
14081
  code: "GSC_VERIFICATION_MISSING",
14110
- severity: "warning",
14082
+ // A notice: a site verified by DNS, Google Analytics or Tag Manager leaves no trace here.
14083
+ severity: "notice",
14111
14084
  category: "on-page",
14112
14085
  title: dnsChecked ? "Google Search Console verification not found" : "Google Search Console verification not visible in the HTML",
14113
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.",
@@ -14182,6 +14155,113 @@ async function analyzeTrackingVerification(html, url) {
14182
14155
  return { issues, data };
14183
14156
  }
14184
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
+
14185
14265
  // src/audit/engine.ts
14186
14266
  function sameUrl(a, b) {
14187
14267
  try {
@@ -14194,15 +14274,15 @@ function sameUrl(a, b) {
14194
14274
  }
14195
14275
  }
14196
14276
  async function runFullAudit(options) {
14197
- 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;
14198
14279
  const auditRoot = isSiteRoot(url) || sameUrl(url, options.auditRootUrl ?? url);
14199
14280
  const allIssues = [];
14200
14281
  const pages = [];
14201
14282
  const tier = checksLimit <= 50 ? "free" : checksLimit <= 100 ? "registered" : "paid";
14202
14283
  const runPremiumChecks = tier === "paid" && !!premiumChecks;
14203
14284
  const runExtendedChecks = tier !== "free";
14204
- const parsedUrl = new URL(url);
14205
- const domain = parsedUrl.hostname;
14285
+ let domain = new URL(url).hostname;
14206
14286
  console.error(`
14207
14287
  \u{1F50D} Running comprehensive SEO audit on ${url}...
14208
14288
  `);
@@ -14229,7 +14309,9 @@ async function runFullAudit(options) {
14229
14309
  timeout: 3e4,
14230
14310
  validateStatus: () => true
14231
14311
  }).catch((err) => {
14232
- 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}`);
14233
14315
  return { error: err, data: "", headers: {} };
14234
14316
  })
14235
14317
  ]);
@@ -14252,7 +14334,7 @@ async function runFullAudit(options) {
14252
14334
  });
14253
14335
  return createReport(url, domain, allIssues, pages, { kind: "unreachable" });
14254
14336
  }
14255
- const statusCode = fetchResult.status;
14337
+ let statusCode = fetchResult.status;
14256
14338
  if (statusCode >= 400) {
14257
14339
  const blocked = statusCode === 401 || statusCode === 403 || statusCode === 429;
14258
14340
  allIssues.push({
@@ -14265,10 +14347,42 @@ async function runFullAudit(options) {
14265
14347
  pages.push({ url, statusCode, wordCount: 0, loadTime: 0, issues: allIssues.map((i) => i.code) });
14266
14348
  return createReport(url, domain, allIssues, pages, { kind: "http-error", statusCode });
14267
14349
  }
14268
- 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
+ }
14269
14384
  const html = body.replace(/<\/?noscript[^>]*>/gi, "");
14270
- const headers = fetchResult.headers;
14271
- console.error(`\u{1F4DD} Phase 2: Running synchronous HTML checks (tier: ${tier}, limit: ${checksLimit})...`);
14385
+ console.error("\u{1F4DD} Phase 2: Running HTML checks...");
14272
14386
  const onPageResult = analyzeOnPage(html, url, headers, { auditRoot });
14273
14387
  const structuredDataResult = analyzeStructuredData2(html, url);
14274
14388
  const mobileResult = analyzeMobile(html, url);
@@ -14435,10 +14549,10 @@ async function runFullAudit(options) {
14435
14549
  if (cruxApiKey) {
14436
14550
  asyncChecks.push(safeAsyncIssues("Core Web Vitals", async () => {
14437
14551
  const cwv = await fetchCoreWebVitals(url, cruxApiKey);
14438
- const issues = [];
14552
+ const issues2 = [];
14439
14553
  if (cwv) {
14440
14554
  if (cwv.lcp?.rating === "poor") {
14441
- issues.push({
14555
+ issues2.push({
14442
14556
  code: "LCP_POOR",
14443
14557
  severity: "error",
14444
14558
  category: "performance",
@@ -14451,7 +14565,7 @@ async function runFullAudit(options) {
14451
14565
  });
14452
14566
  }
14453
14567
  if (cwv.cls?.rating === "poor") {
14454
- issues.push({
14568
+ issues2.push({
14455
14569
  code: "CLS_POOR",
14456
14570
  severity: "error",
14457
14571
  category: "performance",
@@ -14464,14 +14578,15 @@ async function runFullAudit(options) {
14464
14578
  });
14465
14579
  }
14466
14580
  }
14467
- return issues;
14581
+ return issues2;
14468
14582
  }));
14469
14583
  }
14470
14584
  }
14471
14585
  const asyncResults = await Promise.all(asyncChecks);
14472
- for (const issues of asyncResults) {
14473
- allIssues.push(...issues);
14586
+ for (const issues2 of asyncResults) {
14587
+ allIssues.push(...issues2);
14474
14588
  }
14589
+ const issues = dedupeIssues(allIssues);
14475
14590
  pages.push({
14476
14591
  url,
14477
14592
  statusCode,
@@ -14481,10 +14596,10 @@ async function runFullAudit(options) {
14481
14596
  h1: onPageResult.data.h1s,
14482
14597
  wordCount: onPageResult.data.wordCount,
14483
14598
  loadTime: perfData.loadTime,
14484
- issues: allIssues.map((i) => i.code)
14599
+ issues: issues.map((i) => i.code)
14485
14600
  });
14486
14601
  console.error("\n\u2705 Audit complete!\n");
14487
- return createReport(url, domain, allIssues, pages);
14602
+ return createReport(url, domain, issues, pages);
14488
14603
  }
14489
14604
  function createReport(url, domain, issues, pages, pageFailure) {
14490
14605
  const healthScore = calculateHealthScore(issues);
@@ -14649,10 +14764,10 @@ function groupIssuesByCategory(issues) {
14649
14764
  }
14650
14765
 
14651
14766
  // src/audit/checks/duplicate-content.ts
14652
- import * as cheerio37 from "cheerio";
14767
+ import * as cheerio38 from "cheerio";
14653
14768
  import { createHash } from "crypto";
14654
14769
  function extractContentHash(html, url) {
14655
- const $ = cheerio37.load(html);
14770
+ const $ = cheerio38.load(html);
14656
14771
  $("script, style, nav, header, footer, aside, .nav, .header, .footer, .sidebar").remove();
14657
14772
  const title = $("title").text().trim();
14658
14773
  const bodyText = $("body").text().replace(/\s+/g, " ").trim();
@@ -15880,7 +15995,7 @@ var PRIORITY_WEIGHTS = {
15880
15995
  };
15881
15996
 
15882
15997
  // src/keywords/engine.ts
15883
- import * as cheerio39 from "cheerio";
15998
+ import * as cheerio40 from "cheerio";
15884
15999
 
15885
16000
  // src/keywords/prioritizer.ts
15886
16001
  function prioritizeKeywords(keywords, siteProfile, existingMeta) {
@@ -16174,7 +16289,7 @@ function enrichKeywordsWithEstimates(keywords) {
16174
16289
  }
16175
16290
 
16176
16291
  // src/keywords/sources/free-sources.ts
16177
- import * as cheerio38 from "cheerio";
16292
+ import * as cheerio39 from "cheerio";
16178
16293
  var USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
16179
16294
  async function getPeopleAlsoAsk(query) {
16180
16295
  try {
@@ -16183,7 +16298,7 @@ async function getPeopleAlsoAsk(query) {
16183
16298
  headers: { "User-Agent": USER_AGENT },
16184
16299
  timeout: 1e4
16185
16300
  });
16186
- const $ = cheerio38.load(response.data);
16301
+ const $ = cheerio39.load(response.data);
16187
16302
  const questions = [];
16188
16303
  $("[data-sgrd]").each((_, el) => {
16189
16304
  const text = $(el).text().trim();
@@ -16209,7 +16324,7 @@ async function getRelatedSearches(query) {
16209
16324
  headers: { "User-Agent": USER_AGENT },
16210
16325
  timeout: 1e4
16211
16326
  });
16212
- const $ = cheerio38.load(response.data);
16327
+ const $ = cheerio39.load(response.data);
16213
16328
  const related = [];
16214
16329
  $("div[data-ved] a").each((_, el) => {
16215
16330
  const href = $(el).attr("href");
@@ -16299,7 +16414,7 @@ async function analyzeCompetitorTitles(query) {
16299
16414
  headers: { "User-Agent": USER_AGENT },
16300
16415
  timeout: 1e4
16301
16416
  });
16302
- const $ = cheerio38.load(response.data);
16417
+ const $ = cheerio39.load(response.data);
16303
16418
  const titles = [];
16304
16419
  const keywords = /* @__PURE__ */ new Set();
16305
16420
  $("h3").each((_, el) => {
@@ -16631,7 +16746,7 @@ async function fetchPageMeta(url) {
16631
16746
  const response = await httpGet(url, {
16632
16747
  timeout: 1e4
16633
16748
  });
16634
- const $ = cheerio39.load(response.data);
16749
+ const $ = cheerio40.load(response.data);
16635
16750
  return {
16636
16751
  url,
16637
16752
  title: $("title").text().trim() || void 0,
@@ -16762,20 +16877,17 @@ async function extractSeedKeywords(url) {
16762
16877
  const response = await httpGet(url, {
16763
16878
  timeout: 1e4
16764
16879
  });
16765
- const $ = cheerio39.load(response.data);
16880
+ const $ = cheerio40.load(response.data);
16766
16881
  const seeds = /* @__PURE__ */ new Set();
16767
- const title = $("title").text().toLowerCase();
16768
- const titleWords = title.split(/[\s\-|:]+/).filter((w) => w.length > 3);
16769
- titleWords.forEach((w) => seeds.add(w));
16770
- const h1 = $("h1").first().text().toLowerCase();
16771
- const h1Words = h1.split(/[\s\-|:]+/).filter((w) => w.length > 3);
16772
- 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));
16773
16885
  const metaKeywords = $('meta[name="keywords"]').attr("content");
16774
16886
  if (metaKeywords) {
16775
16887
  metaKeywords.split(",").forEach((k) => seeds.add(k.trim().toLowerCase()));
16776
16888
  }
16777
16889
  const desc = $('meta[name="description"]').attr("content")?.toLowerCase() || "";
16778
- const descWords = desc.split(/\s+/).filter((w) => w.length > 5);
16890
+ const descWords = words(desc).filter((w) => w.length > 5);
16779
16891
  descWords.slice(0, 5).forEach((w) => seeds.add(w));
16780
16892
  return Array.from(seeds).slice(0, 10);
16781
16893
  } catch {
@@ -16828,7 +16940,7 @@ function parseJsonReply(text) {
16828
16940
  }
16829
16941
 
16830
16942
  // src/keywords/site-crawler.ts
16831
- import * as cheerio40 from "cheerio";
16943
+ import * as cheerio41 from "cheerio";
16832
16944
  var EXCLUDED_PATHS = [
16833
16945
  "/cdn-cgi/",
16834
16946
  "/wp-admin/",
@@ -16940,7 +17052,7 @@ async function crawlPage(url, timeout) {
16940
17052
  validateStatus: (status) => status === 200
16941
17053
  });
16942
17054
  const html = response.data;
16943
- const $ = cheerio40.load(html);
17055
+ const $ = cheerio41.load(html);
16944
17056
  $('script, style, noscript, iframe, nav, footer, header, aside, [role="navigation"]').remove();
16945
17057
  const title = $("title").text().trim();
16946
17058
  const description = $('meta[name="description"]').attr("content")?.trim() || "";
@@ -19575,7 +19687,7 @@ function getDateRange(days = 28) {
19575
19687
  }
19576
19688
 
19577
19689
  // src/keywords/sources/competitor-analysis.ts
19578
- import * as cheerio41 from "cheerio";
19690
+ import * as cheerio42 from "cheerio";
19579
19691
  var USER_AGENT2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
19580
19692
  async function discoverCompetitorKeywords(yourDomain, seedKeywords, competitors) {
19581
19693
  const yourKeywords = /* @__PURE__ */ new Set();
@@ -19672,7 +19784,7 @@ async function analyzeSERP(query) {
19672
19784
  headers: { "User-Agent": USER_AGENT2 },
19673
19785
  timeout: 1e4
19674
19786
  });
19675
- const $ = cheerio41.load(response.data);
19787
+ const $ = cheerio42.load(response.data);
19676
19788
  const results = [];
19677
19789
  const relatedSearches = [];
19678
19790
  const peopleAlsoAsk = [];
@@ -20125,7 +20237,7 @@ function formatTopicReport(result) {
20125
20237
 
20126
20238
  // src/content/readability.ts
20127
20239
  function analyzeReadability(text) {
20128
- 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();
20129
20241
  const words = getWords(cleanText);
20130
20242
  const sentences = getSentences(cleanText);
20131
20243
  const paragraphs = getParagraphs(cleanText);
@@ -20194,7 +20306,7 @@ function getSentences(text) {
20194
20306
  return text.split(/[.!?]+/).filter((s) => s.trim().length > 0);
20195
20307
  }
20196
20308
  function getParagraphs(text) {
20197
- 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);
20198
20310
  }
20199
20311
  function countSyllables(word) {
20200
20312
  word = word.toLowerCase().replace(/[^a-z]/g, "");
@@ -32462,6 +32574,7 @@ export {
32462
32574
  LOCATION_CODES,
32463
32575
  OG_IMAGE_SPECS,
32464
32576
  PRIORITY_WEIGHTS,
32577
+ REDUNDANT_WHEN_PRESENT,
32465
32578
  RankTracker,
32466
32579
  SEO_SCOPES,
32467
32580
  SITE_PROFILE_QUESTIONS,
@@ -32545,6 +32658,7 @@ export {
32545
32658
  createPullRequest,
32546
32659
  createSEOCommit,
32547
32660
  createSEOCommits,
32661
+ dedupeIssues,
32548
32662
  describeConcerns,
32549
32663
  detectDuplicates,
32550
32664
  detectFramework4 as detectFramework,
@@ -32681,6 +32795,7 @@ export {
32681
32795
  loadAgent,
32682
32796
  loadAgentByName,
32683
32797
  mergePages,
32798
+ metaRefreshTarget,
32684
32799
  openAIKeywordLLM,
32685
32800
  optimizeForAI,
32686
32801
  optimizeReadme,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rankcli/agent-runtime",
3
- "version": "0.0.38",
3
+ "version": "0.0.39",
4
4
  "description": "RankCLI agent runtime - executes SEO audits and fixes with AI",
5
5
  "homepage": "https://rankcli.dev",
6
6
  "main": "dist/index.js",