@rankcli/agent-runtime 0.0.20 → 0.0.22
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 +27 -1
- package/dist/index.d.ts +27 -1
- package/dist/index.js +250 -71
- package/dist/index.mjs +250 -71
- package/package.json +1 -2
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: "
|
|
5947
|
+
severity: "warning",
|
|
5936
5948
|
category: "indexability",
|
|
5937
|
-
title: "Page has noindex tag",
|
|
5938
|
-
description: "The page contains a
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
},
|
|
@@ -18827,7 +18965,7 @@ function formatPRBody(fixes, options) {
|
|
|
18827
18965
|
body += "- [ ] Test social share previews (if OG tags changed)\n";
|
|
18828
18966
|
body += "\n";
|
|
18829
18967
|
body += "---\n";
|
|
18830
|
-
body += "\u{1F916} Generated with [
|
|
18968
|
+
body += "\u{1F916} Generated with [RankCLI](https://rankcli.dev)\n";
|
|
18831
18969
|
return body;
|
|
18832
18970
|
}
|
|
18833
18971
|
function generatePRDescription(fixes, config) {
|
|
@@ -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
|
-
|
|
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: '
|
|
25342
|
+
node-version: '22'
|
|
25197
25343
|
|
|
25198
25344
|
- name: Install RankCLI
|
|
25199
25345
|
run: npm install -g @rankcli/cli
|
|
25200
|
-
${
|
|
25201
|
-
|
|
25202
|
-
|
|
25203
|
-
|
|
25204
|
-
run:
|
|
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
|
-
|
|
25208
|
-
|
|
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
|
-
|
|
25216
|
-
|
|
25217
|
-
|
|
25218
|
-
|
|
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
|
-
` : ""}${
|
|
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
|
-
|
|
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 -
|
|
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
|
-
|
|
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
|
-
-
|
|
25330
|
-
-
|
|
25331
|
-
|
|
25332
|
-
|
|
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
|
|
25339
|
-
GITHUB_TOKEN. To also sync results to your RankCLI dashboard
|
|
25340
|
-
|
|
25341
|
-
\`
|
|
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
|
}
|
|
@@ -25434,7 +25613,7 @@ function generateMarkdownReport(data) {
|
|
|
25434
25613
|
lines.push("");
|
|
25435
25614
|
lines.push("---");
|
|
25436
25615
|
lines.push("");
|
|
25437
|
-
lines.push("*Generated by [
|
|
25616
|
+
lines.push("*Generated by [RankCLI](https://rankcli.dev) \u{1F916}*");
|
|
25438
25617
|
return lines.join("\n");
|
|
25439
25618
|
}
|
|
25440
25619
|
function generateExecutiveSummary(data) {
|
|
@@ -31811,7 +31990,7 @@ function generateOGFix(context, siteName, url) {
|
|
|
31811
31990
|
skipReason: "OG tags already exist"
|
|
31812
31991
|
};
|
|
31813
31992
|
}
|
|
31814
|
-
const ogTags = `<!--
|
|
31993
|
+
const ogTags = `<!-- RankCLI: Missing OG tags -->
|
|
31815
31994
|
${missingTags.join("\n ")}`;
|
|
31816
31995
|
return {
|
|
31817
31996
|
issue: { code: "MISSING_OG_TAGS", message: "Missing Open Graph tags", severity: "warning" },
|
|
@@ -32649,8 +32828,8 @@ var Schemas = {
|
|
|
32649
32828
|
var import_child_process2 = require("child_process");
|
|
32650
32829
|
var import_util2 = require("util");
|
|
32651
32830
|
var execAsync2 = (0, import_util2.promisify)(import_child_process2.exec);
|
|
32652
|
-
var SYSTEM_NAME = process.env.SEO_SYSTEM_NAME || "
|
|
32653
|
-
var SYSTEM_EMAIL = process.env.SEO_SYSTEM_EMAIL || "bot@
|
|
32831
|
+
var SYSTEM_NAME = process.env.SEO_SYSTEM_NAME || "RankCLI";
|
|
32832
|
+
var SYSTEM_EMAIL = process.env.SEO_SYSTEM_EMAIL || "bot@rankcli.dev";
|
|
32654
32833
|
var CATEGORY_MAPPING2 = {
|
|
32655
32834
|
"crawlability": { type: "fix", scope: "seo" },
|
|
32656
32835
|
"indexability": { type: "fix", scope: "seo" },
|
|
@@ -33072,7 +33251,7 @@ function formatSlackMessage(message) {
|
|
|
33072
33251
|
elements: [
|
|
33073
33252
|
{
|
|
33074
33253
|
type: "mrkdwn",
|
|
33075
|
-
text: `\u{1F916} Sent by
|
|
33254
|
+
text: `\u{1F916} Sent by RankCLI`
|
|
33076
33255
|
}
|
|
33077
33256
|
]
|
|
33078
33257
|
}
|
|
@@ -33108,7 +33287,7 @@ function formatDiscordMessage(message) {
|
|
|
33108
33287
|
color,
|
|
33109
33288
|
url: message.url,
|
|
33110
33289
|
footer: {
|
|
33111
|
-
text: "\u{1F916} Sent by
|
|
33290
|
+
text: "\u{1F916} Sent by RankCLI"
|
|
33112
33291
|
},
|
|
33113
33292
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
33114
33293
|
}
|
|
@@ -33182,7 +33361,7 @@ async function sendAlerts(configs, type, payload) {
|
|
|
33182
33361
|
|
|
33183
33362
|
// src/reports/report-generator.ts
|
|
33184
33363
|
var DEFAULT_BRANDING = {
|
|
33185
|
-
companyName: "
|
|
33364
|
+
companyName: "RankCLI",
|
|
33186
33365
|
primaryColor: "#2563eb",
|
|
33187
33366
|
accentColor: "#10b981"
|
|
33188
33367
|
};
|
|
@@ -33650,7 +33829,7 @@ function generateHTMLReport(data, config = {}) {
|
|
|
33650
33829
|
${branding.contactEmail ? `<p>Contact: <a href="mailto:${branding.contactEmail}">${branding.contactEmail}</a></p>` : ""}
|
|
33651
33830
|
${branding.websiteUrl ? `<p><a href="${branding.websiteUrl}">${branding.websiteUrl}</a></p>` : ""}
|
|
33652
33831
|
</footer>` : `<footer>
|
|
33653
|
-
<p>Generated by <a href="https://
|
|
33832
|
+
<p>Generated by <a href="https://rankcli.dev">RankCLI</a> on ${formatDate(data.timestamp)}</p>
|
|
33654
33833
|
</footer>`;
|
|
33655
33834
|
const categoriesHtml = Object.entries(data.healthScores).filter(([key]) => key !== "overall").map(([key, value]) => `
|
|
33656
33835
|
<div class="category-card">
|
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: "
|
|
2432
|
+
severity: "warning",
|
|
2421
2433
|
category: "indexability",
|
|
2422
|
-
title: "Page has noindex tag",
|
|
2423
|
-
description: "The page contains a
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
},
|
|
@@ -15284,7 +15422,7 @@ function formatPRBody(fixes, options) {
|
|
|
15284
15422
|
body += "- [ ] Test social share previews (if OG tags changed)\n";
|
|
15285
15423
|
body += "\n";
|
|
15286
15424
|
body += "---\n";
|
|
15287
|
-
body += "\u{1F916} Generated with [
|
|
15425
|
+
body += "\u{1F916} Generated with [RankCLI](https://rankcli.dev)\n";
|
|
15288
15426
|
return body;
|
|
15289
15427
|
}
|
|
15290
15428
|
function generatePRDescription(fixes, config) {
|
|
@@ -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
|
-
|
|
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: '
|
|
21793
|
+
node-version: '22'
|
|
21648
21794
|
|
|
21649
21795
|
- name: Install RankCLI
|
|
21650
21796
|
run: npm install -g @rankcli/cli
|
|
21651
|
-
${
|
|
21652
|
-
|
|
21653
|
-
|
|
21654
|
-
|
|
21655
|
-
run:
|
|
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
|
-
|
|
21659
|
-
|
|
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
|
-
|
|
21667
|
-
|
|
21668
|
-
|
|
21669
|
-
|
|
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
|
-
` : ""}${
|
|
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
|
-
|
|
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 -
|
|
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
|
-
|
|
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
|
-
-
|
|
21781
|
-
-
|
|
21782
|
-
|
|
21783
|
-
|
|
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
|
|
21790
|
-
GITHUB_TOKEN. To also sync results to your RankCLI dashboard
|
|
21791
|
-
|
|
21792
|
-
\`
|
|
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
|
}
|
|
@@ -21885,7 +22064,7 @@ function generateMarkdownReport(data) {
|
|
|
21885
22064
|
lines.push("");
|
|
21886
22065
|
lines.push("---");
|
|
21887
22066
|
lines.push("");
|
|
21888
|
-
lines.push("*Generated by [
|
|
22067
|
+
lines.push("*Generated by [RankCLI](https://rankcli.dev) \u{1F916}*");
|
|
21889
22068
|
return lines.join("\n");
|
|
21890
22069
|
}
|
|
21891
22070
|
function generateExecutiveSummary(data) {
|
|
@@ -28256,7 +28435,7 @@ function generateOGFix(context, siteName, url) {
|
|
|
28256
28435
|
skipReason: "OG tags already exist"
|
|
28257
28436
|
};
|
|
28258
28437
|
}
|
|
28259
|
-
const ogTags = `<!--
|
|
28438
|
+
const ogTags = `<!-- RankCLI: Missing OG tags -->
|
|
28260
28439
|
${missingTags.join("\n ")}`;
|
|
28261
28440
|
return {
|
|
28262
28441
|
issue: { code: "MISSING_OG_TAGS", message: "Missing Open Graph tags", severity: "warning" },
|
|
@@ -29094,8 +29273,8 @@ var Schemas = {
|
|
|
29094
29273
|
import { execSync, exec as exec2 } from "child_process";
|
|
29095
29274
|
import { promisify as promisify2 } from "util";
|
|
29096
29275
|
var execAsync2 = promisify2(exec2);
|
|
29097
|
-
var SYSTEM_NAME = process.env.SEO_SYSTEM_NAME || "
|
|
29098
|
-
var SYSTEM_EMAIL = process.env.SEO_SYSTEM_EMAIL || "bot@
|
|
29276
|
+
var SYSTEM_NAME = process.env.SEO_SYSTEM_NAME || "RankCLI";
|
|
29277
|
+
var SYSTEM_EMAIL = process.env.SEO_SYSTEM_EMAIL || "bot@rankcli.dev";
|
|
29099
29278
|
var CATEGORY_MAPPING2 = {
|
|
29100
29279
|
"crawlability": { type: "fix", scope: "seo" },
|
|
29101
29280
|
"indexability": { type: "fix", scope: "seo" },
|
|
@@ -29517,7 +29696,7 @@ function formatSlackMessage(message) {
|
|
|
29517
29696
|
elements: [
|
|
29518
29697
|
{
|
|
29519
29698
|
type: "mrkdwn",
|
|
29520
|
-
text: `\u{1F916} Sent by
|
|
29699
|
+
text: `\u{1F916} Sent by RankCLI`
|
|
29521
29700
|
}
|
|
29522
29701
|
]
|
|
29523
29702
|
}
|
|
@@ -29553,7 +29732,7 @@ function formatDiscordMessage(message) {
|
|
|
29553
29732
|
color,
|
|
29554
29733
|
url: message.url,
|
|
29555
29734
|
footer: {
|
|
29556
|
-
text: "\u{1F916} Sent by
|
|
29735
|
+
text: "\u{1F916} Sent by RankCLI"
|
|
29557
29736
|
},
|
|
29558
29737
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
29559
29738
|
}
|
|
@@ -29627,7 +29806,7 @@ async function sendAlerts(configs, type, payload) {
|
|
|
29627
29806
|
|
|
29628
29807
|
// src/reports/report-generator.ts
|
|
29629
29808
|
var DEFAULT_BRANDING = {
|
|
29630
|
-
companyName: "
|
|
29809
|
+
companyName: "RankCLI",
|
|
29631
29810
|
primaryColor: "#2563eb",
|
|
29632
29811
|
accentColor: "#10b981"
|
|
29633
29812
|
};
|
|
@@ -30095,7 +30274,7 @@ function generateHTMLReport(data, config = {}) {
|
|
|
30095
30274
|
${branding.contactEmail ? `<p>Contact: <a href="mailto:${branding.contactEmail}">${branding.contactEmail}</a></p>` : ""}
|
|
30096
30275
|
${branding.websiteUrl ? `<p><a href="${branding.websiteUrl}">${branding.websiteUrl}</a></p>` : ""}
|
|
30097
30276
|
</footer>` : `<footer>
|
|
30098
|
-
<p>Generated by <a href="https://
|
|
30277
|
+
<p>Generated by <a href="https://rankcli.dev">RankCLI</a> on ${formatDate(data.timestamp)}</p>
|
|
30099
30278
|
</footer>`;
|
|
30100
30279
|
const categoriesHtml = Object.entries(data.healthScores).filter(([key]) => key !== "overall").map(([key, value]) => `
|
|
30101
30280
|
<div class="category-card">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rankcli/agent-runtime",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.22",
|
|
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"
|