@rankcli/agent-runtime 0.0.36 → 0.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
httpHead,
|
|
16
16
|
httpPost,
|
|
17
17
|
looksLikeHtmlFallback
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-ZLQQXIZ3.mjs";
|
|
19
19
|
import {
|
|
20
20
|
AI_CRAWLERS_INFO,
|
|
21
21
|
analyzeCitationReadiness,
|
|
@@ -6459,7 +6459,9 @@ function analyzeAnchorText(html, url) {
|
|
|
6459
6459
|
} catch {
|
|
6460
6460
|
internalLinks++;
|
|
6461
6461
|
}
|
|
6462
|
-
const
|
|
6462
|
+
const imgAlt = $(el).find("img[alt]").map((_2, img) => ($(img).attr("alt") || "").trim()).get().join(" ").trim();
|
|
6463
|
+
const svgTitle = $(el).find("svg title").text().trim();
|
|
6464
|
+
const hasAccessibleText = text.length > 0 || ariaLabel || title || imgAlt.length > 0 || svgTitle.length > 0;
|
|
6463
6465
|
if (!hasAccessibleText) {
|
|
6464
6466
|
emptyAnchors++;
|
|
6465
6467
|
if (emptyAnchorUrls.length < 5) {
|
|
@@ -6862,6 +6864,120 @@ async function checkInternalRedirects(internalLinks, batchSize = 10) {
|
|
|
6862
6864
|
return { redirectLinks };
|
|
6863
6865
|
}
|
|
6864
6866
|
|
|
6867
|
+
// src/audit/checks/crawler-access.ts
|
|
6868
|
+
var CRAWLERS = [
|
|
6869
|
+
{
|
|
6870
|
+
name: "Googlebot",
|
|
6871
|
+
userAgent: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
|
|
6872
|
+
matters: "Google Search indexing",
|
|
6873
|
+
critical: true
|
|
6874
|
+
},
|
|
6875
|
+
{
|
|
6876
|
+
name: "bingbot",
|
|
6877
|
+
userAgent: "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
|
|
6878
|
+
matters: "Bing indexing, and Copilot answers",
|
|
6879
|
+
critical: true
|
|
6880
|
+
},
|
|
6881
|
+
{
|
|
6882
|
+
name: "GPTBot",
|
|
6883
|
+
userAgent: "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)",
|
|
6884
|
+
matters: "ChatGPT being able to read and cite the page",
|
|
6885
|
+
critical: false
|
|
6886
|
+
},
|
|
6887
|
+
{
|
|
6888
|
+
name: "ClaudeBot",
|
|
6889
|
+
userAgent: "Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)",
|
|
6890
|
+
matters: "Claude being able to read and cite the page",
|
|
6891
|
+
critical: false
|
|
6892
|
+
},
|
|
6893
|
+
{
|
|
6894
|
+
name: "PerplexityBot",
|
|
6895
|
+
userAgent: "Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)",
|
|
6896
|
+
matters: "Perplexity being able to read and cite the page",
|
|
6897
|
+
critical: false
|
|
6898
|
+
}
|
|
6899
|
+
];
|
|
6900
|
+
var BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
|
6901
|
+
function isRefusal(status) {
|
|
6902
|
+
return status === 401 || status === 403 || status === 429 || status === 503;
|
|
6903
|
+
}
|
|
6904
|
+
async function statusFor(url, userAgent) {
|
|
6905
|
+
try {
|
|
6906
|
+
const response = await httpGet(url, {
|
|
6907
|
+
headers: { "User-Agent": userAgent },
|
|
6908
|
+
timeout: 15e3,
|
|
6909
|
+
// We want the status itself, not an exception on 4xx/5xx.
|
|
6910
|
+
validateStatus: () => true
|
|
6911
|
+
});
|
|
6912
|
+
return response.status;
|
|
6913
|
+
} catch {
|
|
6914
|
+
return null;
|
|
6915
|
+
}
|
|
6916
|
+
}
|
|
6917
|
+
async function analyzeCrawlerAccess(url) {
|
|
6918
|
+
const issues = [];
|
|
6919
|
+
const baselineStatus = await statusFor(url, BROWSER_UA);
|
|
6920
|
+
const data = { baselineStatus, crawlers: [] };
|
|
6921
|
+
if (baselineStatus === null || baselineStatus >= 400) {
|
|
6922
|
+
return { issues, data };
|
|
6923
|
+
}
|
|
6924
|
+
const results = await Promise.all(
|
|
6925
|
+
CRAWLERS.map(async (crawler) => ({
|
|
6926
|
+
crawler,
|
|
6927
|
+
status: await statusFor(url, crawler.userAgent)
|
|
6928
|
+
}))
|
|
6929
|
+
);
|
|
6930
|
+
const blocked = results.filter(({ status }) => status !== null && isRefusal(status));
|
|
6931
|
+
data.crawlers = results.map(({ crawler, status }) => ({
|
|
6932
|
+
name: crawler.name,
|
|
6933
|
+
status,
|
|
6934
|
+
blocked: status !== null && isRefusal(status)
|
|
6935
|
+
}));
|
|
6936
|
+
if (blocked.length === 0) return { issues, data };
|
|
6937
|
+
const search = blocked.filter(({ crawler }) => crawler.critical);
|
|
6938
|
+
const ai = blocked.filter(({ crawler }) => !crawler.critical);
|
|
6939
|
+
if (search.length > 0) {
|
|
6940
|
+
const names = search.map(({ crawler }) => crawler.name).join(", ");
|
|
6941
|
+
issues.push({
|
|
6942
|
+
code: "SEARCH_CRAWLER_BLOCKED_AT_EDGE",
|
|
6943
|
+
severity: "error",
|
|
6944
|
+
category: "crawlability",
|
|
6945
|
+
title: `${names} refused at the edge, not by robots.txt`,
|
|
6946
|
+
description: `A browser request to this URL returned ${baselineStatus}, but ${names} received ${search.map(({ status }) => status).join(", ")}. Something in front of the origin - a CDN, WAF or bot rule - is turning the crawler away, and robots.txt cannot show you this.`,
|
|
6947
|
+
impact: "Pages a search crawler cannot fetch fall out of the index. This is the failure mode behind Cloudflare\u2019s 2026-09-15 default, where blocking AI training also blocked Googlebot because it is classed as a mixed-use crawler.",
|
|
6948
|
+
howToFix: `Check your CDN or WAF bot rules rather than robots.txt.
|
|
6949
|
+
|
|
6950
|
+
On Cloudflare: the zone's Security > Bots settings, and any "Block AI Bots" or AI Crawler Control toggle. Because Googlebot crawls for both Search and AI training, blocking Training applies to all of its functions on ad-supported pages - allow Search crawlers explicitly, or opt out of the default.
|
|
6951
|
+
|
|
6952
|
+
Confirm with Search Console's URL Inspection, which fetches as the verified Googlebot.`,
|
|
6953
|
+
affectedUrls: [url],
|
|
6954
|
+
details: {
|
|
6955
|
+
baselineStatus,
|
|
6956
|
+
blocked: search.map(({ crawler, status }) => ({ crawler: crawler.name, status })),
|
|
6957
|
+
note: "Observed with the crawler\u2019s user-agent. Real crawlers are also verified by IP, so a site may treat them differently."
|
|
6958
|
+
}
|
|
6959
|
+
});
|
|
6960
|
+
}
|
|
6961
|
+
if (ai.length > 0) {
|
|
6962
|
+
const names = ai.map(({ crawler }) => crawler.name).join(", ");
|
|
6963
|
+
issues.push({
|
|
6964
|
+
code: "AI_CRAWLER_BLOCKED_AT_EDGE",
|
|
6965
|
+
severity: "warning",
|
|
6966
|
+
category: "ai-readiness",
|
|
6967
|
+
title: `${names} refused at the edge, not by robots.txt`,
|
|
6968
|
+
description: `A browser request returned ${baselineStatus}, but ${names} received ${ai.map(({ status }) => status).join(", ")}. The block is in front of the origin, so allowing these agents in robots.txt will not change it.`,
|
|
6969
|
+
impact: "An assistant that cannot fetch the page cannot quote it. This is invisible to a robots.txt check, which is where most AI-visibility tools stop.",
|
|
6970
|
+
howToFix: `If blocking them is deliberate, nothing to do. If not, the rule is in your CDN or WAF, not robots.txt - on Cloudflare see Security > Bots and any AI Crawler Control setting.`,
|
|
6971
|
+
affectedUrls: [url],
|
|
6972
|
+
details: {
|
|
6973
|
+
baselineStatus,
|
|
6974
|
+
blocked: ai.map(({ crawler, status }) => ({ crawler: crawler.name, status }))
|
|
6975
|
+
}
|
|
6976
|
+
});
|
|
6977
|
+
}
|
|
6978
|
+
return { issues, data };
|
|
6979
|
+
}
|
|
6980
|
+
|
|
6865
6981
|
// src/audit/checks/ai-readiness.ts
|
|
6866
6982
|
import * as cheerio13 from "cheerio";
|
|
6867
6983
|
|
|
@@ -10901,6 +11017,106 @@ function analyzeResponsiveImages(html, url) {
|
|
|
10901
11017
|
|
|
10902
11018
|
// src/audit/checks/bing-optimization.ts
|
|
10903
11019
|
import * as cheerio28 from "cheerio";
|
|
11020
|
+
|
|
11021
|
+
// src/audit/checks/site-verification.ts
|
|
11022
|
+
var UNCHECKED = {
|
|
11023
|
+
dnsChecked: false,
|
|
11024
|
+
googleDns: false,
|
|
11025
|
+
bingDns: false,
|
|
11026
|
+
bingSiteAuthFile: false
|
|
11027
|
+
};
|
|
11028
|
+
var DNS_TIMEOUT_MS = 2e3;
|
|
11029
|
+
var FILE_TIMEOUT_MS = 5e3;
|
|
11030
|
+
var CACHE_TTL_MS = 10 * 60 * 1e3;
|
|
11031
|
+
var cache = /* @__PURE__ */ new Map();
|
|
11032
|
+
function lookupSiteVerification(url) {
|
|
11033
|
+
let parsed;
|
|
11034
|
+
try {
|
|
11035
|
+
parsed = new URL(url);
|
|
11036
|
+
} catch {
|
|
11037
|
+
return Promise.resolve(UNCHECKED);
|
|
11038
|
+
}
|
|
11039
|
+
const host = parsed.hostname.toLowerCase();
|
|
11040
|
+
const now = Date.now();
|
|
11041
|
+
const cached = cache.get(host);
|
|
11042
|
+
if (cached && cached.expires > now) return cached.result;
|
|
11043
|
+
const result = Promise.all([readTxtRecords(host), fetchBingSiteAuth(parsed.origin)]).then(
|
|
11044
|
+
([txt, bingSiteAuthFile]) => ({
|
|
11045
|
+
dnsChecked: txt.checked,
|
|
11046
|
+
googleDns: txt.records.some((record) => /^google-site-verification=/i.test(record)),
|
|
11047
|
+
bingDns: txt.records.some((record) => /^MS=/i.test(record)),
|
|
11048
|
+
bingSiteAuthFile
|
|
11049
|
+
})
|
|
11050
|
+
);
|
|
11051
|
+
cache.set(host, { expires: now + CACHE_TTL_MS, result });
|
|
11052
|
+
return result;
|
|
11053
|
+
}
|
|
11054
|
+
var resolveTxtLoader;
|
|
11055
|
+
function loadResolveTxt() {
|
|
11056
|
+
resolveTxtLoader ??= (async () => {
|
|
11057
|
+
if (typeof process === "undefined" || !process.versions?.node) return null;
|
|
11058
|
+
try {
|
|
11059
|
+
const specifier = "node:dns";
|
|
11060
|
+
const dns = await import(specifier);
|
|
11061
|
+
const resolveTxt = dns?.promises?.resolveTxt ?? dns?.default?.promises?.resolveTxt;
|
|
11062
|
+
return typeof resolveTxt === "function" ? resolveTxt : null;
|
|
11063
|
+
} catch {
|
|
11064
|
+
return null;
|
|
11065
|
+
}
|
|
11066
|
+
})();
|
|
11067
|
+
return resolveTxtLoader;
|
|
11068
|
+
}
|
|
11069
|
+
function domainCandidates(host) {
|
|
11070
|
+
if (/^[\d.]+$/.test(host) || host.includes(":")) return [];
|
|
11071
|
+
const labels = host.split(".").filter(Boolean);
|
|
11072
|
+
const names = [];
|
|
11073
|
+
for (let n = 2; n <= labels.length && names.length < 4; n++) {
|
|
11074
|
+
names.push(labels.slice(-n).join("."));
|
|
11075
|
+
}
|
|
11076
|
+
return names.length > 0 ? names : [host];
|
|
11077
|
+
}
|
|
11078
|
+
async function readTxtRecords(host) {
|
|
11079
|
+
const resolveTxt = await loadResolveTxt();
|
|
11080
|
+
if (!resolveTxt) return { checked: false, records: [] };
|
|
11081
|
+
const lookups = await Promise.all(domainCandidates(host).map((name) => resolveTxtWithin(resolveTxt, name)));
|
|
11082
|
+
return {
|
|
11083
|
+
checked: lookups.every((records) => records !== null),
|
|
11084
|
+
records: lookups.flatMap((records) => records ?? [])
|
|
11085
|
+
};
|
|
11086
|
+
}
|
|
11087
|
+
var NO_RECORD_CODES = /* @__PURE__ */ new Set(["ENODATA", "ENOTFOUND"]);
|
|
11088
|
+
async function resolveTxtWithin(resolveTxt, name) {
|
|
11089
|
+
let timer;
|
|
11090
|
+
const timeout = new Promise((resolve) => {
|
|
11091
|
+
timer = setTimeout(() => resolve(null), DNS_TIMEOUT_MS);
|
|
11092
|
+
});
|
|
11093
|
+
try {
|
|
11094
|
+
return await Promise.race([
|
|
11095
|
+
resolveTxt(name).then(
|
|
11096
|
+
(records) => records.map((chunks) => chunks.join("").trim()),
|
|
11097
|
+
(error) => NO_RECORD_CODES.has(error?.code ?? "") ? [] : null
|
|
11098
|
+
),
|
|
11099
|
+
timeout
|
|
11100
|
+
]);
|
|
11101
|
+
} catch {
|
|
11102
|
+
return null;
|
|
11103
|
+
} finally {
|
|
11104
|
+
clearTimeout(timer);
|
|
11105
|
+
}
|
|
11106
|
+
}
|
|
11107
|
+
async function fetchBingSiteAuth(origin) {
|
|
11108
|
+
try {
|
|
11109
|
+
const response = await httpGet(`${origin}/BingSiteAuth.xml`, {
|
|
11110
|
+
timeout: FILE_TIMEOUT_MS,
|
|
11111
|
+
maxBytes: 64 * 1024
|
|
11112
|
+
});
|
|
11113
|
+
return response.status === 200 && typeof response.data === "string" && !looksLikeHtmlFallback(response.data, response.headers["content-type"]) && /<users[\s>]/i.test(response.data);
|
|
11114
|
+
} catch {
|
|
11115
|
+
return false;
|
|
11116
|
+
}
|
|
11117
|
+
}
|
|
11118
|
+
|
|
11119
|
+
// src/audit/checks/bing-optimization.ts
|
|
10904
11120
|
async function analyzeBingOptimization(html, url) {
|
|
10905
11121
|
const issues = [];
|
|
10906
11122
|
const $ = cheerio28.load(html);
|
|
@@ -10909,12 +11125,9 @@ async function analyzeBingOptimization(html, url) {
|
|
|
10909
11125
|
const detectedIssues = [];
|
|
10910
11126
|
const bingMetaTag = $('meta[name="msvalidate.01"]').attr("content");
|
|
10911
11127
|
const hasBingMetaTag = !!bingMetaTag && bingMetaTag.length > 0;
|
|
10912
|
-
|
|
10913
|
-
|
|
10914
|
-
|
|
10915
|
-
hasBingSiteAuth = authResponse.status === 200 && authResponse.data.includes("<?xml") && authResponse.data.toLowerCase().includes("users");
|
|
10916
|
-
} catch {
|
|
10917
|
-
}
|
|
11128
|
+
const verification = await lookupSiteVerification(url);
|
|
11129
|
+
const hasBingSiteAuth = verification.bingSiteAuthFile;
|
|
11130
|
+
const hasBingDnsRecord = verification.bingDns;
|
|
10918
11131
|
let hasBingIndexNowKey = false;
|
|
10919
11132
|
try {
|
|
10920
11133
|
const keyPatterns = [
|
|
@@ -10962,22 +11175,25 @@ async function analyzeBingOptimization(html, url) {
|
|
|
10962
11175
|
bingSitemapAccessible = sitemapResponse.status === 200 && sitemapResponse.data.includes("<?xml");
|
|
10963
11176
|
} catch {
|
|
10964
11177
|
}
|
|
10965
|
-
if (!hasBingMetaTag && !hasBingSiteAuth) {
|
|
11178
|
+
if (!hasBingMetaTag && !hasBingSiteAuth && !hasBingDnsRecord) {
|
|
10966
11179
|
detectedIssues.push("No Bing site verification");
|
|
10967
11180
|
issues.push({
|
|
10968
11181
|
code: "BING_NOT_VERIFIED",
|
|
10969
11182
|
severity: "warning",
|
|
10970
11183
|
category: "ai-readiness",
|
|
10971
|
-
title: "Site not verified with Bing Webmaster Tools",
|
|
10972
|
-
description: "ChatGPT Search uses Bing's index. Verifying your site with Bing Webmaster Tools ensures proper indexing and access to Bing-specific SEO tools.",
|
|
11184
|
+
title: verification.dnsChecked ? "Site not verified with Bing Webmaster Tools" : "Bing Webmaster Tools verification not visible in the HTML",
|
|
11185
|
+
description: verification.dnsChecked ? "No msvalidate.01 meta tag in the HTML, no MS= TXT record on the domain and no /BingSiteAuth.xml file. ChatGPT Search uses Bing's index. Verifying your site with Bing Webmaster Tools ensures proper indexing and access to Bing-specific SEO tools." : "No msvalidate.01 meta tag in the HTML and no /BingSiteAuth.xml file. The site may be verified through a DNS record instead, which this run could not check. ChatGPT Search uses Bing's index. Verifying your site with Bing Webmaster Tools ensures proper indexing and access to Bing-specific SEO tools.",
|
|
10973
11186
|
impact: "May reduce visibility in ChatGPT Search results and miss Bing-specific optimization opportunities.",
|
|
10974
|
-
howToFix: "
|
|
11187
|
+
howToFix: "If the site is not yet in Bing Webmaster Tools: 1) Go to Bing Webmaster Tools, 2) Add your site (or import it from Google Search Console), 3) Choose a verification method (meta tag, DNS record or XML file), 4) Add the verification code to your site.",
|
|
10975
11188
|
affectedUrls: [url],
|
|
10976
11189
|
details: {
|
|
10977
11190
|
hasBingMetaTag,
|
|
10978
11191
|
hasBingSiteAuth,
|
|
11192
|
+
hasBingDnsRecord,
|
|
11193
|
+
dnsChecked: verification.dnsChecked,
|
|
10979
11194
|
verificationMethods: [
|
|
10980
11195
|
'Add <meta name="msvalidate.01" content="YOUR_CODE"> to <head>',
|
|
11196
|
+
"Add an MS=YOUR_CODE TXT record to the domain's DNS",
|
|
10981
11197
|
"Upload BingSiteAuth.xml to root directory"
|
|
10982
11198
|
]
|
|
10983
11199
|
}
|
|
@@ -11037,6 +11253,8 @@ async function analyzeBingOptimization(html, url) {
|
|
|
11037
11253
|
data: {
|
|
11038
11254
|
hasBingSiteAuth,
|
|
11039
11255
|
hasBingMetaTag,
|
|
11256
|
+
hasBingDnsRecord,
|
|
11257
|
+
dnsChecked: verification.dnsChecked,
|
|
11040
11258
|
hasBingIndexNowKey,
|
|
11041
11259
|
bingPlacesDetected,
|
|
11042
11260
|
bingSitemapAccessible,
|
|
@@ -13015,6 +13233,10 @@ function analyzePageResources(html, url) {
|
|
|
13015
13233
|
|
|
13016
13234
|
// src/audit/checks/responsive-css.ts
|
|
13017
13235
|
import * as cheerio35 from "cheerio";
|
|
13236
|
+
var MAX_STYLESHEETS = 3;
|
|
13237
|
+
var MAX_STYLESHEET_BYTES = 1024 * 1024;
|
|
13238
|
+
var STYLESHEET_TIMEOUT_MS = 3e3;
|
|
13239
|
+
var RESPONSIVE_SIGNAL = /@media\b|@container\b|\bclamp\(|(?<![\w-])(?:min|max)\(/i;
|
|
13018
13240
|
function extractMediaQueries(css) {
|
|
13019
13241
|
const mediaQueries = [];
|
|
13020
13242
|
const regex = /@media\s*([^{]+)/g;
|
|
@@ -13024,36 +13246,31 @@ function extractMediaQueries(css) {
|
|
|
13024
13246
|
}
|
|
13025
13247
|
return mediaQueries;
|
|
13026
13248
|
}
|
|
13249
|
+
var WIDTH_RULE = /(?:(?:min|max)-width\s*:|width\s*(?:<=|>=|<|>))\s*(\d+(?:\.\d+)?)\s*(px|em|rem)?|(\d+(?:\.\d+)?)\s*(px|em|rem)?\s*(?:<=|>=|<|>)\s*width/gi;
|
|
13027
13250
|
function classifyBreakpoints(mediaQueries) {
|
|
13028
13251
|
const breakpoints = /* @__PURE__ */ new Set();
|
|
13029
13252
|
let hasMobile = false;
|
|
13030
13253
|
let hasTablet = false;
|
|
13031
13254
|
let hasDesktop = false;
|
|
13032
13255
|
for (const query of mediaQueries) {
|
|
13033
|
-
const widthMatches = query.match(/(?:min|max)-width\s*:\s*(\d+)(?:px|em|rem)?/gi);
|
|
13034
|
-
if (widthMatches) {
|
|
13035
|
-
for (const match of widthMatches) {
|
|
13036
|
-
const valueMatch = match.match(/(\d+)/);
|
|
13037
|
-
if (valueMatch) {
|
|
13038
|
-
const value = parseInt(valueMatch[1], 10);
|
|
13039
|
-
breakpoints.add(`${value}px`);
|
|
13040
|
-
if (value <= 480) {
|
|
13041
|
-
hasMobile = true;
|
|
13042
|
-
} else if (value <= 768) {
|
|
13043
|
-
hasMobile = true;
|
|
13044
|
-
} else if (value <= 1024) {
|
|
13045
|
-
hasTablet = true;
|
|
13046
|
-
} else {
|
|
13047
|
-
hasDesktop = true;
|
|
13048
|
-
}
|
|
13049
|
-
}
|
|
13050
|
-
}
|
|
13051
|
-
}
|
|
13052
|
-
if (/screen\s+and/i.test(query)) {
|
|
13053
|
-
}
|
|
13054
13256
|
if (/print/i.test(query)) {
|
|
13055
13257
|
continue;
|
|
13056
13258
|
}
|
|
13259
|
+
for (const match of query.matchAll(WIDTH_RULE)) {
|
|
13260
|
+
const raw = match[1] ?? match[3];
|
|
13261
|
+
const unit = (match[2] ?? match[4] ?? "px").toLowerCase();
|
|
13262
|
+
const value = Math.round(parseFloat(raw) * (unit === "px" ? 1 : 16));
|
|
13263
|
+
breakpoints.add(`${value}px`);
|
|
13264
|
+
if (value <= 480) {
|
|
13265
|
+
hasMobile = true;
|
|
13266
|
+
} else if (value <= 768) {
|
|
13267
|
+
hasMobile = true;
|
|
13268
|
+
} else if (value <= 1024) {
|
|
13269
|
+
hasTablet = true;
|
|
13270
|
+
} else {
|
|
13271
|
+
hasDesktop = true;
|
|
13272
|
+
}
|
|
13273
|
+
}
|
|
13057
13274
|
}
|
|
13058
13275
|
return {
|
|
13059
13276
|
hasMobile,
|
|
@@ -13071,6 +13288,7 @@ async function analyzeResponsiveCss(html, url) {
|
|
|
13071
13288
|
const allMediaQueries = [];
|
|
13072
13289
|
const externalSources = [];
|
|
13073
13290
|
let inlineCount = 0;
|
|
13291
|
+
let hasResponsiveSignals = false;
|
|
13074
13292
|
$("style").each((_, el) => {
|
|
13075
13293
|
const css = $(el).text();
|
|
13076
13294
|
const queries = extractMediaQueries(css);
|
|
@@ -13078,39 +13296,43 @@ async function analyzeResponsiveCss(html, url) {
|
|
|
13078
13296
|
allMediaQueries.push(...queries);
|
|
13079
13297
|
inlineCount += queries.length;
|
|
13080
13298
|
}
|
|
13299
|
+
if (RESPONSIVE_SIGNAL.test(css)) hasResponsiveSignals = true;
|
|
13081
13300
|
});
|
|
13082
|
-
const
|
|
13083
|
-
|
|
13301
|
+
const sameOrigin = [];
|
|
13302
|
+
let crossOrigin = 0;
|
|
13303
|
+
$("link[rel][href]").each((_, el) => {
|
|
13304
|
+
if (!/\bstylesheet\b/i.test($(el).attr("rel") ?? "")) return;
|
|
13305
|
+
const href = $(el).attr("href");
|
|
13306
|
+
if (!href || href.startsWith("data:")) return;
|
|
13084
13307
|
try {
|
|
13085
|
-
const
|
|
13086
|
-
|
|
13308
|
+
const resolved = new URL(href, url);
|
|
13309
|
+
if (resolved.hostname === baseUrl.hostname) sameOrigin.push(resolved.href);
|
|
13310
|
+
else crossOrigin++;
|
|
13087
13311
|
} catch {
|
|
13088
|
-
|
|
13312
|
+
crossOrigin++;
|
|
13089
13313
|
}
|
|
13090
13314
|
});
|
|
13091
|
-
|
|
13092
|
-
|
|
13093
|
-
|
|
13094
|
-
|
|
13095
|
-
|
|
13096
|
-
|
|
13097
|
-
|
|
13098
|
-
|
|
13099
|
-
|
|
13315
|
+
let fetchFailed = 0;
|
|
13316
|
+
await Promise.all(
|
|
13317
|
+
sameOrigin.slice(0, MAX_STYLESHEETS).map(async (cssUrl) => {
|
|
13318
|
+
try {
|
|
13319
|
+
const response = await httpGet(cssUrl, {
|
|
13320
|
+
timeout: STYLESHEET_TIMEOUT_MS,
|
|
13321
|
+
maxBytes: MAX_STYLESHEET_BYTES
|
|
13322
|
+
});
|
|
13323
|
+
if (typeof response.data !== "string") throw new Error("not text");
|
|
13100
13324
|
const queries = extractMediaQueries(response.data);
|
|
13101
|
-
if (queries.length > 0)
|
|
13102
|
-
|
|
13325
|
+
if (queries.length > 0) allMediaQueries.push(...queries);
|
|
13326
|
+
if (RESPONSIVE_SIGNAL.test(response.data)) {
|
|
13327
|
+
hasResponsiveSignals = true;
|
|
13103
13328
|
externalSources.push(cssUrl);
|
|
13104
13329
|
}
|
|
13330
|
+
} catch {
|
|
13331
|
+
fetchFailed++;
|
|
13105
13332
|
}
|
|
13106
|
-
}
|
|
13107
|
-
|
|
13108
|
-
|
|
13109
|
-
await Promise.race([
|
|
13110
|
-
Promise.all(cssPromises),
|
|
13111
|
-
new Promise((resolve) => setTimeout(resolve, 6e3))
|
|
13112
|
-
// 6s overall timeout
|
|
13113
|
-
]);
|
|
13333
|
+
})
|
|
13334
|
+
);
|
|
13335
|
+
const stylesheetsCovered = crossOrigin === 0 && fetchFailed === 0 && sameOrigin.length <= MAX_STYLESHEETS;
|
|
13114
13336
|
const hasMediaQueries = allMediaQueries.length > 0;
|
|
13115
13337
|
const { hasMobile, hasTablet, hasDesktop, breakpoints } = classifyBreakpoints(allMediaQueries);
|
|
13116
13338
|
if (!hasViewport) {
|
|
@@ -13125,21 +13347,22 @@ async function analyzeResponsiveCss(html, url) {
|
|
|
13125
13347
|
affectedUrls: [url]
|
|
13126
13348
|
});
|
|
13127
13349
|
}
|
|
13128
|
-
if (!
|
|
13350
|
+
if (!hasResponsiveSignals && stylesheetsCovered) {
|
|
13129
13351
|
issues.push({
|
|
13130
13352
|
code: "RESPONSIVE_NO_MEDIA_QUERIES",
|
|
13131
13353
|
severity: "warning",
|
|
13132
13354
|
category: "mobile",
|
|
13133
13355
|
title: "No CSS media queries detected",
|
|
13134
|
-
description: "No @media queries found in inline styles or first-party stylesheets.",
|
|
13356
|
+
description: "No @media or @container queries and no fluid sizing (clamp, min, max) found in inline styles or first-party stylesheets.",
|
|
13135
13357
|
impact: "Without media queries, the site likely uses fixed layouts that do not adapt to different screen sizes. This hurts mobile usability and SEO.",
|
|
13136
13358
|
howToFix: "Add CSS media queries to adapt layout for different screen sizes. Consider using a mobile-first approach with min-width breakpoints.",
|
|
13137
13359
|
affectedUrls: [url],
|
|
13138
13360
|
details: {
|
|
13139
|
-
suggestion: "Common breakpoints: 480px (mobile), 768px (tablet), 1024px (desktop)"
|
|
13361
|
+
suggestion: "Common breakpoints: 480px (mobile), 768px (tablet), 1024px (desktop)",
|
|
13362
|
+
stylesheetsScanned: sameOrigin.length
|
|
13140
13363
|
}
|
|
13141
13364
|
});
|
|
13142
|
-
} else if (!hasMobile && !hasTablet) {
|
|
13365
|
+
} else if (hasMediaQueries && !hasMobile && !hasTablet) {
|
|
13143
13366
|
issues.push({
|
|
13144
13367
|
code: "RESPONSIVE_NO_MOBILE_BREAKPOINT",
|
|
13145
13368
|
severity: "warning",
|
|
@@ -13165,6 +13388,8 @@ async function analyzeResponsiveCss(html, url) {
|
|
|
13165
13388
|
inline: inlineCount,
|
|
13166
13389
|
external: externalSources
|
|
13167
13390
|
},
|
|
13391
|
+
hasResponsiveSignals,
|
|
13392
|
+
stylesheetsCovered,
|
|
13168
13393
|
breakpoints,
|
|
13169
13394
|
hasMobileBreakpoint: hasMobile,
|
|
13170
13395
|
hasTabletBreakpoint: hasTablet,
|
|
@@ -13802,7 +14027,22 @@ var urlSafetyDatabase = {
|
|
|
13802
14027
|
|
|
13803
14028
|
// src/audit/checks/tracking-verification.ts
|
|
13804
14029
|
import * as cheerio36 from "cheerio";
|
|
13805
|
-
|
|
14030
|
+
var OTHER_ANALYTICS = [
|
|
14031
|
+
{ name: "Cloudflare Web Analytics", pattern: /cloudflareinsights\.com\/beacon(?:\.min)?\.js/i },
|
|
14032
|
+
{ name: "Plausible", pattern: /plausible\.io\/js\/|<script[^>]*data-domain=[^>]*plausible|<script[^>]*plausible[^>]*data-domain=/i },
|
|
14033
|
+
{ name: "Fathom", pattern: /cdn\.usefathom\.com/i },
|
|
14034
|
+
// data-website-id is Umami's attribute, on the hosted script and on self-hosted ones alike.
|
|
14035
|
+
{ name: "Umami", pattern: /<script[^>]*\bdata-website-id=/i },
|
|
14036
|
+
{ name: "PostHog", pattern: /posthog\.init\s*\(|\.posthog\.com\//i },
|
|
14037
|
+
{ name: "Matomo", pattern: /matomo\.js|piwik\.js|_paq\s*(?:=|\.push)/i },
|
|
14038
|
+
{ name: "Simple Analytics", pattern: /scripts\.simpleanalyticscdn\.com/i },
|
|
14039
|
+
{ name: "Mixpanel", pattern: /cdn\.mxpnl\.com|mixpanel\.init\s*\(/i },
|
|
14040
|
+
{ name: "Segment", pattern: /cdn\.segment\.com/i }
|
|
14041
|
+
];
|
|
14042
|
+
function detectOtherAnalytics(html) {
|
|
14043
|
+
return OTHER_ANALYTICS.filter(({ pattern }) => pattern.test(html)).map(({ name }) => name);
|
|
14044
|
+
}
|
|
14045
|
+
async function analyzeTrackingVerification(html, url) {
|
|
13806
14046
|
const issues = [];
|
|
13807
14047
|
const $ = cheerio36.load(html);
|
|
13808
14048
|
const ga4Match = html.match(/gtag\s*\(\s*['"]config['"]\s*,\s*['"](G-[A-Z0-9]+)['"]/i) || html.match(/googletagmanager\.com\/gtag\/js\?id=(G-[A-Z0-9]+)/i);
|
|
@@ -13812,10 +14052,16 @@ function analyzeTrackingVerification(html, url) {
|
|
|
13812
14052
|
const hasGtm = !!gtmMatch;
|
|
13813
14053
|
const gtmContainerId = gtmMatch ? gtmMatch[1] : void 0;
|
|
13814
14054
|
const hasGtmNoscript = /googletagmanager\.com\/ns\.html\?id=GTM-[A-Z0-9]+/i.test(html);
|
|
14055
|
+
const otherAnalytics = detectOtherAnalytics(html);
|
|
14056
|
+
const verification = await lookupSiteVerification(url);
|
|
13815
14057
|
const gscMeta = $('meta[name="google-site-verification"]').attr("content");
|
|
13816
|
-
const
|
|
14058
|
+
const hasGscMeta = !!(gscMeta && gscMeta.length > 10);
|
|
14059
|
+
const hasGscVerification = hasGscMeta || verification.googleDns;
|
|
14060
|
+
const gscSource = hasGscMeta ? "meta" : verification.googleDns ? "dns" : void 0;
|
|
13817
14061
|
const bingMeta = $('meta[name="msvalidate.01"]').attr("content");
|
|
13818
|
-
const
|
|
14062
|
+
const hasBingMeta = !!(bingMeta && bingMeta.length > 10);
|
|
14063
|
+
const hasBingVerification = hasBingMeta || verification.bingDns || verification.bingSiteAuthFile;
|
|
14064
|
+
const bingSource = hasBingMeta ? "meta" : verification.bingDns ? "dns" : verification.bingSiteAuthFile ? "file" : void 0;
|
|
13819
14065
|
const jsonLdScripts = $('script[type="application/ld+json"]');
|
|
13820
14066
|
const hasMicrodata = $('[itemtype*="schema.org"]').length > 0;
|
|
13821
14067
|
const hasJsonLd = jsonLdScripts.length > 0;
|
|
@@ -13837,13 +14083,15 @@ function analyzeTrackingVerification(html, url) {
|
|
|
13837
14083
|
const data = {
|
|
13838
14084
|
ga4: { detected: hasGa4, measurementId: ga4MeasurementId },
|
|
13839
14085
|
gtm: { detected: hasGtm, containerId: gtmContainerId, hasNoscript: hasGtmNoscript },
|
|
13840
|
-
|
|
13841
|
-
|
|
14086
|
+
otherAnalytics,
|
|
14087
|
+
gsc: { verified: hasGscVerification, verificationCode: gscMeta, source: gscSource },
|
|
14088
|
+
bing: { verified: hasBingVerification, verificationCode: bingMeta, source: bingSource },
|
|
14089
|
+
dnsChecked: verification.dnsChecked,
|
|
13842
14090
|
schemaOrg: { detected: hasJsonLd || hasMicrodata, types: schemaTypes, hasValidJson },
|
|
13843
14091
|
sitemap: { inRobotsTxt: false }
|
|
13844
14092
|
// Set later if we check robots.txt
|
|
13845
14093
|
};
|
|
13846
|
-
if (!hasGa4 && !hasGtm) {
|
|
14094
|
+
if (!hasGa4 && !hasGtm && otherAnalytics.length === 0) {
|
|
13847
14095
|
issues.push({
|
|
13848
14096
|
code: "GA4_MISSING",
|
|
13849
14097
|
severity: "warning",
|
|
@@ -13856,40 +14104,46 @@ function analyzeTrackingVerification(html, url) {
|
|
|
13856
14104
|
});
|
|
13857
14105
|
}
|
|
13858
14106
|
if (!hasGscVerification) {
|
|
14107
|
+
const dnsChecked = verification.dnsChecked;
|
|
13859
14108
|
issues.push({
|
|
13860
14109
|
code: "GSC_VERIFICATION_MISSING",
|
|
13861
14110
|
severity: "warning",
|
|
13862
14111
|
category: "on-page",
|
|
13863
|
-
title: "Google Search Console verification not found",
|
|
13864
|
-
description: "No GSC verification meta tag
|
|
14112
|
+
title: dnsChecked ? "Google Search Console verification not found" : "Google Search Console verification not visible in the HTML",
|
|
14113
|
+
description: dnsChecked ? "No google-site-verification meta tag in the HTML and no google-site-verification TXT record on the domain. GSC provides essential data about your search performance." : "No google-site-verification meta tag in the HTML. The site may be verified through a DNS TXT record instead, which this run could not check. GSC provides essential data about your search performance.",
|
|
13865
14114
|
impact: "Missing critical insights into Google search visibility, impressions, and clicks.",
|
|
13866
|
-
howToFix: "
|
|
13867
|
-
affectedUrls: [url]
|
|
14115
|
+
howToFix: dnsChecked ? "Verify the site in Google Search Console: add the meta tag to <head>, or a TXT record to the domain's DNS. Get it from search.google.com/search-console." : "If the site is not yet verified in Google Search Console, add the meta tag to <head> or a TXT record to the domain's DNS. Get it from search.google.com/search-console.",
|
|
14116
|
+
affectedUrls: [url],
|
|
14117
|
+
details: { dnsChecked }
|
|
13868
14118
|
});
|
|
13869
14119
|
}
|
|
13870
14120
|
if (!hasBingVerification) {
|
|
14121
|
+
const dnsChecked = verification.dnsChecked;
|
|
13871
14122
|
issues.push({
|
|
13872
14123
|
code: "BING_VERIFICATION_MISSING",
|
|
13873
14124
|
severity: "notice",
|
|
13874
14125
|
category: "on-page",
|
|
13875
|
-
title: "Bing Webmaster Tools verification not found",
|
|
13876
|
-
description: "No Bing
|
|
14126
|
+
title: dnsChecked ? "Bing Webmaster Tools verification not found" : "Bing Webmaster Tools verification not visible in the HTML",
|
|
14127
|
+
description: dnsChecked ? "No msvalidate.01 meta tag in the HTML, no MS= TXT record on the domain and no /BingSiteAuth.xml file. Bing/Yahoo account for 5-8% of search traffic." : "No msvalidate.01 meta tag in the HTML and no /BingSiteAuth.xml file. The site may be verified through a DNS record instead, which this run could not check. Bing/Yahoo account for 5-8% of search traffic.",
|
|
13877
14128
|
impact: "Missing insights on Bing/Yahoo search traffic.",
|
|
13878
|
-
howToFix: "
|
|
13879
|
-
affectedUrls: [url]
|
|
14129
|
+
howToFix: "If the site is not yet in Bing Webmaster Tools, add it there: import it from Google Search Console, or verify with the meta tag, a DNS record or BingSiteAuth.xml. Get it from bing.com/webmasters.",
|
|
14130
|
+
affectedUrls: [url],
|
|
14131
|
+
details: { dnsChecked }
|
|
13880
14132
|
});
|
|
13881
14133
|
}
|
|
13882
14134
|
if (!hasGtm) {
|
|
13883
|
-
|
|
13884
|
-
|
|
13885
|
-
|
|
13886
|
-
|
|
13887
|
-
|
|
13888
|
-
|
|
13889
|
-
|
|
13890
|
-
|
|
13891
|
-
|
|
13892
|
-
|
|
14135
|
+
if (otherAnalytics.length === 0) {
|
|
14136
|
+
issues.push({
|
|
14137
|
+
code: "GTM_MISSING",
|
|
14138
|
+
severity: "notice",
|
|
14139
|
+
category: "on-page",
|
|
14140
|
+
title: "Google Tag Manager not detected",
|
|
14141
|
+
description: "GTM not found. While not required, GTM makes it easier to manage tracking tags without code changes.",
|
|
14142
|
+
impact: "Tag management requires developer involvement for each change.",
|
|
14143
|
+
howToFix: "Consider using GTM for easier tag management. It allows marketing to add tags without developer help.",
|
|
14144
|
+
affectedUrls: [url]
|
|
14145
|
+
});
|
|
14146
|
+
}
|
|
13893
14147
|
} else if (!hasGtmNoscript) {
|
|
13894
14148
|
issues.push({
|
|
13895
14149
|
code: "GTM_NOSCRIPT_MISSING",
|
|
@@ -14029,7 +14283,6 @@ async function runFullAudit(options) {
|
|
|
14029
14283
|
const urlSafetyResult = analyzeUrlSafety(url);
|
|
14030
14284
|
const domSizeResult = analyzeDomSize(html, url);
|
|
14031
14285
|
const imageDimensionsResult = analyzeImageDimensions(html, url);
|
|
14032
|
-
const trackingResult = analyzeTrackingVerification(html, url);
|
|
14033
14286
|
const anchorResult = runExtendedChecks ? analyzeAnchorText(html, url) : { issues: [], data: {} };
|
|
14034
14287
|
const localSeoResult = runExtendedChecks ? analyzeLocalSEO(html, url) : { issues: [], data: {} };
|
|
14035
14288
|
const modernImageResult = runExtendedChecks ? analyzeModernImages(html, url) : { issues: [], data: {} };
|
|
@@ -14104,8 +14357,7 @@ async function runFullAudit(options) {
|
|
|
14104
14357
|
...colorContrastResult.issues,
|
|
14105
14358
|
...pageResourcesResult.issues,
|
|
14106
14359
|
...urlSafetyResult.issues,
|
|
14107
|
-
...paginationResult.issues
|
|
14108
|
-
...trackingResult.issues
|
|
14360
|
+
...paginationResult.issues
|
|
14109
14361
|
);
|
|
14110
14362
|
if (!structuredDataResult.data.hasSchema) {
|
|
14111
14363
|
const suggestions = suggestSchemaTypes(html, url);
|
|
@@ -14151,6 +14403,8 @@ async function runFullAudit(options) {
|
|
|
14151
14403
|
asyncChecks.push(safeAsyncIssues("Certificate", async () => (await checkCertificate(url)).issues));
|
|
14152
14404
|
asyncChecks.push(safeAsync("Redirects", () => analyzeRedirects(url)));
|
|
14153
14405
|
asyncChecks.push(safeAsync("Robots Validation", () => validateRobotsTxt(url)));
|
|
14406
|
+
asyncChecks.push(safeAsync("Tracking Verification", () => analyzeTrackingVerification(html, url)));
|
|
14407
|
+
asyncChecks.push(safeAsyncIssues("Crawler Access", async () => (await analyzeCrawlerAccess(url)).issues));
|
|
14154
14408
|
asyncChecks.push(safeAsync("Canonical Domain", () => analyzeCanonicalDomain(url)));
|
|
14155
14409
|
asyncChecks.push(safeAsyncIssues("Framework Detection", async () => {
|
|
14156
14410
|
const frameworkInfo = detectFramework({ html, headers });
|
|
@@ -23028,7 +23282,7 @@ async function executeAgent(agent, options = {}) {
|
|
|
23028
23282
|
}
|
|
23029
23283
|
}
|
|
23030
23284
|
async function runDirectAnalysis(url) {
|
|
23031
|
-
const { analyzeUrl: analyzeUrl2 } = await import("./analyzer-
|
|
23285
|
+
const { analyzeUrl: analyzeUrl2 } = await import("./analyzer-HHRJODCF.mjs");
|
|
23032
23286
|
const result = await analyzeUrl2({ url });
|
|
23033
23287
|
if (!result.success || !result.data) {
|
|
23034
23288
|
throw new Error(result.error || "Analysis failed");
|
|
@@ -29523,9 +29777,37 @@ function jsonLdConcerns(html) {
|
|
|
29523
29777
|
}
|
|
29524
29778
|
return bad;
|
|
29525
29779
|
}
|
|
29780
|
+
var PLACEHOLDERS = [
|
|
29781
|
+
{ pattern: /\+?1[-\s.]?\(?800\)?[-\s.]?555[-\s.]?\d{4}/i, what: "a reserved 555 phone number" },
|
|
29782
|
+
{ pattern: /\b555[-\s.]?01\d{2}\b/, what: "a reserved 555 phone number" },
|
|
29783
|
+
{ pattern: /\bAnytown\b/i, what: '"Anytown"' },
|
|
29784
|
+
{ pattern: /\b123 Main (Street|St)\b/i, what: '"123 Main Street"' },
|
|
29785
|
+
{ pattern: /your_[a-z_]+/i, what: 'an unfilled "your_..." placeholder' },
|
|
29786
|
+
{ pattern: /\b(yourdomain|your-domain|example)\.(com|org|net)\b/i, what: "a placeholder domain" },
|
|
29787
|
+
{ pattern: /\blorem ipsum\b/i, what: "lorem ipsum" },
|
|
29788
|
+
{ pattern: /\bXXX-XXX-XXXX\b/i, what: "an unfilled phone template" }
|
|
29789
|
+
];
|
|
29790
|
+
function isSharedShell(html) {
|
|
29791
|
+
return /<div[^>]+id=["'](root|app|__next|__nuxt)["']/i.test(html);
|
|
29792
|
+
}
|
|
29526
29793
|
function assessReplacement(path3, original, replacement) {
|
|
29527
29794
|
const concerns = [];
|
|
29528
29795
|
const add = (code, detail) => concerns.push({ code, detail });
|
|
29796
|
+
for (const { pattern, what } of PLACEHOLDERS) {
|
|
29797
|
+
const hit = replacement.match(pattern);
|
|
29798
|
+
if (hit && !(original && pattern.test(original))) {
|
|
29799
|
+
add("placeholder_content", `contains ${what} (${hit[0]})`);
|
|
29800
|
+
}
|
|
29801
|
+
}
|
|
29802
|
+
if (/\.html?$/i.test(path3) && isSharedShell(replacement)) {
|
|
29803
|
+
const addsCanonical = /<link[^>]+rel=["']canonical["']/i.test(replacement) && !(original && /<link[^>]+rel=["']canonical["']/i.test(original));
|
|
29804
|
+
if (addsCanonical) {
|
|
29805
|
+
add(
|
|
29806
|
+
"canonical_in_shared_shell",
|
|
29807
|
+
`${path3} mounts a single-page app, so a canonical here applies to every route`
|
|
29808
|
+
);
|
|
29809
|
+
}
|
|
29810
|
+
}
|
|
29529
29811
|
if (/\.json$/i.test(path3) || /\.webmanifest$/i.test(path3)) {
|
|
29530
29812
|
try {
|
|
29531
29813
|
JSON.parse(replacement);
|