@rankcli/agent-runtime 0.0.36 → 0.0.37

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.d.mts CHANGED
@@ -3106,14 +3106,9 @@ declare function servedDir(framework: Framework | undefined, files: string[], ap
3106
3106
 
3107
3107
  interface ReplacementConcern {
3108
3108
  /** Machine-readable, for logs and the PR body. */
3109
- code: 'shrunk' | 'lost_closing_tag' | 'lost_script' | 'lost_stylesheet' | 'lost_verification' | 'lost_import' | 'invalid_json' | 'invalid_xml' | 'invalid_json_ld' | 'fewer_sitemap_urls' | 'lastmod_moved_backwards';
3109
+ code: 'shrunk' | 'lost_closing_tag' | 'lost_script' | 'lost_stylesheet' | 'lost_verification' | 'lost_import' | 'invalid_json' | 'invalid_xml' | 'invalid_json_ld' | 'fewer_sitemap_urls' | 'lastmod_moved_backwards' | 'placeholder_content' | 'canonical_in_shared_shell';
3110
3110
  detail: string;
3111
3111
  }
3112
- /**
3113
- * Everything wrong with replacing `original` with `replacement` at `path`. Empty means safe
3114
- * to commit. `original` is null for a file being created, where there is nothing to lose and
3115
- * only the content's own validity matters.
3116
- */
3117
3112
  declare function assessReplacement(path: string, original: string | null, replacement: string): ReplacementConcern[];
3118
3113
  /** One line naming why a file was dropped, for a log line or a PR body. */
3119
3114
  declare function describeConcerns(concerns: ReplacementConcern[]): string;
package/dist/index.d.ts CHANGED
@@ -3106,14 +3106,9 @@ declare function servedDir(framework: Framework | undefined, files: string[], ap
3106
3106
 
3107
3107
  interface ReplacementConcern {
3108
3108
  /** Machine-readable, for logs and the PR body. */
3109
- code: 'shrunk' | 'lost_closing_tag' | 'lost_script' | 'lost_stylesheet' | 'lost_verification' | 'lost_import' | 'invalid_json' | 'invalid_xml' | 'invalid_json_ld' | 'fewer_sitemap_urls' | 'lastmod_moved_backwards';
3109
+ code: 'shrunk' | 'lost_closing_tag' | 'lost_script' | 'lost_stylesheet' | 'lost_verification' | 'lost_import' | 'invalid_json' | 'invalid_xml' | 'invalid_json_ld' | 'fewer_sitemap_urls' | 'lastmod_moved_backwards' | 'placeholder_content' | 'canonical_in_shared_shell';
3110
3110
  detail: string;
3111
3111
  }
3112
- /**
3113
- * Everything wrong with replacing `original` with `replacement` at `path`. Empty means safe
3114
- * to commit. `original` is null for a file being created, where there is nothing to lose and
3115
- * only the content's own validity matters.
3116
- */
3117
3112
  declare function assessReplacement(path: string, original: string | null, replacement: string): ReplacementConcern[];
3118
3113
  /** One line naming why a file was dropped, for a log line or a PR body. */
3119
3114
  declare function describeConcerns(concerns: ReplacementConcern[]): string;
package/dist/index.js CHANGED
@@ -10401,6 +10401,121 @@ async function checkInternalRedirects(internalLinks, batchSize = 10) {
10401
10401
  return { redirectLinks };
10402
10402
  }
10403
10403
 
10404
+ // src/audit/checks/crawler-access.ts
10405
+ init_http();
10406
+ var CRAWLERS = [
10407
+ {
10408
+ name: "Googlebot",
10409
+ userAgent: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
10410
+ matters: "Google Search indexing",
10411
+ critical: true
10412
+ },
10413
+ {
10414
+ name: "bingbot",
10415
+ userAgent: "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
10416
+ matters: "Bing indexing, and Copilot answers",
10417
+ critical: true
10418
+ },
10419
+ {
10420
+ name: "GPTBot",
10421
+ userAgent: "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)",
10422
+ matters: "ChatGPT being able to read and cite the page",
10423
+ critical: false
10424
+ },
10425
+ {
10426
+ name: "ClaudeBot",
10427
+ userAgent: "Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)",
10428
+ matters: "Claude being able to read and cite the page",
10429
+ critical: false
10430
+ },
10431
+ {
10432
+ name: "PerplexityBot",
10433
+ userAgent: "Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)",
10434
+ matters: "Perplexity being able to read and cite the page",
10435
+ critical: false
10436
+ }
10437
+ ];
10438
+ 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";
10439
+ function isRefusal(status) {
10440
+ return status === 401 || status === 403 || status === 429 || status === 503;
10441
+ }
10442
+ async function statusFor(url, userAgent) {
10443
+ try {
10444
+ const response = await httpGet(url, {
10445
+ headers: { "User-Agent": userAgent },
10446
+ timeout: 15e3,
10447
+ // We want the status itself, not an exception on 4xx/5xx.
10448
+ validateStatus: () => true
10449
+ });
10450
+ return response.status;
10451
+ } catch {
10452
+ return null;
10453
+ }
10454
+ }
10455
+ async function analyzeCrawlerAccess(url) {
10456
+ const issues = [];
10457
+ const baselineStatus = await statusFor(url, BROWSER_UA);
10458
+ const data = { baselineStatus, crawlers: [] };
10459
+ if (baselineStatus === null || baselineStatus >= 400) {
10460
+ return { issues, data };
10461
+ }
10462
+ const results = await Promise.all(
10463
+ CRAWLERS.map(async (crawler) => ({
10464
+ crawler,
10465
+ status: await statusFor(url, crawler.userAgent)
10466
+ }))
10467
+ );
10468
+ const blocked = results.filter(({ status }) => status !== null && isRefusal(status));
10469
+ data.crawlers = results.map(({ crawler, status }) => ({
10470
+ name: crawler.name,
10471
+ status,
10472
+ blocked: status !== null && isRefusal(status)
10473
+ }));
10474
+ if (blocked.length === 0) return { issues, data };
10475
+ const search = blocked.filter(({ crawler }) => crawler.critical);
10476
+ const ai = blocked.filter(({ crawler }) => !crawler.critical);
10477
+ if (search.length > 0) {
10478
+ const names = search.map(({ crawler }) => crawler.name).join(", ");
10479
+ issues.push({
10480
+ code: "SEARCH_CRAWLER_BLOCKED_AT_EDGE",
10481
+ severity: "error",
10482
+ category: "crawlability",
10483
+ title: `${names} refused at the edge, not by robots.txt`,
10484
+ 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.`,
10485
+ 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.",
10486
+ howToFix: `Check your CDN or WAF bot rules rather than robots.txt.
10487
+
10488
+ 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.
10489
+
10490
+ Confirm with Search Console's URL Inspection, which fetches as the verified Googlebot.`,
10491
+ affectedUrls: [url],
10492
+ details: {
10493
+ baselineStatus,
10494
+ blocked: search.map(({ crawler, status }) => ({ crawler: crawler.name, status })),
10495
+ note: "Observed with the crawler\u2019s user-agent. Real crawlers are also verified by IP, so a site may treat them differently."
10496
+ }
10497
+ });
10498
+ }
10499
+ if (ai.length > 0) {
10500
+ const names = ai.map(({ crawler }) => crawler.name).join(", ");
10501
+ issues.push({
10502
+ code: "AI_CRAWLER_BLOCKED_AT_EDGE",
10503
+ severity: "warning",
10504
+ category: "ai-readiness",
10505
+ title: `${names} refused at the edge, not by robots.txt`,
10506
+ 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.`,
10507
+ 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.",
10508
+ 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.`,
10509
+ affectedUrls: [url],
10510
+ details: {
10511
+ baselineStatus,
10512
+ blocked: ai.map(({ crawler, status }) => ({ crawler: crawler.name, status }))
10513
+ }
10514
+ });
10515
+ }
10516
+ return { issues, data };
10517
+ }
10518
+
10404
10519
  // src/audit/checks/ai-readiness.ts
10405
10520
  init_http();
10406
10521
  var cheerio13 = __toESM(require("cheerio"));
@@ -17708,6 +17823,7 @@ async function runFullAudit(options) {
17708
17823
  asyncChecks.push(safeAsyncIssues("Certificate", async () => (await checkCertificate(url)).issues));
17709
17824
  asyncChecks.push(safeAsync("Redirects", () => analyzeRedirects(url)));
17710
17825
  asyncChecks.push(safeAsync("Robots Validation", () => validateRobotsTxt(url)));
17826
+ asyncChecks.push(safeAsyncIssues("Crawler Access", async () => (await analyzeCrawlerAccess(url)).issues));
17711
17827
  asyncChecks.push(safeAsync("Canonical Domain", () => analyzeCanonicalDomain(url)));
17712
17828
  asyncChecks.push(safeAsyncIssues("Framework Detection", async () => {
17713
17829
  const frameworkInfo = detectFramework({ html, headers });
@@ -33095,9 +33211,37 @@ function jsonLdConcerns(html) {
33095
33211
  }
33096
33212
  return bad;
33097
33213
  }
33214
+ var PLACEHOLDERS = [
33215
+ { pattern: /\+?1[-\s.]?\(?800\)?[-\s.]?555[-\s.]?\d{4}/i, what: "a reserved 555 phone number" },
33216
+ { pattern: /\b555[-\s.]?01\d{2}\b/, what: "a reserved 555 phone number" },
33217
+ { pattern: /\bAnytown\b/i, what: '"Anytown"' },
33218
+ { pattern: /\b123 Main (Street|St)\b/i, what: '"123 Main Street"' },
33219
+ { pattern: /your_[a-z_]+/i, what: 'an unfilled "your_..." placeholder' },
33220
+ { pattern: /\b(yourdomain|your-domain|example)\.(com|org|net)\b/i, what: "a placeholder domain" },
33221
+ { pattern: /\blorem ipsum\b/i, what: "lorem ipsum" },
33222
+ { pattern: /\bXXX-XXX-XXXX\b/i, what: "an unfilled phone template" }
33223
+ ];
33224
+ function isSharedShell(html) {
33225
+ return /<div[^>]+id=["'](root|app|__next|__nuxt)["']/i.test(html);
33226
+ }
33098
33227
  function assessReplacement(path3, original, replacement) {
33099
33228
  const concerns = [];
33100
33229
  const add = (code, detail) => concerns.push({ code, detail });
33230
+ for (const { pattern, what } of PLACEHOLDERS) {
33231
+ const hit = replacement.match(pattern);
33232
+ if (hit && !(original && pattern.test(original))) {
33233
+ add("placeholder_content", `contains ${what} (${hit[0]})`);
33234
+ }
33235
+ }
33236
+ if (/\.html?$/i.test(path3) && isSharedShell(replacement)) {
33237
+ const addsCanonical = /<link[^>]+rel=["']canonical["']/i.test(replacement) && !(original && /<link[^>]+rel=["']canonical["']/i.test(original));
33238
+ if (addsCanonical) {
33239
+ add(
33240
+ "canonical_in_shared_shell",
33241
+ `${path3} mounts a single-page app, so a canonical here applies to every route`
33242
+ );
33243
+ }
33244
+ }
33101
33245
  if (/\.json$/i.test(path3) || /\.webmanifest$/i.test(path3)) {
33102
33246
  try {
33103
33247
  JSON.parse(replacement);
package/dist/index.mjs CHANGED
@@ -6862,6 +6862,120 @@ async function checkInternalRedirects(internalLinks, batchSize = 10) {
6862
6862
  return { redirectLinks };
6863
6863
  }
6864
6864
 
6865
+ // src/audit/checks/crawler-access.ts
6866
+ var CRAWLERS = [
6867
+ {
6868
+ name: "Googlebot",
6869
+ userAgent: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
6870
+ matters: "Google Search indexing",
6871
+ critical: true
6872
+ },
6873
+ {
6874
+ name: "bingbot",
6875
+ userAgent: "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
6876
+ matters: "Bing indexing, and Copilot answers",
6877
+ critical: true
6878
+ },
6879
+ {
6880
+ name: "GPTBot",
6881
+ userAgent: "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)",
6882
+ matters: "ChatGPT being able to read and cite the page",
6883
+ critical: false
6884
+ },
6885
+ {
6886
+ name: "ClaudeBot",
6887
+ userAgent: "Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)",
6888
+ matters: "Claude being able to read and cite the page",
6889
+ critical: false
6890
+ },
6891
+ {
6892
+ name: "PerplexityBot",
6893
+ userAgent: "Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)",
6894
+ matters: "Perplexity being able to read and cite the page",
6895
+ critical: false
6896
+ }
6897
+ ];
6898
+ 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";
6899
+ function isRefusal(status) {
6900
+ return status === 401 || status === 403 || status === 429 || status === 503;
6901
+ }
6902
+ async function statusFor(url, userAgent) {
6903
+ try {
6904
+ const response = await httpGet(url, {
6905
+ headers: { "User-Agent": userAgent },
6906
+ timeout: 15e3,
6907
+ // We want the status itself, not an exception on 4xx/5xx.
6908
+ validateStatus: () => true
6909
+ });
6910
+ return response.status;
6911
+ } catch {
6912
+ return null;
6913
+ }
6914
+ }
6915
+ async function analyzeCrawlerAccess(url) {
6916
+ const issues = [];
6917
+ const baselineStatus = await statusFor(url, BROWSER_UA);
6918
+ const data = { baselineStatus, crawlers: [] };
6919
+ if (baselineStatus === null || baselineStatus >= 400) {
6920
+ return { issues, data };
6921
+ }
6922
+ const results = await Promise.all(
6923
+ CRAWLERS.map(async (crawler) => ({
6924
+ crawler,
6925
+ status: await statusFor(url, crawler.userAgent)
6926
+ }))
6927
+ );
6928
+ const blocked = results.filter(({ status }) => status !== null && isRefusal(status));
6929
+ data.crawlers = results.map(({ crawler, status }) => ({
6930
+ name: crawler.name,
6931
+ status,
6932
+ blocked: status !== null && isRefusal(status)
6933
+ }));
6934
+ if (blocked.length === 0) return { issues, data };
6935
+ const search = blocked.filter(({ crawler }) => crawler.critical);
6936
+ const ai = blocked.filter(({ crawler }) => !crawler.critical);
6937
+ if (search.length > 0) {
6938
+ const names = search.map(({ crawler }) => crawler.name).join(", ");
6939
+ issues.push({
6940
+ code: "SEARCH_CRAWLER_BLOCKED_AT_EDGE",
6941
+ severity: "error",
6942
+ category: "crawlability",
6943
+ title: `${names} refused at the edge, not by robots.txt`,
6944
+ 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.`,
6945
+ 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.",
6946
+ howToFix: `Check your CDN or WAF bot rules rather than robots.txt.
6947
+
6948
+ 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.
6949
+
6950
+ Confirm with Search Console's URL Inspection, which fetches as the verified Googlebot.`,
6951
+ affectedUrls: [url],
6952
+ details: {
6953
+ baselineStatus,
6954
+ blocked: search.map(({ crawler, status }) => ({ crawler: crawler.name, status })),
6955
+ note: "Observed with the crawler\u2019s user-agent. Real crawlers are also verified by IP, so a site may treat them differently."
6956
+ }
6957
+ });
6958
+ }
6959
+ if (ai.length > 0) {
6960
+ const names = ai.map(({ crawler }) => crawler.name).join(", ");
6961
+ issues.push({
6962
+ code: "AI_CRAWLER_BLOCKED_AT_EDGE",
6963
+ severity: "warning",
6964
+ category: "ai-readiness",
6965
+ title: `${names} refused at the edge, not by robots.txt`,
6966
+ 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.`,
6967
+ 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.",
6968
+ 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.`,
6969
+ affectedUrls: [url],
6970
+ details: {
6971
+ baselineStatus,
6972
+ blocked: ai.map(({ crawler, status }) => ({ crawler: crawler.name, status }))
6973
+ }
6974
+ });
6975
+ }
6976
+ return { issues, data };
6977
+ }
6978
+
6865
6979
  // src/audit/checks/ai-readiness.ts
6866
6980
  import * as cheerio13 from "cheerio";
6867
6981
 
@@ -14151,6 +14265,7 @@ async function runFullAudit(options) {
14151
14265
  asyncChecks.push(safeAsyncIssues("Certificate", async () => (await checkCertificate(url)).issues));
14152
14266
  asyncChecks.push(safeAsync("Redirects", () => analyzeRedirects(url)));
14153
14267
  asyncChecks.push(safeAsync("Robots Validation", () => validateRobotsTxt(url)));
14268
+ asyncChecks.push(safeAsyncIssues("Crawler Access", async () => (await analyzeCrawlerAccess(url)).issues));
14154
14269
  asyncChecks.push(safeAsync("Canonical Domain", () => analyzeCanonicalDomain(url)));
14155
14270
  asyncChecks.push(safeAsyncIssues("Framework Detection", async () => {
14156
14271
  const frameworkInfo = detectFramework({ html, headers });
@@ -29523,9 +29638,37 @@ function jsonLdConcerns(html) {
29523
29638
  }
29524
29639
  return bad;
29525
29640
  }
29641
+ var PLACEHOLDERS = [
29642
+ { pattern: /\+?1[-\s.]?\(?800\)?[-\s.]?555[-\s.]?\d{4}/i, what: "a reserved 555 phone number" },
29643
+ { pattern: /\b555[-\s.]?01\d{2}\b/, what: "a reserved 555 phone number" },
29644
+ { pattern: /\bAnytown\b/i, what: '"Anytown"' },
29645
+ { pattern: /\b123 Main (Street|St)\b/i, what: '"123 Main Street"' },
29646
+ { pattern: /your_[a-z_]+/i, what: 'an unfilled "your_..." placeholder' },
29647
+ { pattern: /\b(yourdomain|your-domain|example)\.(com|org|net)\b/i, what: "a placeholder domain" },
29648
+ { pattern: /\blorem ipsum\b/i, what: "lorem ipsum" },
29649
+ { pattern: /\bXXX-XXX-XXXX\b/i, what: "an unfilled phone template" }
29650
+ ];
29651
+ function isSharedShell(html) {
29652
+ return /<div[^>]+id=["'](root|app|__next|__nuxt)["']/i.test(html);
29653
+ }
29526
29654
  function assessReplacement(path3, original, replacement) {
29527
29655
  const concerns = [];
29528
29656
  const add = (code, detail) => concerns.push({ code, detail });
29657
+ for (const { pattern, what } of PLACEHOLDERS) {
29658
+ const hit = replacement.match(pattern);
29659
+ if (hit && !(original && pattern.test(original))) {
29660
+ add("placeholder_content", `contains ${what} (${hit[0]})`);
29661
+ }
29662
+ }
29663
+ if (/\.html?$/i.test(path3) && isSharedShell(replacement)) {
29664
+ const addsCanonical = /<link[^>]+rel=["']canonical["']/i.test(replacement) && !(original && /<link[^>]+rel=["']canonical["']/i.test(original));
29665
+ if (addsCanonical) {
29666
+ add(
29667
+ "canonical_in_shared_shell",
29668
+ `${path3} mounts a single-page app, so a canonical here applies to every route`
29669
+ );
29670
+ }
29671
+ }
29529
29672
  if (/\.json$/i.test(path3) || /\.webmanifest$/i.test(path3)) {
29530
29673
  try {
29531
29674
  JSON.parse(replacement);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rankcli/agent-runtime",
3
- "version": "0.0.36",
3
+ "version": "0.0.37",
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",
@@ -28,7 +28,8 @@
28
28
  "test": "vitest run",
29
29
  "test:watch": "vitest",
30
30
  "test:coverage": "vitest run --coverage",
31
- "benchmark:detection": "node scripts/benchmark-detection.mjs"
31
+ "benchmark:detection": "node scripts/benchmark-detection.mjs",
32
+ "typecheck": "tsc --noEmit"
32
33
  },
33
34
  "dependencies": {
34
35
  "@anthropic-ai/sdk": "^0.52.0",