@rankcli/agent-runtime 0.0.20 → 0.0.21

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
@@ -269,6 +269,14 @@ interface AuditOptions {
269
269
  * checksLimit alone cannot unlock premium checks, regardless of its value.
270
270
  */
271
271
  premiumChecks?: PremiumChecks;
272
+ /**
273
+ * The URL the user asked to audit, when `url` is one of several pages
274
+ * being audited on its behalf (the CLI audits discovered pages one
275
+ * runFullAudit call at a time). A noindex on this page - or on the site's
276
+ * homepage - is an error; on any other page it's a warning. Defaults to
277
+ * `url` itself.
278
+ */
279
+ auditRootUrl?: string;
272
280
  }
273
281
  declare function runFullAudit(options: AuditOptions): Promise<AuditReport>;
274
282
  declare function formatReport(report: AuditReport): string;
@@ -306,8 +314,18 @@ interface OnPageData {
306
314
  textToHtmlRatio: number;
307
315
  hasNoindex: boolean;
308
316
  metaRobots?: string;
317
+ /** X-Robots-Tag response header value, when the caller passed headers */
318
+ xRobotsTag?: string;
319
+ }
320
+ interface OnPageOptions {
321
+ /**
322
+ * Whether this page is the root of what's being audited (the URL the user
323
+ * asked for). Noindex there is an error rather than a warning. Defaults to
324
+ * "is the URL the site's homepage".
325
+ */
326
+ auditRoot?: boolean;
309
327
  }
310
- declare function analyzeOnPage(html: string, url: string): {
328
+ declare function analyzeOnPage(html: string, url: string, headers?: Record<string, string>, options?: OnPageOptions): {
311
329
  issues: AuditIssue[];
312
330
  data: OnPageData;
313
331
  };
@@ -2760,6 +2778,14 @@ interface WorkflowConfig {
2760
2778
  }
2761
2779
  /**
2762
2780
  * Generate GitHub Action workflow YAML
2781
+ *
2782
+ * RANKCLI_API_KEY is scoped to the rankcli steps only (never job- or
2783
+ * workflow-level env, where third-party actions like create-pull-request
2784
+ * would see it too). Every rankcli step writes JSON even when it fails,
2785
+ * and every reader parses defensively, so one failed step can't cascade
2786
+ * into a JSON.parse('') crash. Report files go to $RUNNER_TEMP, outside
2787
+ * the checkout: in the workspace, create-pull-request committed them into
2788
+ * every auto-fix PR as untracked files.
2763
2789
  */
2764
2790
  declare function generateWorkflow(config: WorkflowConfig): string;
2765
2791
  /**
package/dist/index.d.ts CHANGED
@@ -269,6 +269,14 @@ interface AuditOptions {
269
269
  * checksLimit alone cannot unlock premium checks, regardless of its value.
270
270
  */
271
271
  premiumChecks?: PremiumChecks;
272
+ /**
273
+ * The URL the user asked to audit, when `url` is one of several pages
274
+ * being audited on its behalf (the CLI audits discovered pages one
275
+ * runFullAudit call at a time). A noindex on this page - or on the site's
276
+ * homepage - is an error; on any other page it's a warning. Defaults to
277
+ * `url` itself.
278
+ */
279
+ auditRootUrl?: string;
272
280
  }
273
281
  declare function runFullAudit(options: AuditOptions): Promise<AuditReport>;
274
282
  declare function formatReport(report: AuditReport): string;
@@ -306,8 +314,18 @@ interface OnPageData {
306
314
  textToHtmlRatio: number;
307
315
  hasNoindex: boolean;
308
316
  metaRobots?: string;
317
+ /** X-Robots-Tag response header value, when the caller passed headers */
318
+ xRobotsTag?: string;
319
+ }
320
+ interface OnPageOptions {
321
+ /**
322
+ * Whether this page is the root of what's being audited (the URL the user
323
+ * asked for). Noindex there is an error rather than a warning. Defaults to
324
+ * "is the URL the site's homepage".
325
+ */
326
+ auditRoot?: boolean;
309
327
  }
310
- declare function analyzeOnPage(html: string, url: string): {
328
+ declare function analyzeOnPage(html: string, url: string, headers?: Record<string, string>, options?: OnPageOptions): {
311
329
  issues: AuditIssue[];
312
330
  data: OnPageData;
313
331
  };
@@ -2760,6 +2778,14 @@ interface WorkflowConfig {
2760
2778
  }
2761
2779
  /**
2762
2780
  * Generate GitHub Action workflow YAML
2781
+ *
2782
+ * RANKCLI_API_KEY is scoped to the rankcli steps only (never job- or
2783
+ * workflow-level env, where third-party actions like create-pull-request
2784
+ * would see it too). Every rankcli step writes JSON even when it fails,
2785
+ * and every reader parses defensively, so one failed step can't cascade
2786
+ * into a JSON.parse('') crash. Report files go to $RUNNER_TEMP, outside
2787
+ * the checkout: in the workspace, create-pull-request committed them into
2788
+ * every auto-fix PR as untracked files.
2763
2789
  */
2764
2790
  declare function generateWorkflow(config: WorkflowConfig): string;
2765
2791
  /**
package/dist/index.js CHANGED
@@ -5866,6 +5866,15 @@ var ISSUE_DEFINITIONS = {
5866
5866
  impact: "Your entire site will not be indexed by search engines.",
5867
5867
  howToFix: "Update robots.txt to allow crawling of important pages."
5868
5868
  },
5869
+ ROBOTS_TXT_SERVER_ERROR: {
5870
+ code: "ROBOTS_TXT_SERVER_ERROR",
5871
+ severity: "warning",
5872
+ category: "crawlability",
5873
+ title: "robots.txt returns a server error",
5874
+ description: "Requesting /robots.txt returned a 5xx (or 429) status instead of the file or a 404.",
5875
+ impact: 'Google treats a 5xx/429 robots.txt as "disallow everything" and stops crawling the whole site until it gets a real answer.',
5876
+ howToFix: "Serve robots.txt with a 200 (or return a 404 if you have none). Check the server/CDN route for /robots.txt."
5877
+ },
5869
5878
  SITEMAP_MISSING: {
5870
5879
  code: "SITEMAP_MISSING",
5871
5880
  severity: "warning",
@@ -5930,14 +5939,35 @@ var ISSUE_DEFINITIONS = {
5930
5939
  howToFix: "Add internal links from relevant pages to this page."
5931
5940
  },
5932
5941
  // ==================== INDEXABILITY ====================
5942
+ // Base severity for a noindexed page other than the audited root;
5943
+ // on-page.ts raises it to 'error' on the site root (a noindexed homepage
5944
+ // takes the whole site out of search).
5933
5945
  NOINDEX_TAG: {
5934
5946
  code: "NOINDEX_TAG",
5935
- severity: "notice",
5947
+ severity: "warning",
5936
5948
  category: "indexability",
5937
- title: "Page has noindex tag",
5938
- description: "The page contains a noindex meta tag or X-Robots-Tag header.",
5949
+ title: "Page has noindex meta tag",
5950
+ description: "The page contains a robots/googlebot meta tag with noindex (or none).",
5939
5951
  impact: "This page will not appear in search results.",
5940
- howToFix: "Remove the noindex tag if you want this page indexed."
5952
+ howToFix: "Remove the noindex directive from the robots meta tag if you want this page indexed."
5953
+ },
5954
+ NOINDEX_HEADER: {
5955
+ code: "NOINDEX_HEADER",
5956
+ severity: "warning",
5957
+ category: "indexability",
5958
+ title: "Page sends X-Robots-Tag: noindex",
5959
+ description: "The HTTP response carries an X-Robots-Tag header with noindex (or none).",
5960
+ impact: "This page will not appear in search results, whatever the HTML says.",
5961
+ howToFix: "Remove noindex from the X-Robots-Tag header in your server, CDN or hosting config."
5962
+ },
5963
+ HTTP_ERROR_STATUS: {
5964
+ code: "HTTP_ERROR_STATUS",
5965
+ severity: "error",
5966
+ category: "indexability",
5967
+ title: "Page returns an HTTP error status",
5968
+ description: "The audited URL answered with a 4xx or 5xx status code.",
5969
+ impact: "Search engines drop pages that return 4xx, and stop crawling pages (and eventually sites) that keep returning 5xx.",
5970
+ howToFix: "Make the URL return 200. For a moved page use a 301 to its new address; for a server error check the application logs."
5941
5971
  },
5942
5972
  CANONICAL_MISSING: {
5943
5973
  code: "CANONICAL_MISSING",
@@ -8340,10 +8370,22 @@ async function checkRobotsTxt(baseUrl) {
8340
8370
  timeout: 1e4,
8341
8371
  validateStatus: () => true
8342
8372
  });
8343
- if (response.status === 404) {
8373
+ if (response.status >= 500 || response.status === 429) {
8374
+ issues.push({
8375
+ ...ISSUE_DEFINITIONS.ROBOTS_TXT_SERVER_ERROR,
8376
+ affectedUrls: [url],
8377
+ details: { status: response.status }
8378
+ });
8379
+ return {
8380
+ issues,
8381
+ data: { exists: false, blocksAll: false, hasSitemap: false, sitemapUrls: [] }
8382
+ };
8383
+ }
8384
+ if (response.status >= 400) {
8344
8385
  issues.push({
8345
8386
  ...ISSUE_DEFINITIONS.ROBOTS_TXT_MISSING,
8346
- affectedUrls: [url]
8387
+ affectedUrls: [url],
8388
+ ...response.status !== 404 && { details: { status: response.status } }
8347
8389
  });
8348
8390
  return {
8349
8391
  issues,
@@ -8507,14 +8549,58 @@ async function runCrawlabilityChecks(baseUrl) {
8507
8549
 
8508
8550
  // src/audit/checks/on-page.ts
8509
8551
  var cheerio = __toESM(require("cheerio"));
8510
- function analyzeOnPage(html, url) {
8552
+ var PREVIEW_HOST_SUFFIXES = [".vercel.app", ".netlify.app", ".pages.dev"];
8553
+ function isPreviewDeploymentHost(hostname) {
8554
+ const host = hostname.toLowerCase();
8555
+ return PREVIEW_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix));
8556
+ }
8557
+ function xRobotsTagHasNoindex(value) {
8558
+ if (!value) return false;
8559
+ const valued = /* @__PURE__ */ new Set(["unavailable_after", "max-snippet", "max-image-preview", "max-video-preview"]);
8560
+ let agent = null;
8561
+ for (const raw of value.toLowerCase().split(",")) {
8562
+ let part = raw.trim();
8563
+ const scoped = part.match(/^([a-z0-9_.*-]+)\s*:\s*(.*)$/);
8564
+ if (scoped && !valued.has(scoped[1])) {
8565
+ agent = scoped[1];
8566
+ part = scoped[2].trim();
8567
+ }
8568
+ if (part === "noindex" || part === "none") {
8569
+ if (agent === null || agent === "*" || agent === "googlebot" || agent === "bingbot") return true;
8570
+ }
8571
+ }
8572
+ return false;
8573
+ }
8574
+ function metaHasNoindex(content) {
8575
+ if (!content) return false;
8576
+ return content.toLowerCase().split(",").some((d) => ["noindex", "none"].includes(d.trim()));
8577
+ }
8578
+ function isSiteRoot(url) {
8579
+ try {
8580
+ const u = new URL(url);
8581
+ return (u.pathname === "/" || u.pathname === "") && !u.search;
8582
+ } catch {
8583
+ return false;
8584
+ }
8585
+ }
8586
+ function analyzeOnPage(html, url, headers, options = {}) {
8511
8587
  const issues = [];
8512
8588
  const $ = cheerio.load(html);
8513
8589
  const title = $("title").text().trim();
8514
8590
  const description = $('meta[name="description"]').attr("content")?.trim();
8515
8591
  const canonical = $('link[rel="canonical"]').attr("href");
8516
8592
  const metaRobots = $('meta[name="robots"]').attr("content");
8517
- const hasNoindex = metaRobots?.toLowerCase().includes("noindex") || false;
8593
+ const robotsMetaValues = [];
8594
+ $("meta[name]").each((_, el) => {
8595
+ const name = ($(el).attr("name") || "").toLowerCase();
8596
+ if (name === "robots" || name === "googlebot") {
8597
+ robotsMetaValues.push($(el).attr("content") || "");
8598
+ }
8599
+ });
8600
+ const metaNoindex = robotsMetaValues.some(metaHasNoindex);
8601
+ const headerLookup = headers ? Object.entries(headers).find(([k]) => k.toLowerCase() === "x-robots-tag")?.[1] : void 0;
8602
+ const headerNoindex = xRobotsTagHasNoindex(headerLookup);
8603
+ const hasNoindex = metaNoindex || headerNoindex;
8518
8604
  const h1s = [];
8519
8605
  $("h1").each((_, el) => {
8520
8606
  h1s.push($(el).text().trim());
@@ -8540,7 +8626,8 @@ function analyzeOnPage(html, url) {
8540
8626
  wordCount,
8541
8627
  textToHtmlRatio,
8542
8628
  hasNoindex,
8543
- metaRobots
8629
+ metaRobots,
8630
+ xRobotsTag: headerLookup
8544
8631
  };
8545
8632
  if (!title) {
8546
8633
  issues.push({
@@ -8654,11 +8741,29 @@ function analyzeOnPage(html, url) {
8654
8741
  details: { ratio: textToHtmlRatio.toFixed(1) + "%" }
8655
8742
  });
8656
8743
  }
8657
- if (hasNoindex) {
8744
+ const root = options.auditRoot ?? isSiteRoot(url);
8745
+ if (metaNoindex) {
8658
8746
  issues.push({
8659
8747
  ...ISSUE_DEFINITIONS.NOINDEX_TAG,
8748
+ severity: root ? "error" : "warning",
8660
8749
  affectedUrls: [url],
8661
- details: { metaRobots }
8750
+ details: { metaRobots: robotsMetaValues.join(", "), siteRoot: root }
8751
+ });
8752
+ }
8753
+ if (headerNoindex) {
8754
+ let preview = false;
8755
+ try {
8756
+ preview = isPreviewDeploymentHost(new URL(url).hostname);
8757
+ } catch {
8758
+ }
8759
+ issues.push({
8760
+ ...ISSUE_DEFINITIONS.NOINDEX_HEADER,
8761
+ severity: preview ? "notice" : root ? "error" : "warning",
8762
+ ...preview && {
8763
+ description: `${ISSUE_DEFINITIONS.NOINDEX_HEADER.description} This host is a preview-deployment domain (Vercel, Netlify or Cloudflare Pages), which adds this header to non-production deployments automatically - your production domain won't send it.`
8764
+ },
8765
+ affectedUrls: [url],
8766
+ details: { xRobotsTag: headerLookup, siteRoot: root, previewHost: preview }
8662
8767
  });
8663
8768
  }
8664
8769
  return { issues, data };
@@ -14553,7 +14658,7 @@ async function validateRobotsTxt(url) {
14553
14658
  timeout: 1e4,
14554
14659
  validateStatus: () => true
14555
14660
  });
14556
- if (response.status === 404) {
14661
+ if (response.status >= 400) {
14557
14662
  return {
14558
14663
  issues,
14559
14664
  data: {
@@ -17404,8 +17509,19 @@ function analyzeTrackingVerification(html, url) {
17404
17509
  }
17405
17510
 
17406
17511
  // src/audit/engine.ts
17512
+ function sameUrl(a, b) {
17513
+ try {
17514
+ const ua = new URL(a);
17515
+ const ub = new URL(b);
17516
+ const path3 = (u) => u.pathname.replace(/\/+$/, "") || "/";
17517
+ return ua.origin === ub.origin && path3(ua) === path3(ub) && ua.search === ub.search;
17518
+ } catch {
17519
+ return a === b;
17520
+ }
17521
+ }
17407
17522
  async function runFullAudit(options) {
17408
17523
  const { url, checkBrokenLinks = false, checkHreflangUrls = false, checkCanonicalChain = false, cruxApiKey, checksLimit = 280, premiumChecks } = options;
17524
+ const auditRoot = isSiteRoot(url) || sameUrl(url, options.auditRootUrl ?? url);
17409
17525
  const allIssues = [];
17410
17526
  const pages = [];
17411
17527
  const tier = checksLimit <= 50 ? "free" : checksLimit <= 100 ? "registered" : "paid";
@@ -17447,17 +17563,31 @@ async function runFullAudit(options) {
17447
17563
  affectedUrls: [url],
17448
17564
  details: { error: errorMsg, cause: errorCause || void 0 }
17449
17565
  });
17450
- return createReport(url, domain, allIssues, pages);
17566
+ return createReport(url, domain, allIssues, pages, { kind: "unreachable" });
17567
+ }
17568
+ const statusCode = fetchResult.status;
17569
+ if (statusCode >= 400) {
17570
+ const blocked = statusCode === 401 || statusCode === 403 || statusCode === 429;
17571
+ allIssues.push({
17572
+ ...ISSUE_DEFINITIONS.HTTP_ERROR_STATUS,
17573
+ title: `Page returns HTTP ${statusCode}`,
17574
+ description: `The audited URL answered with HTTP ${statusCode}${fetchResult.statusText ? ` ${fetchResult.statusText}` : ""}. Page content checks were skipped - they would only be grading the error page.${blocked ? " This status often means a firewall or bot protection (Cloudflare, Vercel, etc.) is refusing automated clients: check that search engine crawlers get the page, and allowlist the RankCLI user agent or audit a preview URL." : ""}`,
17575
+ affectedUrls: [url],
17576
+ details: { statusCode, ...blocked && { likelyBotProtection: true } }
17577
+ });
17578
+ pages.push({ url, statusCode, wordCount: 0, loadTime: 0, issues: allIssues.map((i) => i.code) });
17579
+ return createReport(url, domain, allIssues, pages, { kind: "http-error", statusCode });
17451
17580
  }
17452
- const html = fetchResult.data.replace(/<\/?noscript[^>]*>/gi, "");
17581
+ const body = typeof fetchResult.data === "string" ? fetchResult.data : JSON.stringify(fetchResult.data ?? "");
17582
+ const html = body.replace(/<\/?noscript[^>]*>/gi, "");
17453
17583
  const headers = fetchResult.headers;
17454
17584
  console.error(`\u{1F4DD} Phase 2: Running synchronous HTML checks (tier: ${tier}, limit: ${checksLimit})...`);
17455
- const onPageResult = analyzeOnPage(html, url);
17585
+ const onPageResult = analyzeOnPage(html, url, headers, { auditRoot });
17456
17586
  const structuredDataResult = analyzeStructuredData(html, url);
17457
17587
  const mobileResult = analyzeMobile(html, url);
17458
17588
  const mobileResourceIssues = checkMobileResources(html, url);
17459
17589
  const socialResult = analyzeSocialMeta(html, url);
17460
- const soft404Result = detectSoft404(html, url, 200);
17590
+ const soft404Result = detectSoft404(html, url, statusCode);
17461
17591
  const serpResult = analyzeSERPPreview(html, url);
17462
17592
  const domResult = analyzeDOMStructure(html, url);
17463
17593
  const techResult = detectTechnologies(html, url, headers);
@@ -17555,13 +17685,20 @@ async function runFullAudit(options) {
17555
17685
  }
17556
17686
  }
17557
17687
  console.error("\u{1F517} Phase 3: Running async checks (parallel)...");
17688
+ const withTimeout = async (name, promise, timeoutMs) => {
17689
+ let timer;
17690
+ const timeoutPromise = new Promise((_, reject) => {
17691
+ timer = setTimeout(() => reject(new Error(`${name} timed out`)), timeoutMs);
17692
+ });
17693
+ try {
17694
+ return await Promise.race([promise, timeoutPromise]);
17695
+ } finally {
17696
+ clearTimeout(timer);
17697
+ }
17698
+ };
17558
17699
  const safeAsync = async (name, fn, timeoutMs = 1e4) => {
17559
17700
  try {
17560
- const resultPromise = fn();
17561
- const timeoutPromise = new Promise(
17562
- (_, reject) => setTimeout(() => reject(new Error(`${name} timed out`)), timeoutMs)
17563
- );
17564
- const result = await Promise.race([resultPromise, timeoutPromise]);
17701
+ const result = await withTimeout(name, fn(), timeoutMs);
17565
17702
  return result.issues;
17566
17703
  } catch (err) {
17567
17704
  console.error(`${name} failed:`, err);
@@ -17570,11 +17707,7 @@ async function runFullAudit(options) {
17570
17707
  };
17571
17708
  const safeAsyncIssues = async (name, fn, timeoutMs = 1e4) => {
17572
17709
  try {
17573
- const resultPromise = fn();
17574
- const timeoutPromise = new Promise(
17575
- (_, reject) => setTimeout(() => reject(new Error(`${name} timed out`)), timeoutMs)
17576
- );
17577
- return await Promise.race([resultPromise, timeoutPromise]);
17710
+ return await withTimeout(name, fn(), timeoutMs);
17578
17711
  } catch (err) {
17579
17712
  console.error(`${name} failed:`, err);
17580
17713
  return [];
@@ -17663,7 +17796,7 @@ async function runFullAudit(options) {
17663
17796
  }
17664
17797
  pages.push({
17665
17798
  url,
17666
- statusCode: 200,
17799
+ statusCode,
17667
17800
  title: onPageResult.data.title,
17668
17801
  description: onPageResult.data.description,
17669
17802
  canonical: onPageResult.data.canonical,
@@ -17675,8 +17808,13 @@ async function runFullAudit(options) {
17675
17808
  console.error("\n\u2705 Audit complete!\n");
17676
17809
  return createReport(url, domain, allIssues, pages);
17677
17810
  }
17678
- function createReport(url, domain, issues, pages) {
17811
+ function createReport(url, domain, issues, pages, pageFailure) {
17679
17812
  const healthScore = calculateHealthScore(issues);
17813
+ if (pageFailure) {
17814
+ for (const key of Object.keys(healthScore)) {
17815
+ if (key !== "crawlability") healthScore[key] = 0;
17816
+ }
17817
+ }
17680
17818
  const errors = issues.filter((i) => i.severity === "error").length;
17681
17819
  const warnings = issues.filter((i) => i.severity === "warning").length;
17682
17820
  const notices = issues.filter((i) => i.severity === "notice").length;
@@ -17688,8 +17826,8 @@ function createReport(url, domain, issues, pages) {
17688
17826
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
17689
17827
  crawlStats: {
17690
17828
  totalUrls: 1,
17691
- crawledUrls: 1,
17692
- errorUrls: 0,
17829
+ crawledUrls: pageFailure?.kind === "unreachable" ? 0 : 1,
17830
+ errorUrls: pageFailure ? 1 : 0,
17693
17831
  redirectUrls: 0,
17694
17832
  blockedUrls: 0
17695
17833
  },
@@ -25164,6 +25302,16 @@ This will open a browser for OAuth consent and save tokens locally.
25164
25302
  // src/tracking/github-action.ts
25165
25303
  function generateWorkflow(config) {
25166
25304
  const cronSchedule = getCronSchedule(config.schedule);
25305
+ const sync = !!config.syncToDashboard;
25306
+ const autoFixPRs = config.features.autoFix && config.features.createPRs;
25307
+ const keyEnv = sync ? `
25308
+ env:
25309
+ RANKCLI_API_KEY: \${{ secrets.RANKCLI_API_KEY }}` : "";
25310
+ const permissions = [
25311
+ ` contents: ${autoFixPRs ? "write" : "read"}`,
25312
+ ...config.features.createIssues ? [" issues: write"] : [],
25313
+ ...autoFixPRs ? [" pull-requests: write"] : []
25314
+ ].join("\n");
25167
25315
  return `# RankCLI - Automated SEO Monitoring
25168
25316
  # https://rankcli.dev - generated by \`rankcli setup --github-action\`
25169
25317
 
@@ -25182,9 +25330,7 @@ jobs:
25182
25330
  seo-check:
25183
25331
  runs-on: ubuntu-latest
25184
25332
  permissions:
25185
- contents: write
25186
- issues: write
25187
- pull-requests: write
25333
+ ${permissions}
25188
25334
 
25189
25335
  steps:
25190
25336
  - name: Checkout repository
@@ -25193,36 +25339,60 @@ jobs:
25193
25339
  - name: Setup Node.js
25194
25340
  uses: actions/setup-node@v4
25195
25341
  with:
25196
- node-version: '20'
25342
+ node-version: '22'
25197
25343
 
25198
25344
  - name: Install RankCLI
25199
25345
  run: npm install -g @rankcli/cli
25200
- ${config.syncToDashboard ? `
25201
- - name: Log in to RankCLI
25202
- env:
25203
- RANKCLI_API_KEY: \${{ secrets.RANKCLI_API_KEY }}
25204
- run: rankcli login --token "$RANKCLI_API_KEY"
25346
+ ${sync ? `
25347
+ # Fails fast if the secret is missing or the key was revoked, instead
25348
+ # of silently auditing on the Free tier.
25349
+ - name: Verify RankCLI API key${keyEnv}
25350
+ run: |
25351
+ if [ -z "$RANKCLI_API_KEY" ]; then
25352
+ echo "::error::The RANKCLI_API_KEY secret is not set - see .github/SEO_SETUP.md"
25353
+ exit 1
25354
+ fi
25355
+ rankcli login
25205
25356
  ` : ""}
25357
+ # Exit codes: 0 ok, 1 a gate tripped, 2 bad invocation, 3 site
25358
+ # unreachable or the URL answered 4xx/5xx. To fail this job on SEO
25359
+ # regressions, add gates, e.g.: --fail-on error --min-score 80
25206
25360
  - name: Run SEO audit
25207
- run: |
25208
- rankcli audit --url "$SITE_URL" --output json > audit-report.json
25361
+ id: audit${keyEnv}
25362
+ run: rankcli audit --url "$SITE_URL" --output json > "$RUNNER_TEMP/audit-report.json"
25209
25363
  ${config.features.createIssues ? `
25210
25364
  - name: Create/update SEO report issue
25365
+ if: \${{ !cancelled() && steps.audit.outcome != 'skipped' }}
25211
25366
  uses: actions/github-script@v7
25212
25367
  with:
25213
25368
  script: |
25214
25369
  const fs = require('fs');
25215
- const report = JSON.parse(fs.readFileSync('audit-report.json', 'utf8'));
25216
- const errors = report.issues.filter(i => i.severity === 'error');
25217
- const warnings = report.issues.filter(i => i.severity === 'warning');
25218
- const notices = report.issues.filter(i => i.severity === 'notice');
25370
+ let report = {};
25371
+ try {
25372
+ report = JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP + '/audit-report.json', 'utf8'));
25373
+ } catch (e) {
25374
+ core.setFailed('audit-report.json is not valid JSON: ' + e.message);
25375
+ return;
25376
+ }
25377
+ const all = report.issues || [];
25378
+ const errors = all.filter(i => i.severity === 'error' || i.severity === 'critical');
25379
+ const warnings = all.filter(i => i.severity === 'warning');
25380
+ const notices = all.filter(i => i.severity === 'notice' || i.severity === 'info');
25219
25381
 
25220
25382
  const lines = [
25221
25383
  \`## RankCLI SEO Report\`,
25222
25384
  '',
25223
- \`**Score:** \${report.score}/100 \xB7 **Errors:** \${errors.length} \xB7 **Warnings:** \${warnings.length} \xB7 **Notices:** \${notices.length}\`,
25224
- '',
25225
25385
  ];
25386
+ if (report.error) {
25387
+ lines.push(\`> **Audit failed:** \${report.error}\`, '');
25388
+ }
25389
+ lines.push(
25390
+ \`**Score:** \${report.score ?? 0}/100 \xB7 **Errors:** \${errors.length} \xB7 **Warnings:** \${warnings.length} \xB7 **Notices:** \${notices.length}\`,
25391
+ '',
25392
+ );
25393
+ if (report.gate && !report.gate.passed) {
25394
+ lines.push('### CI gate failed', ...report.gate.failures.map(f => \`- \${f}\`), '');
25395
+ }
25226
25396
  if (errors.length > 0) {
25227
25397
  lines.push('### Errors', ...errors.slice(0, 15).map(i => \`- \${i.title}\`), '');
25228
25398
  }
@@ -25258,18 +25428,24 @@ ${config.features.createIssues ? `
25258
25428
  labels: ['seo-report', 'automated'],
25259
25429
  });
25260
25430
  }
25261
- ` : ""}${config.features.autoFix && config.features.createPRs ? `
25431
+ ` : ""}${autoFixPRs ? `
25432
+ # Auto-fix needs a Solo+ plan (resolved from RANKCLI_API_KEY). It
25433
+ # always writes JSON - appliedCount is 0 when there was nothing to fix
25434
+ # or the plan doesn't include auto-fix.
25262
25435
  - name: Auto-fix issues
25263
- run: rankcli fix --url "$SITE_URL" --auto --output json > fixes.json
25436
+ id: autofix
25437
+ if: \${{ !cancelled() && steps.audit.outcome != 'skipped' }}${keyEnv}
25438
+ run: rankcli fix --url "$SITE_URL" --auto --output json > "$RUNNER_TEMP/fixes.json"
25264
25439
 
25265
25440
  - name: Check if any fixes were applied
25266
25441
  id: fixes
25442
+ if: \${{ !cancelled() && steps.autofix.outcome != 'skipped' }}
25267
25443
  run: |
25268
- APPLIED=$(node -p "JSON.parse(require('fs').readFileSync('fixes.json','utf8')).appliedCount || 0")
25269
- echo "applied=$APPLIED" >> $GITHUB_OUTPUT
25444
+ APPLIED=$(node -e "let r = {}; try { r = JSON.parse(require('fs').readFileSync(process.env.RUNNER_TEMP + '/fixes.json', 'utf8')); } catch {} console.log(r.appliedCount || 0)")
25445
+ echo "applied=$APPLIED" >> "$GITHUB_OUTPUT"
25270
25446
 
25271
25447
  - name: Create PR with fixes
25272
- if: steps.fixes.outputs.applied != '0'
25448
+ if: \${{ !cancelled() && steps.fixes.outputs.applied != '' && steps.fixes.outputs.applied != '0' }}
25273
25449
  uses: peter-evans/create-pull-request@v6
25274
25450
  with:
25275
25451
  token: \${{ secrets.GITHUB_TOKEN }}
@@ -25288,12 +25464,14 @@ ${config.features.createIssues ? `
25288
25464
  labels: seo, automated
25289
25465
  ` : ""}
25290
25466
  - name: Save report artifact
25467
+ if: \${{ !cancelled() }}
25291
25468
  uses: actions/upload-artifact@v4
25292
25469
  with:
25293
25470
  name: rankcli-report-\${{ github.run_number }}
25294
25471
  path: |
25295
- audit-report.json
25296
- ${config.features.autoFix ? "fixes.json" : ""}
25472
+ \${{ runner.temp }}/audit-report.json${autoFixPRs ? `
25473
+ \${{ runner.temp }}/fixes.json` : ""}
25474
+ if-no-files-found: ignore
25297
25475
  retention-days: 90
25298
25476
  `;
25299
25477
  }
@@ -25326,19 +25504,20 @@ Go to your repository Settings > Secrets and variables > Actions
25326
25504
  ## RankCLI (required - sends audits to your dashboard)
25327
25505
 
25328
25506
  1. \`RANKCLI_API_KEY\`
25329
- - Get a free API key at https://rankcli.dev/account?tab=api-keys
25330
- - Without this secret, remove the "Log in to RankCLI" step from
25331
- .github/workflows/seo.yml - the audit still runs, it just won't
25332
- appear on your dashboard.
25507
+ - Create one at https://rankcli.dev/account?tab=api-keys
25508
+ - It is only passed to the RankCLI steps. The "Verify RankCLI API key"
25509
+ step fails the run if the secret is missing or the key was revoked,
25510
+ rather than silently auditing on the Free tier.
25511
+ - Your plan comes from the key: auto-fix PRs need Solo or higher.
25333
25512
 
25334
25513
  `;
25335
25514
  } else {
25336
25515
  secrets += `
25337
25516
  No secrets are required for this workflow as configured - it runs a local
25338
- audit and, if enabled, opens auto-fix PRs using the repository's built-in
25339
- GITHUB_TOKEN. To also sync results to your RankCLI dashboard, re-run
25340
- \`rankcli setup --github-action\` and opt in when asked, or add a
25341
- \`RANKCLI_API_KEY\` secret and a \`rankcli login --token\` step yourself.
25517
+ audit and files the results as a GitHub issue using the repository's
25518
+ built-in GITHUB_TOKEN. To also sync results to your RankCLI dashboard and
25519
+ get auto-fix PRs (Solo plan or higher), re-run
25520
+ \`rankcli setup --github-action --sync\` and add a \`RANKCLI_API_KEY\` secret.
25342
25521
 
25343
25522
  `;
25344
25523
  }
package/dist/index.mjs CHANGED
@@ -2351,6 +2351,15 @@ var ISSUE_DEFINITIONS = {
2351
2351
  impact: "Your entire site will not be indexed by search engines.",
2352
2352
  howToFix: "Update robots.txt to allow crawling of important pages."
2353
2353
  },
2354
+ ROBOTS_TXT_SERVER_ERROR: {
2355
+ code: "ROBOTS_TXT_SERVER_ERROR",
2356
+ severity: "warning",
2357
+ category: "crawlability",
2358
+ title: "robots.txt returns a server error",
2359
+ description: "Requesting /robots.txt returned a 5xx (or 429) status instead of the file or a 404.",
2360
+ impact: 'Google treats a 5xx/429 robots.txt as "disallow everything" and stops crawling the whole site until it gets a real answer.',
2361
+ howToFix: "Serve robots.txt with a 200 (or return a 404 if you have none). Check the server/CDN route for /robots.txt."
2362
+ },
2354
2363
  SITEMAP_MISSING: {
2355
2364
  code: "SITEMAP_MISSING",
2356
2365
  severity: "warning",
@@ -2415,14 +2424,35 @@ var ISSUE_DEFINITIONS = {
2415
2424
  howToFix: "Add internal links from relevant pages to this page."
2416
2425
  },
2417
2426
  // ==================== INDEXABILITY ====================
2427
+ // Base severity for a noindexed page other than the audited root;
2428
+ // on-page.ts raises it to 'error' on the site root (a noindexed homepage
2429
+ // takes the whole site out of search).
2418
2430
  NOINDEX_TAG: {
2419
2431
  code: "NOINDEX_TAG",
2420
- severity: "notice",
2432
+ severity: "warning",
2421
2433
  category: "indexability",
2422
- title: "Page has noindex tag",
2423
- description: "The page contains a noindex meta tag or X-Robots-Tag header.",
2434
+ title: "Page has noindex meta tag",
2435
+ description: "The page contains a robots/googlebot meta tag with noindex (or none).",
2424
2436
  impact: "This page will not appear in search results.",
2425
- howToFix: "Remove the noindex tag if you want this page indexed."
2437
+ howToFix: "Remove the noindex directive from the robots meta tag if you want this page indexed."
2438
+ },
2439
+ NOINDEX_HEADER: {
2440
+ code: "NOINDEX_HEADER",
2441
+ severity: "warning",
2442
+ category: "indexability",
2443
+ title: "Page sends X-Robots-Tag: noindex",
2444
+ description: "The HTTP response carries an X-Robots-Tag header with noindex (or none).",
2445
+ impact: "This page will not appear in search results, whatever the HTML says.",
2446
+ howToFix: "Remove noindex from the X-Robots-Tag header in your server, CDN or hosting config."
2447
+ },
2448
+ HTTP_ERROR_STATUS: {
2449
+ code: "HTTP_ERROR_STATUS",
2450
+ severity: "error",
2451
+ category: "indexability",
2452
+ title: "Page returns an HTTP error status",
2453
+ description: "The audited URL answered with a 4xx or 5xx status code.",
2454
+ impact: "Search engines drop pages that return 4xx, and stop crawling pages (and eventually sites) that keep returning 5xx.",
2455
+ howToFix: "Make the URL return 200. For a moved page use a 301 to its new address; for a server error check the application logs."
2426
2456
  },
2427
2457
  CANONICAL_MISSING: {
2428
2458
  code: "CANONICAL_MISSING",
@@ -4821,10 +4851,22 @@ async function checkRobotsTxt(baseUrl) {
4821
4851
  timeout: 1e4,
4822
4852
  validateStatus: () => true
4823
4853
  });
4824
- if (response.status === 404) {
4854
+ if (response.status >= 500 || response.status === 429) {
4855
+ issues.push({
4856
+ ...ISSUE_DEFINITIONS.ROBOTS_TXT_SERVER_ERROR,
4857
+ affectedUrls: [url],
4858
+ details: { status: response.status }
4859
+ });
4860
+ return {
4861
+ issues,
4862
+ data: { exists: false, blocksAll: false, hasSitemap: false, sitemapUrls: [] }
4863
+ };
4864
+ }
4865
+ if (response.status >= 400) {
4825
4866
  issues.push({
4826
4867
  ...ISSUE_DEFINITIONS.ROBOTS_TXT_MISSING,
4827
- affectedUrls: [url]
4868
+ affectedUrls: [url],
4869
+ ...response.status !== 404 && { details: { status: response.status } }
4828
4870
  });
4829
4871
  return {
4830
4872
  issues,
@@ -4988,14 +5030,58 @@ async function runCrawlabilityChecks(baseUrl) {
4988
5030
 
4989
5031
  // src/audit/checks/on-page.ts
4990
5032
  import * as cheerio from "cheerio";
4991
- function analyzeOnPage(html, url) {
5033
+ var PREVIEW_HOST_SUFFIXES = [".vercel.app", ".netlify.app", ".pages.dev"];
5034
+ function isPreviewDeploymentHost(hostname) {
5035
+ const host = hostname.toLowerCase();
5036
+ return PREVIEW_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix));
5037
+ }
5038
+ function xRobotsTagHasNoindex(value) {
5039
+ if (!value) return false;
5040
+ const valued = /* @__PURE__ */ new Set(["unavailable_after", "max-snippet", "max-image-preview", "max-video-preview"]);
5041
+ let agent = null;
5042
+ for (const raw of value.toLowerCase().split(",")) {
5043
+ let part = raw.trim();
5044
+ const scoped = part.match(/^([a-z0-9_.*-]+)\s*:\s*(.*)$/);
5045
+ if (scoped && !valued.has(scoped[1])) {
5046
+ agent = scoped[1];
5047
+ part = scoped[2].trim();
5048
+ }
5049
+ if (part === "noindex" || part === "none") {
5050
+ if (agent === null || agent === "*" || agent === "googlebot" || agent === "bingbot") return true;
5051
+ }
5052
+ }
5053
+ return false;
5054
+ }
5055
+ function metaHasNoindex(content) {
5056
+ if (!content) return false;
5057
+ return content.toLowerCase().split(",").some((d) => ["noindex", "none"].includes(d.trim()));
5058
+ }
5059
+ function isSiteRoot(url) {
5060
+ try {
5061
+ const u = new URL(url);
5062
+ return (u.pathname === "/" || u.pathname === "") && !u.search;
5063
+ } catch {
5064
+ return false;
5065
+ }
5066
+ }
5067
+ function analyzeOnPage(html, url, headers, options = {}) {
4992
5068
  const issues = [];
4993
5069
  const $ = cheerio.load(html);
4994
5070
  const title = $("title").text().trim();
4995
5071
  const description = $('meta[name="description"]').attr("content")?.trim();
4996
5072
  const canonical = $('link[rel="canonical"]').attr("href");
4997
5073
  const metaRobots = $('meta[name="robots"]').attr("content");
4998
- const hasNoindex = metaRobots?.toLowerCase().includes("noindex") || false;
5074
+ const robotsMetaValues = [];
5075
+ $("meta[name]").each((_, el) => {
5076
+ const name = ($(el).attr("name") || "").toLowerCase();
5077
+ if (name === "robots" || name === "googlebot") {
5078
+ robotsMetaValues.push($(el).attr("content") || "");
5079
+ }
5080
+ });
5081
+ const metaNoindex = robotsMetaValues.some(metaHasNoindex);
5082
+ const headerLookup = headers ? Object.entries(headers).find(([k]) => k.toLowerCase() === "x-robots-tag")?.[1] : void 0;
5083
+ const headerNoindex = xRobotsTagHasNoindex(headerLookup);
5084
+ const hasNoindex = metaNoindex || headerNoindex;
4999
5085
  const h1s = [];
5000
5086
  $("h1").each((_, el) => {
5001
5087
  h1s.push($(el).text().trim());
@@ -5021,7 +5107,8 @@ function analyzeOnPage(html, url) {
5021
5107
  wordCount,
5022
5108
  textToHtmlRatio,
5023
5109
  hasNoindex,
5024
- metaRobots
5110
+ metaRobots,
5111
+ xRobotsTag: headerLookup
5025
5112
  };
5026
5113
  if (!title) {
5027
5114
  issues.push({
@@ -5135,11 +5222,29 @@ function analyzeOnPage(html, url) {
5135
5222
  details: { ratio: textToHtmlRatio.toFixed(1) + "%" }
5136
5223
  });
5137
5224
  }
5138
- if (hasNoindex) {
5225
+ const root = options.auditRoot ?? isSiteRoot(url);
5226
+ if (metaNoindex) {
5139
5227
  issues.push({
5140
5228
  ...ISSUE_DEFINITIONS.NOINDEX_TAG,
5229
+ severity: root ? "error" : "warning",
5141
5230
  affectedUrls: [url],
5142
- details: { metaRobots }
5231
+ details: { metaRobots: robotsMetaValues.join(", "), siteRoot: root }
5232
+ });
5233
+ }
5234
+ if (headerNoindex) {
5235
+ let preview = false;
5236
+ try {
5237
+ preview = isPreviewDeploymentHost(new URL(url).hostname);
5238
+ } catch {
5239
+ }
5240
+ issues.push({
5241
+ ...ISSUE_DEFINITIONS.NOINDEX_HEADER,
5242
+ severity: preview ? "notice" : root ? "error" : "warning",
5243
+ ...preview && {
5244
+ description: `${ISSUE_DEFINITIONS.NOINDEX_HEADER.description} This host is a preview-deployment domain (Vercel, Netlify or Cloudflare Pages), which adds this header to non-production deployments automatically - your production domain won't send it.`
5245
+ },
5246
+ affectedUrls: [url],
5247
+ details: { xRobotsTag: headerLookup, siteRoot: root, previewHost: preview }
5143
5248
  });
5144
5249
  }
5145
5250
  return { issues, data };
@@ -11017,7 +11122,7 @@ async function validateRobotsTxt(url) {
11017
11122
  timeout: 1e4,
11018
11123
  validateStatus: () => true
11019
11124
  });
11020
- if (response.status === 404) {
11125
+ if (response.status >= 400) {
11021
11126
  return {
11022
11127
  issues,
11023
11128
  data: {
@@ -13861,8 +13966,19 @@ function analyzeTrackingVerification(html, url) {
13861
13966
  }
13862
13967
 
13863
13968
  // src/audit/engine.ts
13969
+ function sameUrl(a, b) {
13970
+ try {
13971
+ const ua = new URL(a);
13972
+ const ub = new URL(b);
13973
+ const path3 = (u) => u.pathname.replace(/\/+$/, "") || "/";
13974
+ return ua.origin === ub.origin && path3(ua) === path3(ub) && ua.search === ub.search;
13975
+ } catch {
13976
+ return a === b;
13977
+ }
13978
+ }
13864
13979
  async function runFullAudit(options) {
13865
13980
  const { url, checkBrokenLinks = false, checkHreflangUrls = false, checkCanonicalChain = false, cruxApiKey, checksLimit = 280, premiumChecks } = options;
13981
+ const auditRoot = isSiteRoot(url) || sameUrl(url, options.auditRootUrl ?? url);
13866
13982
  const allIssues = [];
13867
13983
  const pages = [];
13868
13984
  const tier = checksLimit <= 50 ? "free" : checksLimit <= 100 ? "registered" : "paid";
@@ -13904,17 +14020,31 @@ async function runFullAudit(options) {
13904
14020
  affectedUrls: [url],
13905
14021
  details: { error: errorMsg, cause: errorCause || void 0 }
13906
14022
  });
13907
- return createReport(url, domain, allIssues, pages);
14023
+ return createReport(url, domain, allIssues, pages, { kind: "unreachable" });
14024
+ }
14025
+ const statusCode = fetchResult.status;
14026
+ if (statusCode >= 400) {
14027
+ const blocked = statusCode === 401 || statusCode === 403 || statusCode === 429;
14028
+ allIssues.push({
14029
+ ...ISSUE_DEFINITIONS.HTTP_ERROR_STATUS,
14030
+ title: `Page returns HTTP ${statusCode}`,
14031
+ description: `The audited URL answered with HTTP ${statusCode}${fetchResult.statusText ? ` ${fetchResult.statusText}` : ""}. Page content checks were skipped - they would only be grading the error page.${blocked ? " This status often means a firewall or bot protection (Cloudflare, Vercel, etc.) is refusing automated clients: check that search engine crawlers get the page, and allowlist the RankCLI user agent or audit a preview URL." : ""}`,
14032
+ affectedUrls: [url],
14033
+ details: { statusCode, ...blocked && { likelyBotProtection: true } }
14034
+ });
14035
+ pages.push({ url, statusCode, wordCount: 0, loadTime: 0, issues: allIssues.map((i) => i.code) });
14036
+ return createReport(url, domain, allIssues, pages, { kind: "http-error", statusCode });
13908
14037
  }
13909
- const html = fetchResult.data.replace(/<\/?noscript[^>]*>/gi, "");
14038
+ const body = typeof fetchResult.data === "string" ? fetchResult.data : JSON.stringify(fetchResult.data ?? "");
14039
+ const html = body.replace(/<\/?noscript[^>]*>/gi, "");
13910
14040
  const headers = fetchResult.headers;
13911
14041
  console.error(`\u{1F4DD} Phase 2: Running synchronous HTML checks (tier: ${tier}, limit: ${checksLimit})...`);
13912
- const onPageResult = analyzeOnPage(html, url);
14042
+ const onPageResult = analyzeOnPage(html, url, headers, { auditRoot });
13913
14043
  const structuredDataResult = analyzeStructuredData2(html, url);
13914
14044
  const mobileResult = analyzeMobile(html, url);
13915
14045
  const mobileResourceIssues = checkMobileResources(html, url);
13916
14046
  const socialResult = analyzeSocialMeta(html, url);
13917
- const soft404Result = detectSoft404(html, url, 200);
14047
+ const soft404Result = detectSoft404(html, url, statusCode);
13918
14048
  const serpResult = analyzeSERPPreview(html, url);
13919
14049
  const domResult = analyzeDOMStructure(html, url);
13920
14050
  const techResult = detectTechnologies(html, url, headers);
@@ -14012,13 +14142,20 @@ async function runFullAudit(options) {
14012
14142
  }
14013
14143
  }
14014
14144
  console.error("\u{1F517} Phase 3: Running async checks (parallel)...");
14145
+ const withTimeout = async (name, promise, timeoutMs) => {
14146
+ let timer;
14147
+ const timeoutPromise = new Promise((_, reject) => {
14148
+ timer = setTimeout(() => reject(new Error(`${name} timed out`)), timeoutMs);
14149
+ });
14150
+ try {
14151
+ return await Promise.race([promise, timeoutPromise]);
14152
+ } finally {
14153
+ clearTimeout(timer);
14154
+ }
14155
+ };
14015
14156
  const safeAsync = async (name, fn, timeoutMs = 1e4) => {
14016
14157
  try {
14017
- const resultPromise = fn();
14018
- const timeoutPromise = new Promise(
14019
- (_, reject) => setTimeout(() => reject(new Error(`${name} timed out`)), timeoutMs)
14020
- );
14021
- const result = await Promise.race([resultPromise, timeoutPromise]);
14158
+ const result = await withTimeout(name, fn(), timeoutMs);
14022
14159
  return result.issues;
14023
14160
  } catch (err) {
14024
14161
  console.error(`${name} failed:`, err);
@@ -14027,11 +14164,7 @@ async function runFullAudit(options) {
14027
14164
  };
14028
14165
  const safeAsyncIssues = async (name, fn, timeoutMs = 1e4) => {
14029
14166
  try {
14030
- const resultPromise = fn();
14031
- const timeoutPromise = new Promise(
14032
- (_, reject) => setTimeout(() => reject(new Error(`${name} timed out`)), timeoutMs)
14033
- );
14034
- return await Promise.race([resultPromise, timeoutPromise]);
14167
+ return await withTimeout(name, fn(), timeoutMs);
14035
14168
  } catch (err) {
14036
14169
  console.error(`${name} failed:`, err);
14037
14170
  return [];
@@ -14120,7 +14253,7 @@ async function runFullAudit(options) {
14120
14253
  }
14121
14254
  pages.push({
14122
14255
  url,
14123
- statusCode: 200,
14256
+ statusCode,
14124
14257
  title: onPageResult.data.title,
14125
14258
  description: onPageResult.data.description,
14126
14259
  canonical: onPageResult.data.canonical,
@@ -14132,8 +14265,13 @@ async function runFullAudit(options) {
14132
14265
  console.error("\n\u2705 Audit complete!\n");
14133
14266
  return createReport(url, domain, allIssues, pages);
14134
14267
  }
14135
- function createReport(url, domain, issues, pages) {
14268
+ function createReport(url, domain, issues, pages, pageFailure) {
14136
14269
  const healthScore = calculateHealthScore(issues);
14270
+ if (pageFailure) {
14271
+ for (const key of Object.keys(healthScore)) {
14272
+ if (key !== "crawlability") healthScore[key] = 0;
14273
+ }
14274
+ }
14137
14275
  const errors = issues.filter((i) => i.severity === "error").length;
14138
14276
  const warnings = issues.filter((i) => i.severity === "warning").length;
14139
14277
  const notices = issues.filter((i) => i.severity === "notice").length;
@@ -14145,8 +14283,8 @@ function createReport(url, domain, issues, pages) {
14145
14283
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
14146
14284
  crawlStats: {
14147
14285
  totalUrls: 1,
14148
- crawledUrls: 1,
14149
- errorUrls: 0,
14286
+ crawledUrls: pageFailure?.kind === "unreachable" ? 0 : 1,
14287
+ errorUrls: pageFailure ? 1 : 0,
14150
14288
  redirectUrls: 0,
14151
14289
  blockedUrls: 0
14152
14290
  },
@@ -21615,6 +21753,16 @@ This will open a browser for OAuth consent and save tokens locally.
21615
21753
  // src/tracking/github-action.ts
21616
21754
  function generateWorkflow(config) {
21617
21755
  const cronSchedule = getCronSchedule(config.schedule);
21756
+ const sync = !!config.syncToDashboard;
21757
+ const autoFixPRs = config.features.autoFix && config.features.createPRs;
21758
+ const keyEnv = sync ? `
21759
+ env:
21760
+ RANKCLI_API_KEY: \${{ secrets.RANKCLI_API_KEY }}` : "";
21761
+ const permissions = [
21762
+ ` contents: ${autoFixPRs ? "write" : "read"}`,
21763
+ ...config.features.createIssues ? [" issues: write"] : [],
21764
+ ...autoFixPRs ? [" pull-requests: write"] : []
21765
+ ].join("\n");
21618
21766
  return `# RankCLI - Automated SEO Monitoring
21619
21767
  # https://rankcli.dev - generated by \`rankcli setup --github-action\`
21620
21768
 
@@ -21633,9 +21781,7 @@ jobs:
21633
21781
  seo-check:
21634
21782
  runs-on: ubuntu-latest
21635
21783
  permissions:
21636
- contents: write
21637
- issues: write
21638
- pull-requests: write
21784
+ ${permissions}
21639
21785
 
21640
21786
  steps:
21641
21787
  - name: Checkout repository
@@ -21644,36 +21790,60 @@ jobs:
21644
21790
  - name: Setup Node.js
21645
21791
  uses: actions/setup-node@v4
21646
21792
  with:
21647
- node-version: '20'
21793
+ node-version: '22'
21648
21794
 
21649
21795
  - name: Install RankCLI
21650
21796
  run: npm install -g @rankcli/cli
21651
- ${config.syncToDashboard ? `
21652
- - name: Log in to RankCLI
21653
- env:
21654
- RANKCLI_API_KEY: \${{ secrets.RANKCLI_API_KEY }}
21655
- run: rankcli login --token "$RANKCLI_API_KEY"
21797
+ ${sync ? `
21798
+ # Fails fast if the secret is missing or the key was revoked, instead
21799
+ # of silently auditing on the Free tier.
21800
+ - name: Verify RankCLI API key${keyEnv}
21801
+ run: |
21802
+ if [ -z "$RANKCLI_API_KEY" ]; then
21803
+ echo "::error::The RANKCLI_API_KEY secret is not set - see .github/SEO_SETUP.md"
21804
+ exit 1
21805
+ fi
21806
+ rankcli login
21656
21807
  ` : ""}
21808
+ # Exit codes: 0 ok, 1 a gate tripped, 2 bad invocation, 3 site
21809
+ # unreachable or the URL answered 4xx/5xx. To fail this job on SEO
21810
+ # regressions, add gates, e.g.: --fail-on error --min-score 80
21657
21811
  - name: Run SEO audit
21658
- run: |
21659
- rankcli audit --url "$SITE_URL" --output json > audit-report.json
21812
+ id: audit${keyEnv}
21813
+ run: rankcli audit --url "$SITE_URL" --output json > "$RUNNER_TEMP/audit-report.json"
21660
21814
  ${config.features.createIssues ? `
21661
21815
  - name: Create/update SEO report issue
21816
+ if: \${{ !cancelled() && steps.audit.outcome != 'skipped' }}
21662
21817
  uses: actions/github-script@v7
21663
21818
  with:
21664
21819
  script: |
21665
21820
  const fs = require('fs');
21666
- const report = JSON.parse(fs.readFileSync('audit-report.json', 'utf8'));
21667
- const errors = report.issues.filter(i => i.severity === 'error');
21668
- const warnings = report.issues.filter(i => i.severity === 'warning');
21669
- const notices = report.issues.filter(i => i.severity === 'notice');
21821
+ let report = {};
21822
+ try {
21823
+ report = JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP + '/audit-report.json', 'utf8'));
21824
+ } catch (e) {
21825
+ core.setFailed('audit-report.json is not valid JSON: ' + e.message);
21826
+ return;
21827
+ }
21828
+ const all = report.issues || [];
21829
+ const errors = all.filter(i => i.severity === 'error' || i.severity === 'critical');
21830
+ const warnings = all.filter(i => i.severity === 'warning');
21831
+ const notices = all.filter(i => i.severity === 'notice' || i.severity === 'info');
21670
21832
 
21671
21833
  const lines = [
21672
21834
  \`## RankCLI SEO Report\`,
21673
21835
  '',
21674
- \`**Score:** \${report.score}/100 \xB7 **Errors:** \${errors.length} \xB7 **Warnings:** \${warnings.length} \xB7 **Notices:** \${notices.length}\`,
21675
- '',
21676
21836
  ];
21837
+ if (report.error) {
21838
+ lines.push(\`> **Audit failed:** \${report.error}\`, '');
21839
+ }
21840
+ lines.push(
21841
+ \`**Score:** \${report.score ?? 0}/100 \xB7 **Errors:** \${errors.length} \xB7 **Warnings:** \${warnings.length} \xB7 **Notices:** \${notices.length}\`,
21842
+ '',
21843
+ );
21844
+ if (report.gate && !report.gate.passed) {
21845
+ lines.push('### CI gate failed', ...report.gate.failures.map(f => \`- \${f}\`), '');
21846
+ }
21677
21847
  if (errors.length > 0) {
21678
21848
  lines.push('### Errors', ...errors.slice(0, 15).map(i => \`- \${i.title}\`), '');
21679
21849
  }
@@ -21709,18 +21879,24 @@ ${config.features.createIssues ? `
21709
21879
  labels: ['seo-report', 'automated'],
21710
21880
  });
21711
21881
  }
21712
- ` : ""}${config.features.autoFix && config.features.createPRs ? `
21882
+ ` : ""}${autoFixPRs ? `
21883
+ # Auto-fix needs a Solo+ plan (resolved from RANKCLI_API_KEY). It
21884
+ # always writes JSON - appliedCount is 0 when there was nothing to fix
21885
+ # or the plan doesn't include auto-fix.
21713
21886
  - name: Auto-fix issues
21714
- run: rankcli fix --url "$SITE_URL" --auto --output json > fixes.json
21887
+ id: autofix
21888
+ if: \${{ !cancelled() && steps.audit.outcome != 'skipped' }}${keyEnv}
21889
+ run: rankcli fix --url "$SITE_URL" --auto --output json > "$RUNNER_TEMP/fixes.json"
21715
21890
 
21716
21891
  - name: Check if any fixes were applied
21717
21892
  id: fixes
21893
+ if: \${{ !cancelled() && steps.autofix.outcome != 'skipped' }}
21718
21894
  run: |
21719
- APPLIED=$(node -p "JSON.parse(require('fs').readFileSync('fixes.json','utf8')).appliedCount || 0")
21720
- echo "applied=$APPLIED" >> $GITHUB_OUTPUT
21895
+ APPLIED=$(node -e "let r = {}; try { r = JSON.parse(require('fs').readFileSync(process.env.RUNNER_TEMP + '/fixes.json', 'utf8')); } catch {} console.log(r.appliedCount || 0)")
21896
+ echo "applied=$APPLIED" >> "$GITHUB_OUTPUT"
21721
21897
 
21722
21898
  - name: Create PR with fixes
21723
- if: steps.fixes.outputs.applied != '0'
21899
+ if: \${{ !cancelled() && steps.fixes.outputs.applied != '' && steps.fixes.outputs.applied != '0' }}
21724
21900
  uses: peter-evans/create-pull-request@v6
21725
21901
  with:
21726
21902
  token: \${{ secrets.GITHUB_TOKEN }}
@@ -21739,12 +21915,14 @@ ${config.features.createIssues ? `
21739
21915
  labels: seo, automated
21740
21916
  ` : ""}
21741
21917
  - name: Save report artifact
21918
+ if: \${{ !cancelled() }}
21742
21919
  uses: actions/upload-artifact@v4
21743
21920
  with:
21744
21921
  name: rankcli-report-\${{ github.run_number }}
21745
21922
  path: |
21746
- audit-report.json
21747
- ${config.features.autoFix ? "fixes.json" : ""}
21923
+ \${{ runner.temp }}/audit-report.json${autoFixPRs ? `
21924
+ \${{ runner.temp }}/fixes.json` : ""}
21925
+ if-no-files-found: ignore
21748
21926
  retention-days: 90
21749
21927
  `;
21750
21928
  }
@@ -21777,19 +21955,20 @@ Go to your repository Settings > Secrets and variables > Actions
21777
21955
  ## RankCLI (required - sends audits to your dashboard)
21778
21956
 
21779
21957
  1. \`RANKCLI_API_KEY\`
21780
- - Get a free API key at https://rankcli.dev/account?tab=api-keys
21781
- - Without this secret, remove the "Log in to RankCLI" step from
21782
- .github/workflows/seo.yml - the audit still runs, it just won't
21783
- appear on your dashboard.
21958
+ - Create one at https://rankcli.dev/account?tab=api-keys
21959
+ - It is only passed to the RankCLI steps. The "Verify RankCLI API key"
21960
+ step fails the run if the secret is missing or the key was revoked,
21961
+ rather than silently auditing on the Free tier.
21962
+ - Your plan comes from the key: auto-fix PRs need Solo or higher.
21784
21963
 
21785
21964
  `;
21786
21965
  } else {
21787
21966
  secrets += `
21788
21967
  No secrets are required for this workflow as configured - it runs a local
21789
- audit and, if enabled, opens auto-fix PRs using the repository's built-in
21790
- GITHUB_TOKEN. To also sync results to your RankCLI dashboard, re-run
21791
- \`rankcli setup --github-action\` and opt in when asked, or add a
21792
- \`RANKCLI_API_KEY\` secret and a \`rankcli login --token\` step yourself.
21968
+ audit and files the results as a GitHub issue using the repository's
21969
+ built-in GITHUB_TOKEN. To also sync results to your RankCLI dashboard and
21970
+ get auto-fix PRs (Solo plan or higher), re-run
21971
+ \`rankcli setup --github-action --sync\` and add a \`RANKCLI_API_KEY\` secret.
21793
21972
 
21794
21973
  `;
21795
21974
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rankcli/agent-runtime",
3
- "version": "0.0.20",
3
+ "version": "0.0.21",
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",
@@ -25,7 +25,6 @@
25
25
  "build:deno": "tsx scripts/build-deno.ts",
26
26
  "generate:recipes": "tsx scripts/generate-embedded-recipes.ts",
27
27
  "build:wasm": "tsup --config tsup.wasm.config.ts",
28
- "lint": "eslint src/",
29
28
  "test": "vitest run",
30
29
  "test:watch": "vitest",
31
30
  "test:coverage": "vitest run --coverage"