@rankcli/agent-runtime 0.0.19 → 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 +27 -1
- package/dist/index.d.ts +27 -1
- package/dist/index.js +248 -64
- package/dist/index.mjs +248 -64
- 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",
|
|
8749
|
+
affectedUrls: [url],
|
|
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
|
+
},
|
|
8660
8765
|
affectedUrls: [url],
|
|
8661
|
-
details: {
|
|
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
|
},
|
|
@@ -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
|
}
|
|
@@ -33778,6 +33957,11 @@ async function generatePDFReport(data, config = {}) {
|
|
|
33778
33957
|
return Buffer.from(html, "utf-8");
|
|
33779
33958
|
}
|
|
33780
33959
|
|
|
33960
|
+
// src/models.ts
|
|
33961
|
+
var GOOGLE_FAST = "gemini-3.7-flash";
|
|
33962
|
+
var GOOGLE_ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/${GOOGLE_FAST}:generateContent`;
|
|
33963
|
+
var OPENAI_FLAGSHIP = "gpt-5.6";
|
|
33964
|
+
|
|
33781
33965
|
// src/geo/geo-tracker.ts
|
|
33782
33966
|
function detectMentions(text, config) {
|
|
33783
33967
|
const mentions = [];
|
|
@@ -33960,7 +34144,7 @@ function getProviderEndpoint(provider) {
|
|
|
33960
34144
|
case "anthropic":
|
|
33961
34145
|
return "https://api.anthropic.com/v1/messages";
|
|
33962
34146
|
case "google":
|
|
33963
|
-
return
|
|
34147
|
+
return GOOGLE_ENDPOINT;
|
|
33964
34148
|
case "perplexity":
|
|
33965
34149
|
return "https://api.perplexity.ai/chat/completions";
|
|
33966
34150
|
default:
|
|
@@ -33975,7 +34159,7 @@ async function queryProvider(provider, keyword, fetchFn) {
|
|
|
33975
34159
|
"Content-Type": "application/json"
|
|
33976
34160
|
},
|
|
33977
34161
|
body: JSON.stringify({
|
|
33978
|
-
model: provider === "openai" ?
|
|
34162
|
+
model: provider === "openai" ? OPENAI_FLAGSHIP : void 0,
|
|
33979
34163
|
messages: [
|
|
33980
34164
|
{
|
|
33981
34165
|
role: "user",
|
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",
|
|
5230
|
+
affectedUrls: [url],
|
|
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
|
+
},
|
|
5141
5246
|
affectedUrls: [url],
|
|
5142
|
-
details: {
|
|
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
|
},
|
|
@@ -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
|
}
|
|
@@ -30223,6 +30402,11 @@ async function generatePDFReport(data, config = {}) {
|
|
|
30223
30402
|
return Buffer.from(html, "utf-8");
|
|
30224
30403
|
}
|
|
30225
30404
|
|
|
30405
|
+
// src/models.ts
|
|
30406
|
+
var GOOGLE_FAST = "gemini-3.7-flash";
|
|
30407
|
+
var GOOGLE_ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/${GOOGLE_FAST}:generateContent`;
|
|
30408
|
+
var OPENAI_FLAGSHIP = "gpt-5.6";
|
|
30409
|
+
|
|
30226
30410
|
// src/geo/geo-tracker.ts
|
|
30227
30411
|
function detectMentions(text, config) {
|
|
30228
30412
|
const mentions = [];
|
|
@@ -30405,7 +30589,7 @@ function getProviderEndpoint(provider) {
|
|
|
30405
30589
|
case "anthropic":
|
|
30406
30590
|
return "https://api.anthropic.com/v1/messages";
|
|
30407
30591
|
case "google":
|
|
30408
|
-
return
|
|
30592
|
+
return GOOGLE_ENDPOINT;
|
|
30409
30593
|
case "perplexity":
|
|
30410
30594
|
return "https://api.perplexity.ai/chat/completions";
|
|
30411
30595
|
default:
|
|
@@ -30420,7 +30604,7 @@ async function queryProvider(provider, keyword, fetchFn) {
|
|
|
30420
30604
|
"Content-Type": "application/json"
|
|
30421
30605
|
},
|
|
30422
30606
|
body: JSON.stringify({
|
|
30423
|
-
model: provider === "openai" ?
|
|
30607
|
+
model: provider === "openai" ? OPENAI_FLAGSHIP : void 0,
|
|
30424
30608
|
messages: [
|
|
30425
30609
|
{
|
|
30426
30610
|
role: "user",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rankcli/agent-runtime",
|
|
3
|
-
"version": "0.0.
|
|
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"
|