@forkpoint/agent-lighthouse-core 0.2.4 → 0.4.0
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 +111 -5
- package/dist/index.d.ts +111 -5
- package/dist/index.js +1138 -35
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1135 -35
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -45,7 +45,9 @@ __export(index_exports, {
|
|
|
45
45
|
MAX_PAGES_PER_SCAN: () => MAX_PAGES_PER_SCAN,
|
|
46
46
|
MAX_RESPONSE_BODY_BYTES: () => MAX_RESPONSE_BODY_BYTES,
|
|
47
47
|
PAGE_TYPE_LABELS: () => PAGE_TYPE_LABELS,
|
|
48
|
+
PHASE_WEIGHTS: () => PHASE_WEIGHTS,
|
|
48
49
|
PRESETS: () => PRESETS,
|
|
50
|
+
ProgressTracker: () => ProgressTracker,
|
|
49
51
|
READINESS_WEIGHTS: () => READINESS_WEIGHTS,
|
|
50
52
|
REQUEST_TIMEOUT_MS: () => REQUEST_TIMEOUT_MS,
|
|
51
53
|
SCANNER_USER_AGENT: () => SCANNER_USER_AGENT,
|
|
@@ -89,6 +91,7 @@ __export(index_exports, {
|
|
|
89
91
|
logger: () => logger,
|
|
90
92
|
normalizeUrl: () => normalizeUrl,
|
|
91
93
|
parseHtml: () => parseHtml,
|
|
94
|
+
planAudits: () => planAudits,
|
|
92
95
|
runAudits: () => runAudits,
|
|
93
96
|
runScan: () => runScan
|
|
94
97
|
});
|
|
@@ -4006,6 +4009,175 @@ var NoBotDetectionAudit = class extends Audit {
|
|
|
4006
4009
|
}
|
|
4007
4010
|
};
|
|
4008
4011
|
|
|
4012
|
+
// src/audits/crawler-permissions/tdm-rep.ts
|
|
4013
|
+
var TDMREP_PATH = "/.well-known/tdmrep.json";
|
|
4014
|
+
async function getTdmRepFile(ctx) {
|
|
4015
|
+
const cached = ctx.rootFiles[TDMREP_PATH];
|
|
4016
|
+
if (cached) return cached;
|
|
4017
|
+
try {
|
|
4018
|
+
return await ctx.fetch({ url: `${ctx.baseUrl}${TDMREP_PATH}` });
|
|
4019
|
+
} catch {
|
|
4020
|
+
return null;
|
|
4021
|
+
}
|
|
4022
|
+
}
|
|
4023
|
+
function describeReservation(value) {
|
|
4024
|
+
return value.trim() === "1" ? "rights reserved (mining denied by default)" : "mining explicitly permitted";
|
|
4025
|
+
}
|
|
4026
|
+
var TdmRepAudit = class extends Audit {
|
|
4027
|
+
static meta = {
|
|
4028
|
+
id: "2.27",
|
|
4029
|
+
category: "crawler-permissions",
|
|
4030
|
+
title: "TDM-Rep data mining rights declared",
|
|
4031
|
+
failureTitle: "TDM-Rep data mining rights declared",
|
|
4032
|
+
description: 'The TDM-Rep (Text and Data Mining Reservation) protocol is the emerging machine-readable standard for declaring whether your content may be used for text and data mining, anchored in EU DSM Directive Article 4. Without an explicit declaration \u2014 either a <meta name="tdm-reservation"> tag or a /.well-known/tdmrep.json policy file \u2014 AI crawlers and licensing agents cannot tell whether you reserve your mining rights, leaving your content in a legal gray zone where well-behaved agents guess and the rest assume permission. Declaring your terms explicitly puts you in control of how AI systems may use your content.',
|
|
4033
|
+
scoreDisplayMode: "ternary",
|
|
4034
|
+
weight: 0.7,
|
|
4035
|
+
defaultPriority: "medium",
|
|
4036
|
+
guidance: {
|
|
4037
|
+
impact: "Without an explicit TDM declaration, AI crawlers and content-licensing agents have no machine-readable signal about your data mining terms. Rights-respecting agents may skip your content to be safe, while others assume permission \u2014 you lose control either way, and you forfeit the EU DSM Article 4 opt-out protection that requires a machine-readable reservation.",
|
|
4038
|
+
fix: 'Declare your text and data mining policy explicitly. Add <meta name="tdm-reservation" content="1"> to reserve mining rights (or "0" to permit mining) on every page, and optionally point to a full policy with <meta name="tdm-policy" content="https://yoursite.com/tdm-policy">. For a site-wide declaration, publish a JSON policy at /.well-known/tdmrep.json instead.',
|
|
4039
|
+
code: '<!-- Reserve mining rights on every page -->\n<meta name="tdm-reservation" content="1" />\n<meta name="tdm-policy" content="https://yoursite.com/tdm-policy" />\n\n<!-- Or site-wide at /.well-known/tdmrep.json -->\n{\n "tdm-reservation": 1,\n "tdm-policy": "https://yoursite.com/tdm-policy"\n}',
|
|
4040
|
+
effort: "trivial",
|
|
4041
|
+
docsUrl: "https://www.w3.org/community/reports/tdmrep/CG-FINAL-tdmrep-20240510/",
|
|
4042
|
+
tags: ["tdm-rep", "licensing", "crawler-permissions", "compliance"]
|
|
4043
|
+
}
|
|
4044
|
+
};
|
|
4045
|
+
async audit(ctx) {
|
|
4046
|
+
const pageUrl = ctx.pages[0]?.url;
|
|
4047
|
+
for (const page of ctx.pages) {
|
|
4048
|
+
const reservation = page.meta["tdm-reservation"];
|
|
4049
|
+
if (reservation !== void 0) {
|
|
4050
|
+
const policy = page.meta["tdm-policy"];
|
|
4051
|
+
const policyNote = policy ? ` Policy URL: ${policy}.` : "";
|
|
4052
|
+
return this.pass(
|
|
4053
|
+
`Explicit TDM-Rep declaration found on ${page.url}: tdm-reservation="${reservation.trim()}" (${describeReservation(reservation)}).${policyNote}`,
|
|
4054
|
+
"Explicit TDM reservation via meta tag or /.well-known/tdmrep.json",
|
|
4055
|
+
`meta tdm-reservation="${reservation.trim()}"`,
|
|
4056
|
+
page.url
|
|
4057
|
+
);
|
|
4058
|
+
}
|
|
4059
|
+
}
|
|
4060
|
+
const file = await getTdmRepFile(ctx);
|
|
4061
|
+
if (file && file.status === 200) {
|
|
4062
|
+
try {
|
|
4063
|
+
const policy = JSON.parse(file.body);
|
|
4064
|
+
const reservation = policy["tdm-reservation"];
|
|
4065
|
+
const reservationNote = reservation !== void 0 ? ` tdm-reservation=${String(reservation)} (${describeReservation(String(reservation))}).` : "";
|
|
4066
|
+
return this.pass(
|
|
4067
|
+
`TDM-Rep policy file found at ${TDMREP_PATH}.${reservationNote}`,
|
|
4068
|
+
"Explicit TDM reservation via meta tag or /.well-known/tdmrep.json",
|
|
4069
|
+
`Valid JSON policy at ${TDMREP_PATH}`,
|
|
4070
|
+
pageUrl
|
|
4071
|
+
);
|
|
4072
|
+
} catch {
|
|
4073
|
+
return this.warn(
|
|
4074
|
+
`A file exists at ${TDMREP_PATH} but is not valid JSON, so agents cannot parse your data mining policy.`,
|
|
4075
|
+
"Valid JSON policy at /.well-known/tdmrep.json",
|
|
4076
|
+
"Malformed tdmrep.json",
|
|
4077
|
+
"medium",
|
|
4078
|
+
pageUrl
|
|
4079
|
+
);
|
|
4080
|
+
}
|
|
4081
|
+
}
|
|
4082
|
+
const detail = file && file.status !== 200 && file.status !== 0 ? ` (${TDMREP_PATH} returned HTTP ${file.status})` : "";
|
|
4083
|
+
return this.warn(
|
|
4084
|
+
`No TDM-Rep declaration found. No <meta name="tdm-reservation"> tag on any page and no policy file at ${TDMREP_PATH}${detail}. Your site has not declared its text and data mining terms, so AI crawlers cannot tell whether you reserve your mining rights.`,
|
|
4085
|
+
"Explicit TDM reservation via meta tag or /.well-known/tdmrep.json",
|
|
4086
|
+
"No TDM-Rep declaration found",
|
|
4087
|
+
"medium",
|
|
4088
|
+
pageUrl
|
|
4089
|
+
);
|
|
4090
|
+
}
|
|
4091
|
+
};
|
|
4092
|
+
|
|
4093
|
+
// src/audits/crawler-permissions/agent-governance.ts
|
|
4094
|
+
function explicitlyNamed(groups, bots) {
|
|
4095
|
+
const agents = new Set(groups.map((g) => g.userAgent.toLowerCase()));
|
|
4096
|
+
return bots.filter((bot) => {
|
|
4097
|
+
const names = [bot.botName, ...bot.aliases ?? []];
|
|
4098
|
+
return names.some((name) => agents.has(name.toLowerCase()));
|
|
4099
|
+
});
|
|
4100
|
+
}
|
|
4101
|
+
function categoryBlocked(groups, bots) {
|
|
4102
|
+
const names = new Set(
|
|
4103
|
+
bots.flatMap(
|
|
4104
|
+
(bot) => [bot.botName, ...bot.aliases ?? []].map((n) => n.toLowerCase())
|
|
4105
|
+
)
|
|
4106
|
+
);
|
|
4107
|
+
const rules = groups.filter((g) => names.has(g.userAgent.toLowerCase())).flatMap((g) => g.rules);
|
|
4108
|
+
return isBlanketBlocked(rules);
|
|
4109
|
+
}
|
|
4110
|
+
var AgentGovernanceAudit = class extends Audit {
|
|
4111
|
+
static meta = {
|
|
4112
|
+
id: "2.28",
|
|
4113
|
+
category: "crawler-permissions",
|
|
4114
|
+
title: "AI crawler vs conversational agent separation",
|
|
4115
|
+
failureTitle: "No separation between training crawlers and live agents",
|
|
4116
|
+
description: "Not all AI bots are the same. Training crawlers like GPTBot, CCBot, and Google-Extended scrape your content to build datasets, while conversational and retrieval agents like ChatGPT-User, Claude-User, and OAI-SearchBot fetch pages live to answer real user questions and can send referral traffic back to you. Many sites want to block the former while welcoming the latter \u2014 but a single catch-all User-agent: * cannot express that distinction. Granular robots.txt governance names both categories explicitly so each gets the access policy you actually intend.",
|
|
4117
|
+
scoreDisplayMode: "ternary",
|
|
4118
|
+
weight: 0.8,
|
|
4119
|
+
defaultPriority: "medium",
|
|
4120
|
+
guidance: {
|
|
4121
|
+
impact: "Without separate rules for training crawlers and live conversational agents, you cannot block dataset scraping while still appearing in ChatGPT, Claude, and Perplexity answers. A blanket policy either locks you out of AI-powered discovery entirely or leaves your content open to bulk training crawls you never agreed to.",
|
|
4122
|
+
fix: "Add explicit User-agent groups in robots.txt for both categories: name training crawlers (GPTBot, CCBot, Google-Extended, anthropic-ai) with the policy you want, and separately name live agents (ChatGPT-User, Claude-User, OAI-SearchBot) \u2014 typically with Allow: / so your site stays visible in AI answers.",
|
|
4123
|
+
code: "# Block dataset-training crawlers\nUser-agent: GPTBot\nDisallow: /\n\nUser-agent: CCBot\nDisallow: /\n\n# Welcome live conversational agents\nUser-agent: ChatGPT-User\nAllow: /\n\nUser-agent: Claude-User\nAllow: /\n\nUser-agent: *\nAllow: /",
|
|
4124
|
+
effort: "easy",
|
|
4125
|
+
docsUrl: "https://platform.openai.com/docs/bots",
|
|
4126
|
+
tags: ["robots-txt", "crawler-permissions", "ai-governance"]
|
|
4127
|
+
}
|
|
4128
|
+
};
|
|
4129
|
+
audit(ctx) {
|
|
4130
|
+
const robotsFile = ctx.rootFiles["/robots.txt"];
|
|
4131
|
+
if (!robotsFile || robotsFile.status !== 200 || !robotsFile.body) {
|
|
4132
|
+
return this.notApplicable(
|
|
4133
|
+
"No robots.txt found \u2014 agentic governance cannot be evaluated.",
|
|
4134
|
+
"robots.txt with explicit rules for both training crawlers and live conversational agents",
|
|
4135
|
+
"No robots.txt found"
|
|
4136
|
+
);
|
|
4137
|
+
}
|
|
4138
|
+
const groups = parseRobotsTxt(robotsFile.body);
|
|
4139
|
+
const trainingNamed = explicitlyNamed(groups, TRAINING_CRAWLERS);
|
|
4140
|
+
const realtimeNamed = explicitlyNamed(groups, REALTIME_CRAWLERS);
|
|
4141
|
+
const hasCatchAll = groups.some((g) => g.userAgent === "*");
|
|
4142
|
+
const details = {
|
|
4143
|
+
trainingAgents: trainingNamed.map((b) => b.displayName),
|
|
4144
|
+
realtimeAgents: realtimeNamed.map((b) => b.displayName),
|
|
4145
|
+
hasCatchAll
|
|
4146
|
+
};
|
|
4147
|
+
const expected = "Explicit User-agent groups for both training crawlers (GPTBot, CCBot, ...) and live conversational agents (ChatGPT-User, Claude-User, ...)";
|
|
4148
|
+
if (trainingNamed.length === 0 && realtimeNamed.length === 0) {
|
|
4149
|
+
const result2 = this.fail(
|
|
4150
|
+
hasCatchAll ? "robots.txt only defines a catch-all User-agent: * \u2014 no AI-agent-specific rules found." : "robots.txt contains no AI-agent-specific rules.",
|
|
4151
|
+
expected,
|
|
4152
|
+
hasCatchAll ? "Only User-agent: * present" : "No AI crawler user-agents named",
|
|
4153
|
+
{ priority: "medium" }
|
|
4154
|
+
);
|
|
4155
|
+
result2.details = details;
|
|
4156
|
+
return result2;
|
|
4157
|
+
}
|
|
4158
|
+
const differentiated = trainingNamed.length > 0 && realtimeNamed.length > 0 && categoryBlocked(groups, trainingNamed) !== categoryBlocked(groups, realtimeNamed);
|
|
4159
|
+
if (trainingNamed.length >= 2 && realtimeNamed.length >= 2 || differentiated) {
|
|
4160
|
+
const result2 = this.pass(
|
|
4161
|
+
`Granular agentic governance: ${trainingNamed.length} training crawler(s) and ${realtimeNamed.length} live agent(s) explicitly named${differentiated ? " with different policies" : ""}.`,
|
|
4162
|
+
expected,
|
|
4163
|
+
`Training: ${trainingNamed.map((b) => b.displayName).join(", ") || "none"}; Realtime: ${realtimeNamed.map((b) => b.displayName).join(", ") || "none"}`
|
|
4164
|
+
);
|
|
4165
|
+
result2.details = details;
|
|
4166
|
+
return result2;
|
|
4167
|
+
}
|
|
4168
|
+
const covered = trainingNamed.length > 0 ? "training crawlers" : "live conversational agents";
|
|
4169
|
+
const missing = trainingNamed.length > 0 ? "live conversational agents" : "training crawlers";
|
|
4170
|
+
const result = this.warn(
|
|
4171
|
+
`Only ${covered} are explicitly governed in robots.txt \u2014 no rules for ${missing}.`,
|
|
4172
|
+
expected,
|
|
4173
|
+
`Training: ${trainingNamed.map((b) => b.displayName).join(", ") || "none"}; Realtime: ${realtimeNamed.map((b) => b.displayName).join(", ") || "none"}`,
|
|
4174
|
+
{ priority: "medium" }
|
|
4175
|
+
);
|
|
4176
|
+
result.details = details;
|
|
4177
|
+
return result;
|
|
4178
|
+
}
|
|
4179
|
+
};
|
|
4180
|
+
|
|
4009
4181
|
// src/audits/structured-data/json-ld-present.ts
|
|
4010
4182
|
var JsonLdPresentAudit = class extends Audit {
|
|
4011
4183
|
static meta = {
|
|
@@ -5945,6 +6117,138 @@ var ProductReviewsAudit = class extends Audit {
|
|
|
5945
6117
|
}
|
|
5946
6118
|
};
|
|
5947
6119
|
|
|
6120
|
+
// src/audits/structured-data/product-transaction-certainty.ts
|
|
6121
|
+
function matchesAnyType11(schema, types) {
|
|
6122
|
+
return types.some((t) => {
|
|
6123
|
+
const st = schema["@type"];
|
|
6124
|
+
if (typeof st === "string") return st === t;
|
|
6125
|
+
if (Array.isArray(st)) return st.includes(t);
|
|
6126
|
+
return false;
|
|
6127
|
+
});
|
|
6128
|
+
}
|
|
6129
|
+
function asOfferList(offers) {
|
|
6130
|
+
if (!offers) return [];
|
|
6131
|
+
const list = Array.isArray(offers) ? offers : [offers];
|
|
6132
|
+
return list.filter((o) => !!o && typeof o === "object");
|
|
6133
|
+
}
|
|
6134
|
+
var SIGNAL_LABELS = {
|
|
6135
|
+
availability: "offers.availability",
|
|
6136
|
+
priceValidUntil: "offers.priceValidUntil",
|
|
6137
|
+
pricePair: "offers.price + offers.priceCurrency",
|
|
6138
|
+
returnPolicy: "hasMerchantReturnPolicy"
|
|
6139
|
+
};
|
|
6140
|
+
var ProductTransactionCertaintyAudit = class extends Audit {
|
|
6141
|
+
static meta = {
|
|
6142
|
+
id: "3.24",
|
|
6143
|
+
category: "structured-data",
|
|
6144
|
+
title: "Product transactional certainty",
|
|
6145
|
+
failureTitle: "Product transactional certainty",
|
|
6146
|
+
description: "AI shopping assistants need more than a product name and price to make an authoritative recommendation: they must know whether the item is in stock, how long the quoted price is valid, and what the return policy is before they commit a user to a purchase. A Product schema that only carries name and price forces agents to guess at availability, quote potentially stale prices, and stay silent on returns \u2014 all of which erode transactional certainty in agentic commerce flows. Complete your Offer with availability, priceValidUntil, and a valid price + priceCurrency pair, and attach hasMerchantReturnPolicy to the Product or Offer.",
|
|
6147
|
+
scoreDisplayMode: "ternary",
|
|
6148
|
+
weight: 1,
|
|
6149
|
+
applicablePageTypes: ["product"],
|
|
6150
|
+
defaultPriority: "high",
|
|
6151
|
+
guidance: {
|
|
6152
|
+
impact: "Without availability, priceValidUntil, a valid price/currency pair, and a return policy in your Product schema, AI shopping assistants cannot make an authoritative recommendation. Agents either skip your product or answer with guessed availability, stale prices, and unknown return terms \u2014 costing you conversions in agent-driven purchases.",
|
|
6153
|
+
fix: "Complete the Offer block in your Product JSON-LD with availability (a schema.org ItemAvailability value), priceValidUntil (ISO date), and a valid price + priceCurrency pair. Add hasMerchantReturnPolicy to the Product or Offer. Also ensure unique identifiers (GTIN/MPN/SKU) are present \u2014 see the Product identifiers audit \u2014 so agents can match the exact item.",
|
|
6154
|
+
code: `{
|
|
6155
|
+
"@context": "https://schema.org",
|
|
6156
|
+
"@type": "Product",
|
|
6157
|
+
"name": "Product Name",
|
|
6158
|
+
"offers": {
|
|
6159
|
+
"@type": "Offer",
|
|
6160
|
+
"price": "99.00",
|
|
6161
|
+
"priceCurrency": "USD",
|
|
6162
|
+
"availability": "https://schema.org/InStock",
|
|
6163
|
+
"priceValidUntil": "2026-12-31",
|
|
6164
|
+
"hasMerchantReturnPolicy": {
|
|
6165
|
+
"@type": "MerchantReturnPolicy",
|
|
6166
|
+
"applicableCountry": "US",
|
|
6167
|
+
"returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
|
|
6168
|
+
"merchantReturnDays": 30
|
|
6169
|
+
}
|
|
6170
|
+
}
|
|
6171
|
+
}`,
|
|
6172
|
+
effort: "moderate",
|
|
6173
|
+
docsUrl: "https://schema.org/Offer",
|
|
6174
|
+
tags: ["json-ld", "schema", "product", "ecommerce", "agentic-commerce"]
|
|
6175
|
+
}
|
|
6176
|
+
};
|
|
6177
|
+
audit(ctx) {
|
|
6178
|
+
const schemas = ctx.pages.flatMap((p) => flattenJsonLd(p.structuredData ?? p.jsonLd));
|
|
6179
|
+
const products = schemas.filter(
|
|
6180
|
+
(s) => matchesAnyType11(s, [
|
|
6181
|
+
"Product",
|
|
6182
|
+
"IndividualProduct",
|
|
6183
|
+
"ProductModel"
|
|
6184
|
+
])
|
|
6185
|
+
);
|
|
6186
|
+
if (products.length === 0) {
|
|
6187
|
+
return this.notApplicable(
|
|
6188
|
+
"No Product schema found on any page to evaluate transactional certainty.",
|
|
6189
|
+
"Product schema with offers containing availability, priceValidUntil, price + priceCurrency, and hasMerchantReturnPolicy.",
|
|
6190
|
+
"None"
|
|
6191
|
+
);
|
|
6192
|
+
}
|
|
6193
|
+
let best = null;
|
|
6194
|
+
for (const product of products) {
|
|
6195
|
+
const obj = product;
|
|
6196
|
+
const offers = asOfferList(obj["offers"]);
|
|
6197
|
+
if (offers.length === 0) continue;
|
|
6198
|
+
const signals = {
|
|
6199
|
+
availability: offers.some((o) => !!o["availability"]),
|
|
6200
|
+
priceValidUntil: offers.some((o) => !!o["priceValidUntil"]),
|
|
6201
|
+
pricePair: offers.some(
|
|
6202
|
+
(o) => o["price"] !== void 0 && o["price"] !== null && o["price"] !== "" && !!o["priceCurrency"]
|
|
6203
|
+
),
|
|
6204
|
+
returnPolicy: !!obj["hasMerchantReturnPolicy"] || offers.some((o) => !!o["hasMerchantReturnPolicy"])
|
|
6205
|
+
};
|
|
6206
|
+
const count = Object.values(signals).filter(Boolean).length;
|
|
6207
|
+
if (!best || count > best.count) best = { signals, count };
|
|
6208
|
+
}
|
|
6209
|
+
const expected = "Product schema with offers containing availability, priceValidUntil, price + priceCurrency, and hasMerchantReturnPolicy.";
|
|
6210
|
+
if (!best) {
|
|
6211
|
+
return this.fail(
|
|
6212
|
+
"Product schema found but no Offer block \u2014 no transactional data for agents to act on.",
|
|
6213
|
+
expected,
|
|
6214
|
+
"0/4 certainty signals (no offers)",
|
|
6215
|
+
{
|
|
6216
|
+
priority: "high",
|
|
6217
|
+
description: "AI shopping assistants cannot quote a price, check stock, or state return terms without an Offer block. Add offers with price, priceCurrency, availability, priceValidUntil, and hasMerchantReturnPolicy."
|
|
6218
|
+
}
|
|
6219
|
+
);
|
|
6220
|
+
}
|
|
6221
|
+
const missing = Object.keys(best.signals).filter((k) => !best.signals[k]).map((k) => SIGNAL_LABELS[k]);
|
|
6222
|
+
if (best.count === 4) {
|
|
6223
|
+
return this.pass(
|
|
6224
|
+
"All transactional certainty signals present: availability, priceValidUntil, price + priceCurrency, and return policy.",
|
|
6225
|
+
expected,
|
|
6226
|
+
"4/4 certainty signals"
|
|
6227
|
+
);
|
|
6228
|
+
}
|
|
6229
|
+
if (best.count >= 2) {
|
|
6230
|
+
return this.warn(
|
|
6231
|
+
`Missing purchasing data points: ${missing.join(", ")}.`,
|
|
6232
|
+
expected,
|
|
6233
|
+
`${best.count}/4 certainty signals (missing: ${missing.join(", ")})`,
|
|
6234
|
+
{
|
|
6235
|
+
priority: "high",
|
|
6236
|
+
description: `AI shopping assistants are missing ${missing.join(", ")} and cannot give an authoritative recommendation for this product. Complete the Offer block so agents can quote stock, price validity, and return terms with certainty.`
|
|
6237
|
+
}
|
|
6238
|
+
);
|
|
6239
|
+
}
|
|
6240
|
+
return this.fail(
|
|
6241
|
+
`Product relies on name and price alone \u2014 missing: ${missing.join(", ")}.`,
|
|
6242
|
+
expected,
|
|
6243
|
+
`${best.count}/4 certainty signals (missing: ${missing.join(", ")})`,
|
|
6244
|
+
{
|
|
6245
|
+
priority: "high",
|
|
6246
|
+
description: `AI shopping assistants are missing ${missing.join(", ")} and cannot give an authoritative recommendation for this product. Complete the Offer block so agents can quote stock, price validity, and return terms with certainty.`
|
|
6247
|
+
}
|
|
6248
|
+
);
|
|
6249
|
+
}
|
|
6250
|
+
};
|
|
6251
|
+
|
|
5948
6252
|
// src/audits/meta-tags/meta-description.ts
|
|
5949
6253
|
var MetaDescriptionAudit = class extends Audit {
|
|
5950
6254
|
static meta = {
|
|
@@ -10494,6 +10798,368 @@ var WebmcpActionCoverageAudit = class extends Audit {
|
|
|
10494
10798
|
}
|
|
10495
10799
|
};
|
|
10496
10800
|
|
|
10801
|
+
// src/audits/agent-tools/openapi-description-quality.ts
|
|
10802
|
+
function tryParseJson21(body) {
|
|
10803
|
+
try {
|
|
10804
|
+
return JSON.parse(body);
|
|
10805
|
+
} catch {
|
|
10806
|
+
return void 0;
|
|
10807
|
+
}
|
|
10808
|
+
}
|
|
10809
|
+
function isObject21(val) {
|
|
10810
|
+
return typeof val === "object" && val !== null && !Array.isArray(val);
|
|
10811
|
+
}
|
|
10812
|
+
var HTTP_METHODS6 = ["get", "post", "put", "patch", "delete", "options", "head", "trace"];
|
|
10813
|
+
var MIN_DESCRIPTION_LENGTH2 = 15;
|
|
10814
|
+
function getOpenApiSpec8(ctx) {
|
|
10815
|
+
const jsonResult = ctx.rootFiles["/openapi.json"];
|
|
10816
|
+
if (jsonResult && jsonResult.status === 200 && jsonResult.body) {
|
|
10817
|
+
const parsed = tryParseJson21(jsonResult.body);
|
|
10818
|
+
if (isObject21(parsed)) return parsed;
|
|
10819
|
+
}
|
|
10820
|
+
return void 0;
|
|
10821
|
+
}
|
|
10822
|
+
function hasGoodDescription(val) {
|
|
10823
|
+
return typeof val === "string" && val.trim().length > MIN_DESCRIPTION_LENGTH2;
|
|
10824
|
+
}
|
|
10825
|
+
function getCheckableItems(spec) {
|
|
10826
|
+
const paths = spec["paths"];
|
|
10827
|
+
if (!isObject21(paths)) return [];
|
|
10828
|
+
const items = [];
|
|
10829
|
+
for (const [path, pathItem] of Object.entries(paths)) {
|
|
10830
|
+
if (!isObject21(pathItem)) continue;
|
|
10831
|
+
for (const method of HTTP_METHODS6) {
|
|
10832
|
+
const op = pathItem[method];
|
|
10833
|
+
if (!isObject21(op)) continue;
|
|
10834
|
+
const operation = op;
|
|
10835
|
+
const opLabel = `${method.toUpperCase()} ${path}`;
|
|
10836
|
+
items.push({
|
|
10837
|
+
label: `${opLabel} (operation)`,
|
|
10838
|
+
described: hasGoodDescription(operation["description"])
|
|
10839
|
+
});
|
|
10840
|
+
const parameters = operation["parameters"];
|
|
10841
|
+
if (Array.isArray(parameters)) {
|
|
10842
|
+
for (const param of parameters) {
|
|
10843
|
+
if (!isObject21(param)) continue;
|
|
10844
|
+
const name = typeof param["name"] === "string" ? param["name"] : "(unnamed)";
|
|
10845
|
+
items.push({
|
|
10846
|
+
label: `${opLabel} param '${name}'`,
|
|
10847
|
+
described: hasGoodDescription(param["description"])
|
|
10848
|
+
});
|
|
10849
|
+
}
|
|
10850
|
+
}
|
|
10851
|
+
}
|
|
10852
|
+
}
|
|
10853
|
+
return items;
|
|
10854
|
+
}
|
|
10855
|
+
var OpenApiDescriptionQualityAudit = class _OpenApiDescriptionQualityAudit extends Audit {
|
|
10856
|
+
static meta = {
|
|
10857
|
+
id: "5.26",
|
|
10858
|
+
category: "agent-tools",
|
|
10859
|
+
title: "OpenAPI description quality for tool-calling",
|
|
10860
|
+
failureTitle: "OpenAPI descriptions too thin for tool-calling",
|
|
10861
|
+
description: 'When an AI agent converts your OpenAPI spec into callable tools, the description fields become the prompt the LLM uses to decide when and how to call each function. A one-word description like "search" tells the model nothing about what the endpoint does, what the parameter means, or what values are valid \u2014 so the agent guesses, calls the wrong tool, or fills parameters with hallucinated values. Every operation and every parameter needs a verbose description (more than 15 characters) that explains purpose, expected input, and behavior.',
|
|
10862
|
+
scoreDisplayMode: "ternary",
|
|
10863
|
+
weight: 0.9,
|
|
10864
|
+
defaultPriority: "high",
|
|
10865
|
+
guidance: {
|
|
10866
|
+
impact: "LLM tool-calling treats your OpenAPI descriptions as the function-calling prompt. Missing or terse descriptions force the model to guess what each endpoint does and what each parameter accepts, producing wrong tool selection, malformed arguments, and failed API calls that erode user trust in agent-driven workflows on your site.",
|
|
10867
|
+
fix: "Write a verbose description (>15 characters) for every operation and every parameter in your OpenAPI spec. Explain what the operation does, when an agent should use it, and what each parameter means including format and example values.",
|
|
10868
|
+
code: `"/search": {
|
|
10869
|
+
"get": {
|
|
10870
|
+
"operationId": "searchProducts",
|
|
10871
|
+
"description": "Search the product catalog by keyword. Returns matching products with name, price, and availability. Use this when the user asks to find or browse products.",
|
|
10872
|
+
"parameters": [{
|
|
10873
|
+
"name": "q",
|
|
10874
|
+
"in": "query",
|
|
10875
|
+
"description": "Full-text search query, e.g. 'red running shoes'. Matched against product name and description.",
|
|
10876
|
+
"schema": { "type": "string" }
|
|
10877
|
+
}],
|
|
10878
|
+
"responses": { "200": { "description": "List of matching products" } }
|
|
10879
|
+
}
|
|
10880
|
+
}`,
|
|
10881
|
+
effort: "moderate",
|
|
10882
|
+
docsUrl: "https://swagger.io/specification/#operation-object",
|
|
10883
|
+
tags: ["openapi", "descriptions", "tool-calling", "llm", "api"]
|
|
10884
|
+
}
|
|
10885
|
+
};
|
|
10886
|
+
audit(ctx) {
|
|
10887
|
+
const spec = getOpenApiSpec8(ctx);
|
|
10888
|
+
if (!spec) {
|
|
10889
|
+
return this.notApplicable(
|
|
10890
|
+
"No parseable OpenAPI JSON spec found at /openapi.json.",
|
|
10891
|
+
"OpenAPI spec with verbose descriptions on all operations and parameters",
|
|
10892
|
+
"No spec"
|
|
10893
|
+
);
|
|
10894
|
+
}
|
|
10895
|
+
const items = getCheckableItems(spec);
|
|
10896
|
+
if (items.length === 0) {
|
|
10897
|
+
return this.notApplicable(
|
|
10898
|
+
"OpenAPI spec has no operations or parameters to check.",
|
|
10899
|
+
"OpenAPI spec with verbose descriptions on all operations and parameters",
|
|
10900
|
+
"0 checkable items"
|
|
10901
|
+
);
|
|
10902
|
+
}
|
|
10903
|
+
const described = items.filter((i) => i.described).length;
|
|
10904
|
+
const ratio = described / items.length;
|
|
10905
|
+
const missing = items.filter((i) => !i.described).map((i) => i.label);
|
|
10906
|
+
const truncatedMissing = missing.slice(0, 10);
|
|
10907
|
+
const missingSummary = truncatedMissing.join("; ") + (missing.length > 10 ? `; ... and ${missing.length - 10} more` : "");
|
|
10908
|
+
const expected = "Every operation and parameter has a description longer than 15 characters";
|
|
10909
|
+
const found = `${described}/${items.length} described (${Math.round(ratio * 100)}%)`;
|
|
10910
|
+
const details = missing.length > 0 ? { missingDescriptions: truncatedMissing } : void 0;
|
|
10911
|
+
if (ratio >= 0.9) {
|
|
10912
|
+
return {
|
|
10913
|
+
...this.pass(
|
|
10914
|
+
`${described}/${items.length} operation/parameter description(s) are verbose enough for LLM tool-calling.${missing.length > 0 ? ` Still thin: ${missingSummary}` : ""}`,
|
|
10915
|
+
expected,
|
|
10916
|
+
found
|
|
10917
|
+
),
|
|
10918
|
+
details
|
|
10919
|
+
};
|
|
10920
|
+
}
|
|
10921
|
+
const recommendation = {
|
|
10922
|
+
priority: "high",
|
|
10923
|
+
description: _OpenApiDescriptionQualityAudit.meta.description,
|
|
10924
|
+
code: _OpenApiDescriptionQualityAudit.meta.guidance?.code
|
|
10925
|
+
};
|
|
10926
|
+
if (ratio >= 0.5) {
|
|
10927
|
+
return {
|
|
10928
|
+
...this.warn(
|
|
10929
|
+
`Only ${described}/${items.length} operation/parameter description(s) are verbose enough: ${missingSummary}`,
|
|
10930
|
+
expected,
|
|
10931
|
+
found,
|
|
10932
|
+
recommendation
|
|
10933
|
+
),
|
|
10934
|
+
details
|
|
10935
|
+
};
|
|
10936
|
+
}
|
|
10937
|
+
return {
|
|
10938
|
+
...this.fail(
|
|
10939
|
+
`Only ${described}/${items.length} operation/parameter description(s) are verbose enough: ${missingSummary}`,
|
|
10940
|
+
expected,
|
|
10941
|
+
found,
|
|
10942
|
+
recommendation
|
|
10943
|
+
),
|
|
10944
|
+
details
|
|
10945
|
+
};
|
|
10946
|
+
}
|
|
10947
|
+
};
|
|
10948
|
+
|
|
10949
|
+
// src/audits/agent-tools/form-actionability.ts
|
|
10950
|
+
var SKIP_TYPES = ["hidden", "submit", "button", "reset", "image"];
|
|
10951
|
+
var IDENTITY_FIELDS = [
|
|
10952
|
+
{ pattern: /email/i, token: "email" },
|
|
10953
|
+
{ pattern: /(^|[^a-z])(tel|phone|mobile)([^a-z]|$)/i, token: "tel" },
|
|
10954
|
+
{ pattern: /first.?name|given.?name|fname/i, token: "given-name" },
|
|
10955
|
+
{ pattern: /last.?name|family.?name|surname|lname/i, token: "family-name" },
|
|
10956
|
+
{ pattern: /(^|[^a-z])(full.?)?name([^a-z]|$)/i, token: "name" },
|
|
10957
|
+
{ pattern: /street|address/i, token: "street-address" },
|
|
10958
|
+
{ pattern: /city|town/i, token: "address-level2" },
|
|
10959
|
+
{ pattern: /zip|postal|postcode/i, token: "postal-code" },
|
|
10960
|
+
{ pattern: /country/i, token: "country-name" }
|
|
10961
|
+
];
|
|
10962
|
+
var STANDARD_AUTOCOMPLETE_TOKENS = /* @__PURE__ */ new Set([
|
|
10963
|
+
"name",
|
|
10964
|
+
"honorific-prefix",
|
|
10965
|
+
"given-name",
|
|
10966
|
+
"additional-name",
|
|
10967
|
+
"family-name",
|
|
10968
|
+
"honorific-suffix",
|
|
10969
|
+
"nickname",
|
|
10970
|
+
"username",
|
|
10971
|
+
"new-password",
|
|
10972
|
+
"current-password",
|
|
10973
|
+
"one-time-code",
|
|
10974
|
+
"organization-title",
|
|
10975
|
+
"organization",
|
|
10976
|
+
"street-address",
|
|
10977
|
+
"address-line1",
|
|
10978
|
+
"address-line2",
|
|
10979
|
+
"address-line3",
|
|
10980
|
+
"address-level4",
|
|
10981
|
+
"address-level3",
|
|
10982
|
+
"address-level2",
|
|
10983
|
+
"address-level1",
|
|
10984
|
+
"country",
|
|
10985
|
+
"country-name",
|
|
10986
|
+
"postal-code",
|
|
10987
|
+
"cc-name",
|
|
10988
|
+
"cc-given-name",
|
|
10989
|
+
"cc-additional-name",
|
|
10990
|
+
"cc-family-name",
|
|
10991
|
+
"cc-number",
|
|
10992
|
+
"cc-exp",
|
|
10993
|
+
"cc-exp-month",
|
|
10994
|
+
"cc-exp-year",
|
|
10995
|
+
"cc-csc",
|
|
10996
|
+
"cc-type",
|
|
10997
|
+
"transaction-currency",
|
|
10998
|
+
"transaction-amount",
|
|
10999
|
+
"language",
|
|
11000
|
+
"bday",
|
|
11001
|
+
"bday-day",
|
|
11002
|
+
"bday-month",
|
|
11003
|
+
"bday-year",
|
|
11004
|
+
"sex",
|
|
11005
|
+
"url",
|
|
11006
|
+
"photo",
|
|
11007
|
+
"tel",
|
|
11008
|
+
"tel-country-code",
|
|
11009
|
+
"tel-national",
|
|
11010
|
+
"tel-area-code",
|
|
11011
|
+
"tel-local",
|
|
11012
|
+
"tel-local-prefix",
|
|
11013
|
+
"tel-local-suffix",
|
|
11014
|
+
"tel-extension",
|
|
11015
|
+
"email",
|
|
11016
|
+
"impp"
|
|
11017
|
+
]);
|
|
11018
|
+
var AUTOCOMPLETE_QUALIFIERS = /* @__PURE__ */ new Set(["shipping", "billing", "home", "work", "mobile", "fax", "pager"]);
|
|
11019
|
+
function expectedAutocompleteToken(tag, type, name, id, labelText2) {
|
|
11020
|
+
if (tag === "input" && type === "email") return "email";
|
|
11021
|
+
if (tag === "input" && type === "tel") return "tel";
|
|
11022
|
+
const signal = `${name} ${id} ${labelText2}`;
|
|
11023
|
+
for (const { pattern, token } of IDENTITY_FIELDS) {
|
|
11024
|
+
if (pattern.test(signal)) return token;
|
|
11025
|
+
}
|
|
11026
|
+
return void 0;
|
|
11027
|
+
}
|
|
11028
|
+
function isStandardAutocomplete(value) {
|
|
11029
|
+
const tokens = value.toLowerCase().split(/\s+/).filter((t) => t.length > 0 && !t.startsWith("section-") && !AUTOCOMPLETE_QUALIFIERS.has(t));
|
|
11030
|
+
return tokens.length > 0 && STANDARD_AUTOCOMPLETE_TOKENS.has(tokens[tokens.length - 1]);
|
|
11031
|
+
}
|
|
11032
|
+
function labelTextFor($, formEl, id) {
|
|
11033
|
+
if (!id) return "";
|
|
11034
|
+
const escapedId = id.replace(/(["\\\]:])/g, "\\$1");
|
|
11035
|
+
const label2 = $(formEl).find(`label[for="${escapedId}"]`).first();
|
|
11036
|
+
return label2.text().trim();
|
|
11037
|
+
}
|
|
11038
|
+
var FormActionabilityAudit = class extends Audit {
|
|
11039
|
+
static meta = {
|
|
11040
|
+
id: "5.27",
|
|
11041
|
+
category: "agent-tools",
|
|
11042
|
+
title: "Form backend actionability",
|
|
11043
|
+
failureTitle: "Form backend actionability",
|
|
11044
|
+
description: "Autonomous agents fill forms by reading the DOM directly \u2014 they cannot see placeholders rendered visually or guess what a custom div-based widget expects. Fields without a native element, an explicit label (label[for], wrapping label, aria-label, or aria-labelledby), or a standard autocomplete attribute for identity data (email, phone, name, address) force agents to guess, producing failed or incorrect submissions. Keep every fillable field a native input/select/textarea with an explicit label and standard autocomplete tokens.",
|
|
11045
|
+
scoreDisplayMode: "ternary",
|
|
11046
|
+
weight: 1,
|
|
11047
|
+
defaultPriority: "high",
|
|
11048
|
+
guidance: {
|
|
11049
|
+
impact: "AI agents do not render your page visually. Unlabeled fields, div-based fake inputs, and missing autocomplete attributes mean agents cannot tell which field is the email address or the name, so submissions fail silently or land in the wrong fields \u2014 lost leads, broken signups, and abandoned checkouts.",
|
|
11050
|
+
fix: 'Use native input/select/textarea elements (never divs with role="textbox" or contenteditable), associate every field with a <label for="id">, wrapping <label>, aria-label, or aria-labelledby, and add standard autocomplete tokens (email, tel, name, street-address, postal-code, country-name) to identity fields.',
|
|
11051
|
+
code: `<form action="/api/contact" method="POST">
|
|
11052
|
+
<label for="full-name">Full name</label>
|
|
11053
|
+
<input id="full-name" name="name" type="text" autocomplete="name" required />
|
|
11054
|
+
|
|
11055
|
+
<label for="email">Email</label>
|
|
11056
|
+
<input id="email" name="email" type="email" autocomplete="email" required />
|
|
11057
|
+
|
|
11058
|
+
<label for="phone">Phone</label>
|
|
11059
|
+
<input id="phone" name="phone" type="tel" autocomplete="tel" />
|
|
11060
|
+
|
|
11061
|
+
<button type="submit">Send</button>
|
|
11062
|
+
</form>`,
|
|
11063
|
+
effort: "easy",
|
|
11064
|
+
docsUrl: "https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill",
|
|
11065
|
+
tags: ["forms", "labels", "autocomplete", "agent-fillable"]
|
|
11066
|
+
}
|
|
11067
|
+
};
|
|
11068
|
+
audit(ctx) {
|
|
11069
|
+
let totalFields = 0;
|
|
11070
|
+
let actionableFields = 0;
|
|
11071
|
+
const issues = [];
|
|
11072
|
+
for (const page of ctx.pages) {
|
|
11073
|
+
const $ = page.$;
|
|
11074
|
+
$("form").each((formIndex, formEl) => {
|
|
11075
|
+
$(formEl).find('[role="textbox"], [contenteditable="true"]').filter((_, el) => !["input", "select", "textarea"].includes(el.tagName?.toLowerCase() ?? "")).each((_, el) => {
|
|
11076
|
+
const name = $(el).attr("name") ?? $(el).attr("id") ?? el.tagName;
|
|
11077
|
+
totalFields++;
|
|
11078
|
+
issues.push({
|
|
11079
|
+
pageUrl: page.url,
|
|
11080
|
+
formIndex,
|
|
11081
|
+
field: `${el.tagName.toLowerCase()}[name="${name}"]`,
|
|
11082
|
+
failedChecks: ["not a native form element (div pretending to be an input)"]
|
|
11083
|
+
});
|
|
11084
|
+
});
|
|
11085
|
+
$(formEl).find("input, select, textarea").each((_, inputEl) => {
|
|
11086
|
+
const $input = $(inputEl);
|
|
11087
|
+
const tag = inputEl.tagName.toLowerCase();
|
|
11088
|
+
const type = ($input.attr("type") ?? (tag === "input" ? "text" : tag)).toLowerCase();
|
|
11089
|
+
if (SKIP_TYPES.includes(type)) return;
|
|
11090
|
+
totalFields++;
|
|
11091
|
+
const failedChecks = [];
|
|
11092
|
+
const name = $input.attr("name") ?? "";
|
|
11093
|
+
const id = $input.attr("id");
|
|
11094
|
+
const escapedId = id ? id.replace(/(["\\\]:])/g, "\\$1") : "";
|
|
11095
|
+
const hasForLabel = escapedId !== "" && $(formEl).find(`label[for="${escapedId}"]`).length > 0;
|
|
11096
|
+
const hasWrappingLabel = $input.closest("label").length > 0;
|
|
11097
|
+
const hasAriaLabel = ($input.attr("aria-label") ?? "").trim().length > 0;
|
|
11098
|
+
const hasAriaLabelledby = ($input.attr("aria-labelledby") ?? "").trim().length > 0;
|
|
11099
|
+
if (!hasForLabel && !hasWrappingLabel && !hasAriaLabel && !hasAriaLabelledby) {
|
|
11100
|
+
failedChecks.push("no explicit label (missing label[for], wrapping label, aria-label, or aria-labelledby)");
|
|
11101
|
+
}
|
|
11102
|
+
const labelText2 = hasForLabel ? labelTextFor($, formEl, id) : $input.closest("label").text().trim();
|
|
11103
|
+
const expectedToken = expectedAutocompleteToken(tag, type, name, id ?? "", labelText2);
|
|
11104
|
+
const autocomplete2 = $input.attr("autocomplete");
|
|
11105
|
+
if (expectedToken && (!autocomplete2 || !isStandardAutocomplete(autocomplete2))) {
|
|
11106
|
+
failedChecks.push(
|
|
11107
|
+
autocomplete2 ? `non-standard autocomplete="${autocomplete2}" (expected token like "${expectedToken}")` : `missing autocomplete (identity field, expected token like "${expectedToken}")`
|
|
11108
|
+
);
|
|
11109
|
+
}
|
|
11110
|
+
if (failedChecks.length === 0) {
|
|
11111
|
+
actionableFields++;
|
|
11112
|
+
} else {
|
|
11113
|
+
issues.push({
|
|
11114
|
+
pageUrl: page.url,
|
|
11115
|
+
formIndex,
|
|
11116
|
+
field: `${tag}[name="${name || "(unnamed)"}" type="${type}"]`,
|
|
11117
|
+
failedChecks
|
|
11118
|
+
});
|
|
11119
|
+
}
|
|
11120
|
+
});
|
|
11121
|
+
});
|
|
11122
|
+
}
|
|
11123
|
+
if (totalFields === 0) {
|
|
11124
|
+
return this.notApplicable(
|
|
11125
|
+
"No forms with fillable fields found on scanned pages \u2014 nothing to check.",
|
|
11126
|
+
"Forms contain native, labeled fields with standard autocomplete attributes",
|
|
11127
|
+
"0 fillable fields"
|
|
11128
|
+
);
|
|
11129
|
+
}
|
|
11130
|
+
const ratio = actionableFields / totalFields;
|
|
11131
|
+
const issueSummary = issues.slice(0, 10).map((i) => `form #${i.formIndex} ${i.field}: ${i.failedChecks.join("; ")}`).join(" | ");
|
|
11132
|
+
const truncated = issues.length > 10 ? ` (and ${issues.length - 10} more)` : "";
|
|
11133
|
+
const foundBase = `${actionableFields}/${totalFields} fields actionable (${Math.round(ratio * 100)}%)`;
|
|
11134
|
+
const found = issues.length > 0 ? `${foundBase}. Non-actionable: ${issueSummary}${truncated}` : foundBase;
|
|
11135
|
+
const firstIssueUrl = issues[0]?.pageUrl;
|
|
11136
|
+
if (ratio >= 0.9) {
|
|
11137
|
+
return this.pass(
|
|
11138
|
+
`${actionableFields}/${totalFields} form fields are fully actionable for agents (native element, labeled, standard autocomplete).`,
|
|
11139
|
+
"At least 90% of form fields are native, labeled, and use standard autocomplete",
|
|
11140
|
+
found,
|
|
11141
|
+
firstIssueUrl
|
|
11142
|
+
);
|
|
11143
|
+
}
|
|
11144
|
+
if (ratio >= 0.5) {
|
|
11145
|
+
return this.warn(
|
|
11146
|
+
`${actionableFields}/${totalFields} form fields are fully actionable \u2014 agents will struggle with the rest.`,
|
|
11147
|
+
"At least 90% of form fields are native, labeled, and use standard autocomplete",
|
|
11148
|
+
found,
|
|
11149
|
+
"high",
|
|
11150
|
+
firstIssueUrl
|
|
11151
|
+
);
|
|
11152
|
+
}
|
|
11153
|
+
return this.fail(
|
|
11154
|
+
`Only ${actionableFields}/${totalFields} form fields are actionable \u2014 agents cannot reliably fill these forms.`,
|
|
11155
|
+
"At least 90% of form fields are native, labeled, and use standard autocomplete",
|
|
11156
|
+
found,
|
|
11157
|
+
"high",
|
|
11158
|
+
firstIssueUrl
|
|
11159
|
+
);
|
|
11160
|
+
}
|
|
11161
|
+
};
|
|
11162
|
+
|
|
10497
11163
|
// src/audits/semantic-html/single-h1.ts
|
|
10498
11164
|
var SingleH1Audit = class extends Audit {
|
|
10499
11165
|
static meta = {
|
|
@@ -11596,6 +12262,293 @@ var FigureFigcaptionAudit = class extends Audit {
|
|
|
11596
12262
|
}
|
|
11597
12263
|
};
|
|
11598
12264
|
|
|
12265
|
+
// src/audits/semantic-html/svg-bloat.ts
|
|
12266
|
+
var FLAG_THRESHOLD_BYTES = 2048;
|
|
12267
|
+
var SINGLE_FAIL_BYTES = 10240;
|
|
12268
|
+
var TOTAL_FAIL_BYTES = 20480;
|
|
12269
|
+
var TOTAL_WARN_BYTES = 8192;
|
|
12270
|
+
var MARKUP_SNIPPET_CHARS = 120;
|
|
12271
|
+
function formatBytes(bytes) {
|
|
12272
|
+
return bytes >= 1024 ? `${(bytes / 1024).toFixed(1)}KB` : `${bytes}B`;
|
|
12273
|
+
}
|
|
12274
|
+
var SvgBloatAudit = class extends Audit {
|
|
12275
|
+
static meta = {
|
|
12276
|
+
id: "6.18",
|
|
12277
|
+
category: "semantic-html",
|
|
12278
|
+
title: "SVGs not bloating agent context",
|
|
12279
|
+
failureTitle: "Large inline SVGs bloating agent context",
|
|
12280
|
+
description: 'When an LLM converts your HTML to Markdown or reads raw markup, every inline SVG is inlined as thousands of path-data tokens. Decorative icon sprites, charts, and complex illustrations can silently consume tens of thousands of tokens of agent context per page \u2014 "SVG context poisoning" \u2014 crowding out the actual content the agent should read. SVGs marked aria-hidden="true" or role="presentation" are stripped by most accessibility-tree extractors and do not count. Keep visible SVGs small, move decorative ones behind aria-hidden, and prefer raster images or CSS for complex graphics.',
|
|
12281
|
+
scoreDisplayMode: "ternary",
|
|
12282
|
+
weight: 0.6,
|
|
12283
|
+
defaultPriority: "medium",
|
|
12284
|
+
guidance: {
|
|
12285
|
+
impact: "Large inline SVGs are inlined verbatim as path-data tokens when an LLM converts your page to Markdown. A single 10KB icon or chart can consume thousands of tokens of agent context per page load, inflating agent cost and pushing real content out of the context window \u2014 reducing the quality of what agents extract and say about your site.",
|
|
12286
|
+
fix: 'Mark decorative SVGs with aria-hidden="true" so agent pipelines strip them. For visible graphics, simplify path data with SVGO, extract complex SVGs to external files referenced via <img>, or replace them with raster images when they exceed a few kilobytes.',
|
|
12287
|
+
code: '<svg aria-hidden="true" focusable="false" ...>...</svg>',
|
|
12288
|
+
effort: "easy",
|
|
12289
|
+
docsUrl: "https://github.com/svg/svgo",
|
|
12290
|
+
tags: ["svg", "context-window", "tokens", "performance"]
|
|
12291
|
+
}
|
|
12292
|
+
};
|
|
12293
|
+
audit(ctx) {
|
|
12294
|
+
let totalCount = 0;
|
|
12295
|
+
let unhiddenCount = 0;
|
|
12296
|
+
let unhiddenBytes = 0;
|
|
12297
|
+
const flagged = [];
|
|
12298
|
+
for (const page of ctx.pages) {
|
|
12299
|
+
page.$("svg").each((_, el) => {
|
|
12300
|
+
totalCount++;
|
|
12301
|
+
const $el = page.$(el);
|
|
12302
|
+
if ($el.attr("aria-hidden") === "true" || $el.attr("role") === "presentation") {
|
|
12303
|
+
return;
|
|
12304
|
+
}
|
|
12305
|
+
unhiddenCount++;
|
|
12306
|
+
const markup = page.$.html(el) ?? "";
|
|
12307
|
+
const bytes = Buffer.byteLength(markup);
|
|
12308
|
+
unhiddenBytes += bytes;
|
|
12309
|
+
if (bytes > FLAG_THRESHOLD_BYTES) {
|
|
12310
|
+
const oneLine = markup.replace(/\s+/g, " ").trim();
|
|
12311
|
+
const snippet = oneLine.length > MARKUP_SNIPPET_CHARS ? `${oneLine.slice(0, MARKUP_SNIPPET_CHARS)}...` : oneLine;
|
|
12312
|
+
flagged.push({ pageUrl: page.url, bytes, snippet });
|
|
12313
|
+
}
|
|
12314
|
+
});
|
|
12315
|
+
}
|
|
12316
|
+
if (totalCount === 0) {
|
|
12317
|
+
return this.notApplicable(
|
|
12318
|
+
"No inline SVG elements found on any page.",
|
|
12319
|
+
"No oversized unhidden inline SVGs.",
|
|
12320
|
+
"No SVGs present"
|
|
12321
|
+
);
|
|
12322
|
+
}
|
|
12323
|
+
const summary = `${totalCount} SVG(s) total, ${unhiddenCount} unhidden, ${formatBytes(unhiddenBytes)} unhidden bytes across ${ctx.pages.length} page(s).`;
|
|
12324
|
+
if (flagged.length === 0 && unhiddenBytes <= TOTAL_WARN_BYTES) {
|
|
12325
|
+
return this.pass(
|
|
12326
|
+
`${summary} All unhidden SVGs are small enough for agent context.`,
|
|
12327
|
+
"Unhidden SVGs stay under 2KB each and under 8KB total.",
|
|
12328
|
+
`${formatBytes(unhiddenBytes)} of unhidden SVG markup`
|
|
12329
|
+
);
|
|
12330
|
+
}
|
|
12331
|
+
flagged.sort((a, b) => b.bytes - a.bytes);
|
|
12332
|
+
const largest = flagged[0];
|
|
12333
|
+
const offenders = flagged.length ? ` Top offenders:
|
|
12334
|
+
${flagged.slice(0, 5).map((f) => `${formatBytes(f.bytes)} at ${f.pageUrl}: ${f.snippet}`).join("\n")}` : "";
|
|
12335
|
+
const found = `${summary}${offenders}`;
|
|
12336
|
+
const expected = "Unhidden SVGs stay under 2KB each and under 8KB total; nothing over 10KB per SVG or 20KB total.";
|
|
12337
|
+
if (largest && largest.bytes > SINGLE_FAIL_BYTES || unhiddenBytes > TOTAL_FAIL_BYTES) {
|
|
12338
|
+
const largestNote = largest ? ` \u2014 largest unhidden SVG is ${formatBytes(largest.bytes)}` : "";
|
|
12339
|
+
return this.fail(
|
|
12340
|
+
`${summary} Severe SVG context bloat detected${largestNote}.`,
|
|
12341
|
+
expected,
|
|
12342
|
+
found,
|
|
12343
|
+
{
|
|
12344
|
+
priority: "medium",
|
|
12345
|
+
description: 'Large unhidden inline SVGs are inlined as path-data tokens when an LLM reads your page, consuming thousands of tokens of agent context. Mark decorative SVGs aria-hidden="true", simplify paths with SVGO, or move complex graphics to external files.',
|
|
12346
|
+
code: '<svg aria-hidden="true" focusable="false" ...>...</svg>'
|
|
12347
|
+
}
|
|
12348
|
+
);
|
|
12349
|
+
}
|
|
12350
|
+
const warnReason = flagged.length ? `${flagged.length} unhidden SVG(s) exceed 2KB and may bloat agent context.` : `unhidden SVGs total ${formatBytes(unhiddenBytes)}, bloating agent context.`;
|
|
12351
|
+
return this.warn(
|
|
12352
|
+
`${summary} ${warnReason}`,
|
|
12353
|
+
expected,
|
|
12354
|
+
found,
|
|
12355
|
+
{
|
|
12356
|
+
priority: "medium",
|
|
12357
|
+
description: 'Unhidden inline SVGs over 2KB add meaningful token overhead when an LLM converts your page to Markdown. Mark decorative SVGs aria-hidden="true" or optimize them with SVGO to keep agent context focused on real content.',
|
|
12358
|
+
code: '<svg aria-hidden="true" focusable="false" ...>...</svg>'
|
|
12359
|
+
}
|
|
12360
|
+
);
|
|
12361
|
+
}
|
|
12362
|
+
};
|
|
12363
|
+
|
|
12364
|
+
// src/audits/semantic-html/token-ratio.ts
|
|
12365
|
+
var CHARS_PER_TOKEN = 4;
|
|
12366
|
+
var FAIL_RATIO = 0.05;
|
|
12367
|
+
var WARN_RATIO = 0.15;
|
|
12368
|
+
var TokenRatioAudit = class extends Audit {
|
|
12369
|
+
static meta = {
|
|
12370
|
+
id: "6.19",
|
|
12371
|
+
category: "semantic-html",
|
|
12372
|
+
title: "Lean token-to-content ratio",
|
|
12373
|
+
failureTitle: "Lean token-to-content ratio",
|
|
12374
|
+
description: 'AI agents pay for every token of raw HTML they download, but only the visible text carries meaning. This audit compares the character weight of the raw HTML against the extracted main-content text to produce a "context bloat score": the share of the page that is actual content rather than markup, scripts, and styles. The ratio is evaluated on the homepage (the first crawled page), which is the entry point agents most often fetch. A ratio under 5% means an agent parses 20 tokens of noise for every token of content; under 15% still wastes most of the context window on boilerplate. Unlike content depth (which measures text volume), this measures how efficiently that text is packaged.',
|
|
12375
|
+
scoreDisplayMode: "ternary",
|
|
12376
|
+
weight: 0.8,
|
|
12377
|
+
defaultPriority: "high",
|
|
12378
|
+
guidance: {
|
|
12379
|
+
impact: "When less than 15% of your HTML is actual content, AI agents burn most of their context window and token budget on markup noise: inline scripts, CSS, SVG sprites, tracking tags, and deeply nested divs. The useful text that remains gets weaker attention from the model, and pages with extreme bloat may be truncated before the real content is even read.",
|
|
12380
|
+
fix: "Move inline scripts and styles to external cached files, remove unused framework boilerplate and hidden DOM subtrees, flatten excessive wrapper divs, and serve content in semantic HTML rather than JSON blobs that need client-side hydration. Aim for at least 15% of the raw HTML weight to be visible content text.",
|
|
12381
|
+
code: '<!-- Before: content buried in markup noise -->\n<div class="w1"><div class="w2"><div class="w3">\n <script>/* 50KB of hydration data */</script>\n <p>Buy our product</p>\n</div></div></div>\n\n<!-- After: lean, content-first markup -->\n<main>\n <h1>Our product</h1>\n <p>Buy our product. It solves your problem by...</p>\n</main>\n<script src="/app.js" defer></script>',
|
|
12382
|
+
effort: "moderate",
|
|
12383
|
+
tags: ["tokens", "performance", "content", "context-window"]
|
|
12384
|
+
}
|
|
12385
|
+
};
|
|
12386
|
+
audit(ctx) {
|
|
12387
|
+
const page = ctx.pages[0];
|
|
12388
|
+
const rawHtml = page?.fetchResult.body ?? "";
|
|
12389
|
+
if (!page || rawHtml.trim().length === 0) {
|
|
12390
|
+
return this.notApplicable(
|
|
12391
|
+
"No HTML body available to measure token-to-content ratio.",
|
|
12392
|
+
"A non-empty HTML body",
|
|
12393
|
+
"Empty body"
|
|
12394
|
+
);
|
|
12395
|
+
}
|
|
12396
|
+
const cleanText = getMainContentText(page.$);
|
|
12397
|
+
const rawChars = rawHtml.length;
|
|
12398
|
+
const contentChars = cleanText.length;
|
|
12399
|
+
const ratio = contentChars / rawChars;
|
|
12400
|
+
const pct = `${(ratio * 100).toFixed(1)}%`;
|
|
12401
|
+
const displayValue = `${pct} content (${Math.round(contentChars / CHARS_PER_TOKEN)} of ${Math.round(rawChars / CHARS_PER_TOKEN)} est. tokens)`;
|
|
12402
|
+
const expected = `At least ${(WARN_RATIO * 100).toFixed(0)}% of the raw HTML weight is visible content text`;
|
|
12403
|
+
if (ratio >= WARN_RATIO) {
|
|
12404
|
+
return {
|
|
12405
|
+
...this.pass(
|
|
12406
|
+
`Homepage is ${pct} content by character weight \u2014 markup overhead is within a healthy range.`,
|
|
12407
|
+
expected,
|
|
12408
|
+
displayValue,
|
|
12409
|
+
page.url
|
|
12410
|
+
),
|
|
12411
|
+
displayValue
|
|
12412
|
+
};
|
|
12413
|
+
}
|
|
12414
|
+
if (ratio >= FAIL_RATIO) {
|
|
12415
|
+
return {
|
|
12416
|
+
...this.warn(
|
|
12417
|
+
`Homepage is only ${pct} content by character weight \u2014 agents spend most of their context on markup noise.`,
|
|
12418
|
+
expected,
|
|
12419
|
+
displayValue,
|
|
12420
|
+
{
|
|
12421
|
+
priority: "high",
|
|
12422
|
+
description: "AI agents pay for every token of raw HTML they download, but only the visible text carries meaning. A content share under 15% means most of the context window is consumed by scripts, styles, and wrapper markup instead of your actual content. Move inline assets to external files, remove unused boilerplate, and flatten markup."
|
|
12423
|
+
},
|
|
12424
|
+
page.url
|
|
12425
|
+
),
|
|
12426
|
+
displayValue
|
|
12427
|
+
};
|
|
12428
|
+
}
|
|
12429
|
+
return {
|
|
12430
|
+
...this.fail(
|
|
12431
|
+
`Homepage is only ${pct} content by character weight \u2014 the page is almost entirely markup noise.`,
|
|
12432
|
+
expected,
|
|
12433
|
+
displayValue,
|
|
12434
|
+
{
|
|
12435
|
+
priority: "high",
|
|
12436
|
+
description: "AI agents pay for every token of raw HTML they download, but only the visible text carries meaning. A content share under 5% means an agent parses more than 20 tokens of noise for every token of content, and the page may be truncated before the real content is read. Move inline assets to external files, remove unused boilerplate, and flatten markup."
|
|
12437
|
+
},
|
|
12438
|
+
page.url
|
|
12439
|
+
),
|
|
12440
|
+
displayValue
|
|
12441
|
+
};
|
|
12442
|
+
}
|
|
12443
|
+
};
|
|
12444
|
+
|
|
12445
|
+
// src/audits/semantic-html/fake-headings.ts
|
|
12446
|
+
var FAKE_HEADING_CLASS = /(text-(xl|2xl|3xl|4xl|5xl)|font-(bold|semibold|extrabold)|heading|headline)/i;
|
|
12447
|
+
var MIN_FONT_SIZE_PX = 20;
|
|
12448
|
+
var MIN_FONT_WEIGHT = 600;
|
|
12449
|
+
var MAX_HEADING_TEXT_LENGTH = 120;
|
|
12450
|
+
var BLOCK_CHILDREN = "div, p, section, article, aside, ul, ol, dl, table, figure, blockquote, pre, form";
|
|
12451
|
+
var EXCLUDED_ANCESTORS = "nav, footer, button, a";
|
|
12452
|
+
function looksLikeHeading(el, $) {
|
|
12453
|
+
const $el = $(el);
|
|
12454
|
+
const tag = el.tagName?.toLowerCase() ?? "";
|
|
12455
|
+
const text = $el.text().replace(/\s+/g, " ").trim();
|
|
12456
|
+
if (text.length === 0 || text.length > MAX_HEADING_TEXT_LENGTH) return null;
|
|
12457
|
+
if ($el.find("h1, h2, h3, h4, h5, h6").length > 0) return null;
|
|
12458
|
+
if ($el.find(BLOCK_CHILDREN).length > 0) return null;
|
|
12459
|
+
if ($el.parents(EXCLUDED_ANCESTORS).length > 0) return null;
|
|
12460
|
+
const className = $el.attr("class") ?? "";
|
|
12461
|
+
const style = $el.attr("style") ?? "";
|
|
12462
|
+
const classHit = FAKE_HEADING_CLASS.test(className);
|
|
12463
|
+
let styleHit = false;
|
|
12464
|
+
if (style) {
|
|
12465
|
+
const sizeMatch = /font-size\s*:\s*(\d+(?:\.\d+)?)px/i.exec(style);
|
|
12466
|
+
if (sizeMatch && parseFloat(sizeMatch[1]) >= MIN_FONT_SIZE_PX) styleHit = true;
|
|
12467
|
+
const weightMatch = /font-weight\s*:\s*(\d+|bold|bolder)/i.exec(style);
|
|
12468
|
+
if (weightMatch) {
|
|
12469
|
+
const w = weightMatch[1].toLowerCase();
|
|
12470
|
+
if (w === "bold" || w === "bolder" || parseInt(w, 10) >= MIN_FONT_WEIGHT) styleHit = true;
|
|
12471
|
+
}
|
|
12472
|
+
}
|
|
12473
|
+
if (!classHit && !styleHit) return null;
|
|
12474
|
+
return {
|
|
12475
|
+
tag,
|
|
12476
|
+
className: className || void 0,
|
|
12477
|
+
style: style || void 0,
|
|
12478
|
+
text
|
|
12479
|
+
};
|
|
12480
|
+
}
|
|
12481
|
+
var FakeHeadingsAudit = class extends Audit {
|
|
12482
|
+
static meta = {
|
|
12483
|
+
id: "6.20",
|
|
12484
|
+
category: "semantic-html",
|
|
12485
|
+
title: "No fake headings",
|
|
12486
|
+
failureTitle: "Fake headings detected",
|
|
12487
|
+
description: `AI agents chunk and outline page content by reading real <h1>\u2013<h6> tags. When a page styles a <div>, <span>, <p>, or <b> to look like a heading (large text, bold weight, "heading" classes) instead of using a semantic heading element, that text is invisible to the agent's document outline \u2014 sections cannot be navigated, summarized, or cited correctly. This audit is distinct from the sequential-heading check (6.2): 6.2 verifies that real headings appear in the right order, while this audit catches content that impersonates headings without using heading tags at all. Replace styled generic elements with the appropriate <h1>\u2013<h6> level.`,
|
|
12488
|
+
scoreDisplayMode: "ternary",
|
|
12489
|
+
weight: 0.7,
|
|
12490
|
+
defaultPriority: "medium",
|
|
12491
|
+
guidance: {
|
|
12492
|
+
impact: "AI agents build content outlines exclusively from <h1>\u2013<h6> elements. Text that only looks like a heading is treated as ordinary body copy, so agents miss your section structure entirely \u2014 summaries flatten into a wall of text, section-level citations become impossible, and chunking for retrieval splits content at arbitrary points instead of at your intended section boundaries.",
|
|
12493
|
+
fix: "Replace generic elements styled to look like headings with real heading tags. Pick the level that reflects the content's position in the outline (h2 for major sections under the h1, h3 for subsections, and so on), and move the visual styling to CSS targeting those heading elements instead of utility classes on divs and spans.",
|
|
12494
|
+
code: '<!-- Before: looks like a heading, invisible to agents -->\n<div class="text-2xl font-bold">Pricing Plans</div>\n\n<!-- After: semantic and styleable -->\n<h2 class="text-2xl font-bold">Pricing Plans</h2>',
|
|
12495
|
+
effort: "easy",
|
|
12496
|
+
docsUrl: "https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements",
|
|
12497
|
+
tags: ["headings", "semantic", "chunking", "structure"]
|
|
12498
|
+
}
|
|
12499
|
+
};
|
|
12500
|
+
audit(ctx) {
|
|
12501
|
+
const found = [];
|
|
12502
|
+
for (const page of ctx.pages) {
|
|
12503
|
+
const $ = page.$;
|
|
12504
|
+
const flagged = /* @__PURE__ */ new Set();
|
|
12505
|
+
$("div, span, p, b, strong").each((_i, el) => {
|
|
12506
|
+
if ($(el).parents().toArray().some((ancestor) => flagged.has(ancestor))) {
|
|
12507
|
+
return;
|
|
12508
|
+
}
|
|
12509
|
+
const hit = looksLikeHeading(el, $);
|
|
12510
|
+
if (hit) {
|
|
12511
|
+
flagged.add(el);
|
|
12512
|
+
found.push({ url: page.url, heading: hit });
|
|
12513
|
+
}
|
|
12514
|
+
});
|
|
12515
|
+
}
|
|
12516
|
+
const describe = (h) => {
|
|
12517
|
+
const via = h.className ? `class="${h.className}"` : `style="${h.style}"`;
|
|
12518
|
+
const text = h.text.length > 60 ? `${h.text.slice(0, 57)}...` : h.text;
|
|
12519
|
+
return `<${h.tag} ${via}> "${text}"`;
|
|
12520
|
+
};
|
|
12521
|
+
const foundSummary = found.slice(0, 5).map((f) => `${f.url}: ${describe(f.heading)}`).join("; ");
|
|
12522
|
+
const expected = "All heading-like text uses semantic <h1>-<h6> elements";
|
|
12523
|
+
if (found.length === 0) {
|
|
12524
|
+
return this.pass(
|
|
12525
|
+
"No fake headings detected \u2014 heading-like text uses semantic heading elements.",
|
|
12526
|
+
expected,
|
|
12527
|
+
"No styled <div>/<span>/<p>/<b> elements impersonating headings"
|
|
12528
|
+
);
|
|
12529
|
+
}
|
|
12530
|
+
const recommendation = {
|
|
12531
|
+
priority: "medium",
|
|
12532
|
+
description: "AI agents chunk and outline page content by reading real <h1>\u2013<h6> tags. Elements styled to look like headings are invisible to that outline, so sections cannot be navigated, summarized, or cited correctly. Replace styled generic elements with the appropriate heading level.",
|
|
12533
|
+
code: '<!-- Before -->\n<div class="text-2xl font-bold">Pricing Plans</div>\n\n<!-- After -->\n<h2 class="text-2xl font-bold">Pricing Plans</h2>'
|
|
12534
|
+
};
|
|
12535
|
+
if (found.length >= 5) {
|
|
12536
|
+
return this.fail(
|
|
12537
|
+
`Found ${found.length} fake heading(s) \u2014 generic elements styled to look like headings instead of <h1>-<h6> tags.`,
|
|
12538
|
+
expected,
|
|
12539
|
+
foundSummary,
|
|
12540
|
+
recommendation
|
|
12541
|
+
);
|
|
12542
|
+
}
|
|
12543
|
+
return this.warn(
|
|
12544
|
+
`Found ${found.length} fake heading(s) \u2014 generic elements styled to look like headings instead of <h1>-<h6> tags.`,
|
|
12545
|
+
expected,
|
|
12546
|
+
foundSummary,
|
|
12547
|
+
recommendation
|
|
12548
|
+
);
|
|
12549
|
+
}
|
|
12550
|
+
};
|
|
12551
|
+
|
|
11599
12552
|
// src/audits/accessibility/skip-nav.ts
|
|
11600
12553
|
var SkipNavAudit = class extends Audit {
|
|
11601
12554
|
static meta = {
|
|
@@ -16346,7 +17299,9 @@ var defaultConfig = {
|
|
|
16346
17299
|
reg(SensitivePathsAudit),
|
|
16347
17300
|
reg(CrawlDelayAudit),
|
|
16348
17301
|
reg(MetaRobotsNotBlockingAudit),
|
|
16349
|
-
reg(NoBotDetectionAudit)
|
|
17302
|
+
reg(NoBotDetectionAudit),
|
|
17303
|
+
reg(TdmRepAudit),
|
|
17304
|
+
reg(AgentGovernanceAudit)
|
|
16350
17305
|
],
|
|
16351
17306
|
"structured-data": [
|
|
16352
17307
|
reg(JsonLdPresentAudit),
|
|
@@ -16367,7 +17322,8 @@ var defaultConfig = {
|
|
|
16367
17322
|
reg(ActionSchemaAudit),
|
|
16368
17323
|
reg(ProductIdentifiersAudit),
|
|
16369
17324
|
reg(ProductDetailsAudit),
|
|
16370
|
-
reg(ProductReviewsAudit)
|
|
17325
|
+
reg(ProductReviewsAudit),
|
|
17326
|
+
reg(ProductTransactionCertaintyAudit)
|
|
16371
17327
|
],
|
|
16372
17328
|
"meta-tags": [
|
|
16373
17329
|
reg(MetaDescriptionAudit),
|
|
@@ -16416,7 +17372,9 @@ var defaultConfig = {
|
|
|
16416
17372
|
reg(WebmcpInputQualityAudit),
|
|
16417
17373
|
reg(WebmcpToolNamingAudit),
|
|
16418
17374
|
reg(WebmcpToolAnnotationsAudit),
|
|
16419
|
-
reg(WebmcpActionCoverageAudit)
|
|
17375
|
+
reg(WebmcpActionCoverageAudit),
|
|
17376
|
+
reg(OpenApiDescriptionQualityAudit),
|
|
17377
|
+
reg(FormActionabilityAudit)
|
|
16420
17378
|
],
|
|
16421
17379
|
"semantic-html": [
|
|
16422
17380
|
reg(SingleH1Audit),
|
|
@@ -16435,7 +17393,10 @@ var defaultConfig = {
|
|
|
16435
17393
|
reg(ContentDepthAudit),
|
|
16436
17394
|
reg(ImageAltTextAudit),
|
|
16437
17395
|
reg(DecorativeImagesAudit),
|
|
16438
|
-
reg(FigureFigcaptionAudit)
|
|
17396
|
+
reg(FigureFigcaptionAudit),
|
|
17397
|
+
reg(SvgBloatAudit),
|
|
17398
|
+
reg(TokenRatioAudit),
|
|
17399
|
+
reg(FakeHeadingsAudit)
|
|
16439
17400
|
],
|
|
16440
17401
|
accessibility: [
|
|
16441
17402
|
reg(SkipNavAudit),
|
|
@@ -16534,17 +17495,17 @@ function stubCheck(meta, tag, explanation) {
|
|
|
16534
17495
|
tags: [tag]
|
|
16535
17496
|
};
|
|
16536
17497
|
}
|
|
16537
|
-
|
|
17498
|
+
function planAudits(ctx, config) {
|
|
16538
17499
|
const scannedPageTypes = new Set(ctx.pages.map((p) => p.pageType));
|
|
16539
|
-
const
|
|
16540
|
-
const
|
|
17500
|
+
const runnable = [];
|
|
17501
|
+
const skipped = [];
|
|
16541
17502
|
for (const cat of config.categories) {
|
|
16542
17503
|
const regs = config.audits[cat.id] ?? [];
|
|
16543
17504
|
for (const reg2 of regs) {
|
|
16544
17505
|
const applicable = reg2.meta.applicablePageTypes;
|
|
16545
17506
|
if (applicable && applicable.length > 0) {
|
|
16546
17507
|
if (!applicable.some((pt) => scannedPageTypes.has(pt))) {
|
|
16547
|
-
|
|
17508
|
+
skipped.push(
|
|
16548
17509
|
stubCheck(
|
|
16549
17510
|
reg2.meta,
|
|
16550
17511
|
TAG_SKIPPED_PAGE_TYPE,
|
|
@@ -16554,30 +17515,35 @@ async function runAudits(ctx, config, onProgress) {
|
|
|
16554
17515
|
continue;
|
|
16555
17516
|
}
|
|
16556
17517
|
}
|
|
16557
|
-
|
|
17518
|
+
runnable.push({ reg: reg2, categoryId: cat.id });
|
|
16558
17519
|
}
|
|
16559
17520
|
}
|
|
16560
|
-
|
|
16561
|
-
|
|
17521
|
+
return { runnable, skipped };
|
|
17522
|
+
}
|
|
17523
|
+
async function runAudits(ctx, config, onEvent, plan) {
|
|
17524
|
+
const { runnable, skipped } = plan ?? planAudits(ctx, config);
|
|
17525
|
+
const allChecks = [...skipped];
|
|
16562
17526
|
const batchSize = 20;
|
|
16563
|
-
for (let i = 0; i <
|
|
16564
|
-
const batch =
|
|
17527
|
+
for (let i = 0; i < runnable.length; i += batchSize) {
|
|
17528
|
+
const batch = runnable.slice(i, i + batchSize);
|
|
16565
17529
|
const batchResults = await Promise.all(
|
|
16566
17530
|
batch.map(async ({ reg: reg2 }) => {
|
|
17531
|
+
const label2 = `${reg2.meta.id} ${reg2.meta.title}`;
|
|
16567
17532
|
try {
|
|
16568
17533
|
const instance = reg2.create();
|
|
16569
17534
|
const result = await instance.audit(ctx);
|
|
16570
|
-
|
|
17535
|
+
const check = instance.toCheckResult(result);
|
|
17536
|
+
onEvent?.({ type: "unit:done", label: label2 });
|
|
17537
|
+
return check;
|
|
16571
17538
|
} catch (err) {
|
|
16572
17539
|
logger.error({ err, auditId: reg2.meta.id }, "[scanner] Audit error");
|
|
16573
17540
|
const message = err instanceof Error ? err.message : String(err);
|
|
17541
|
+
onEvent?.({ type: "unit:fail", label: label2, error: message });
|
|
16574
17542
|
return stubCheck(reg2.meta, TAG_SCAN_ERROR, `Audit failed to run: ${message}`);
|
|
16575
17543
|
}
|
|
16576
17544
|
})
|
|
16577
17545
|
);
|
|
16578
17546
|
allChecks.push(...batchResults);
|
|
16579
|
-
completed += batchResults.length;
|
|
16580
|
-
onProgress?.(completed, totalAudits);
|
|
16581
17547
|
}
|
|
16582
17548
|
const categories = config.categories.map((cat) => {
|
|
16583
17549
|
const catChecks = allChecks.filter((c) => c.category === cat.id);
|
|
@@ -16625,6 +17591,110 @@ function buildWeightedCategoryResult(cat, checks2, registrations) {
|
|
|
16625
17591
|
};
|
|
16626
17592
|
}
|
|
16627
17593
|
|
|
17594
|
+
// src/progress.ts
|
|
17595
|
+
var PHASE_WEIGHTS = {
|
|
17596
|
+
"fetch-root": 0.35,
|
|
17597
|
+
"fetch-pages": 0.2,
|
|
17598
|
+
analyze: 0.1,
|
|
17599
|
+
audits: 0.3,
|
|
17600
|
+
report: 0.05
|
|
17601
|
+
};
|
|
17602
|
+
var ProgressTracker = class {
|
|
17603
|
+
onEvent;
|
|
17604
|
+
startMs;
|
|
17605
|
+
doneWeight = 0;
|
|
17606
|
+
phase = null;
|
|
17607
|
+
phaseStartMs = 0;
|
|
17608
|
+
totalUnits = 0;
|
|
17609
|
+
completedUnits = 0;
|
|
17610
|
+
lastFraction = 0;
|
|
17611
|
+
constructor(onEvent) {
|
|
17612
|
+
this.onEvent = onEvent;
|
|
17613
|
+
this.startMs = performance.now();
|
|
17614
|
+
}
|
|
17615
|
+
/** Fraction of the whole scan that is complete, in [0, 1]. Never decreases. */
|
|
17616
|
+
get fraction() {
|
|
17617
|
+
let f = this.doneWeight;
|
|
17618
|
+
if (this.phase !== null) {
|
|
17619
|
+
const ratio = this.totalUnits > 0 ? Math.min(1, this.completedUnits / this.totalUnits) : 0;
|
|
17620
|
+
f += PHASE_WEIGHTS[this.phase] * ratio;
|
|
17621
|
+
}
|
|
17622
|
+
return Math.min(1, Math.max(f, this.lastFraction));
|
|
17623
|
+
}
|
|
17624
|
+
/** Stamp an event with the current fraction/elapsed and advance the floor. */
|
|
17625
|
+
stamp() {
|
|
17626
|
+
const fraction = this.fraction;
|
|
17627
|
+
this.lastFraction = Math.max(this.lastFraction, fraction);
|
|
17628
|
+
return { fraction, elapsedMs: this.elapsedMs() };
|
|
17629
|
+
}
|
|
17630
|
+
elapsedMs() {
|
|
17631
|
+
return Math.max(0, Math.round(performance.now() - this.startMs));
|
|
17632
|
+
}
|
|
17633
|
+
scanStart(url) {
|
|
17634
|
+
this.onEvent({ type: "scan:start", url, ...this.stamp() });
|
|
17635
|
+
}
|
|
17636
|
+
phaseStart(phase, totalUnits) {
|
|
17637
|
+
this.phase = phase;
|
|
17638
|
+
this.phaseStartMs = performance.now();
|
|
17639
|
+
this.totalUnits = Math.max(0, totalUnits);
|
|
17640
|
+
this.completedUnits = 0;
|
|
17641
|
+
this.onEvent({
|
|
17642
|
+
type: "phase:start",
|
|
17643
|
+
phase,
|
|
17644
|
+
totalUnits: this.totalUnits,
|
|
17645
|
+
...this.stamp()
|
|
17646
|
+
});
|
|
17647
|
+
}
|
|
17648
|
+
/** Correct the current phase's unit total (e.g. discovery finds pages mid-phase). */
|
|
17649
|
+
setPhaseTotal(totalUnits) {
|
|
17650
|
+
this.totalUnits = Math.max(this.completedUnits, totalUnits);
|
|
17651
|
+
}
|
|
17652
|
+
unitDone(label2) {
|
|
17653
|
+
const phase = this.phase;
|
|
17654
|
+
if (phase === null) return;
|
|
17655
|
+
this.completedUnits += 1;
|
|
17656
|
+
this.onEvent({
|
|
17657
|
+
type: "unit:done",
|
|
17658
|
+
phase,
|
|
17659
|
+
completed: this.completedUnits,
|
|
17660
|
+
total: this.totalUnits,
|
|
17661
|
+
label: label2,
|
|
17662
|
+
...this.stamp()
|
|
17663
|
+
});
|
|
17664
|
+
}
|
|
17665
|
+
/** A failed unit still counts as settled work so the phase can complete. */
|
|
17666
|
+
unitFail(label2, error) {
|
|
17667
|
+
const phase = this.phase;
|
|
17668
|
+
if (phase === null) return;
|
|
17669
|
+
this.completedUnits += 1;
|
|
17670
|
+
this.onEvent({
|
|
17671
|
+
type: "unit:fail",
|
|
17672
|
+
phase,
|
|
17673
|
+
label: label2,
|
|
17674
|
+
error,
|
|
17675
|
+
...this.stamp()
|
|
17676
|
+
});
|
|
17677
|
+
}
|
|
17678
|
+
phaseDone() {
|
|
17679
|
+
const phase = this.phase;
|
|
17680
|
+
if (phase === null) return;
|
|
17681
|
+
this.phase = null;
|
|
17682
|
+
this.doneWeight += PHASE_WEIGHTS[phase];
|
|
17683
|
+
const durationMs = Math.max(0, Math.round(performance.now() - this.phaseStartMs));
|
|
17684
|
+
this.totalUnits = 0;
|
|
17685
|
+
this.completedUnits = 0;
|
|
17686
|
+
this.onEvent({
|
|
17687
|
+
type: "phase:done",
|
|
17688
|
+
phase,
|
|
17689
|
+
durationMs,
|
|
17690
|
+
...this.stamp()
|
|
17691
|
+
});
|
|
17692
|
+
}
|
|
17693
|
+
scanDone(score) {
|
|
17694
|
+
this.onEvent({ type: "scan:done", durationMs: this.elapsedMs(), score, ...this.stamp() });
|
|
17695
|
+
}
|
|
17696
|
+
};
|
|
17697
|
+
|
|
16628
17698
|
// src/audits/accessibility/runner.ts
|
|
16629
17699
|
var import_jsdom = require("jsdom");
|
|
16630
17700
|
|
|
@@ -22136,9 +23206,11 @@ function discoverPages(homepageUrl, domain, rootFiles, homepage$, exclude, maxAd
|
|
|
22136
23206
|
}
|
|
22137
23207
|
return selected;
|
|
22138
23208
|
}
|
|
22139
|
-
async function runScan(url,
|
|
22140
|
-
const
|
|
22141
|
-
|
|
23209
|
+
async function runScan(url, options) {
|
|
23210
|
+
const onEvent = options?.onEvent;
|
|
23211
|
+
const pageOverrides = options?.pages;
|
|
23212
|
+
const signal = options?.signal;
|
|
23213
|
+
const tracker = new ProgressTracker((event) => onEvent?.(event));
|
|
22142
23214
|
const start = performance.now();
|
|
22143
23215
|
const fetcher = createFetcher();
|
|
22144
23216
|
const baseUrl = new URL(url).origin;
|
|
@@ -22161,7 +23233,7 @@ async function runScan(url, onProgress, pageOverrides, signal) {
|
|
|
22161
23233
|
}
|
|
22162
23234
|
logger.debug({ url, domain }, "[orchestrator] Starting runScan");
|
|
22163
23235
|
signal?.throwIfAborted();
|
|
22164
|
-
|
|
23236
|
+
tracker.scanStart(displayUrl);
|
|
22165
23237
|
const rootFilePaths = [
|
|
22166
23238
|
"/robots.txt",
|
|
22167
23239
|
"/llms.txt",
|
|
@@ -22180,6 +23252,7 @@ async function runScan(url, onProgress, pageOverrides, signal) {
|
|
|
22180
23252
|
"/.well-known/ai-plugin.json",
|
|
22181
23253
|
"/.well-known/webmcp",
|
|
22182
23254
|
"/.well-known/security.txt",
|
|
23255
|
+
"/.well-known/tdmrep.json",
|
|
22183
23256
|
"/navigation.json",
|
|
22184
23257
|
"/privacy-policy/",
|
|
22185
23258
|
"/privacy/",
|
|
@@ -22198,19 +23271,26 @@ async function runScan(url, onProgress, pageOverrides, signal) {
|
|
|
22198
23271
|
"/our-story"
|
|
22199
23272
|
];
|
|
22200
23273
|
logger.debug({ count: rootFilePaths.length }, "[orchestrator] Phase 1: Fetching root files");
|
|
23274
|
+
tracker.phaseStart("fetch-root", rootFilePaths.length);
|
|
22201
23275
|
const rootResults = await Promise.all(
|
|
22202
|
-
rootFilePaths.map(
|
|
23276
|
+
rootFilePaths.map(
|
|
23277
|
+
(path) => fetcher.fetch({ url: `${baseUrl}${path}`, signal }).then((result) => {
|
|
23278
|
+
tracker.unitDone(path);
|
|
23279
|
+
return result;
|
|
23280
|
+
})
|
|
23281
|
+
)
|
|
22203
23282
|
);
|
|
22204
23283
|
const rootFiles = {};
|
|
22205
23284
|
rootFilePaths.forEach((path, i) => {
|
|
22206
23285
|
rootFiles[path] = rootResults[i];
|
|
22207
23286
|
});
|
|
22208
|
-
|
|
23287
|
+
tracker.phaseDone();
|
|
22209
23288
|
logger.debug("[orchestrator] Phase 1 complete: Root files fetched");
|
|
22210
23289
|
signal?.throwIfAborted();
|
|
22211
|
-
await progress(30, "Fetching pages");
|
|
22212
23290
|
logger.debug("[orchestrator] Phase 2: Fetching pages");
|
|
23291
|
+
tracker.phaseStart("fetch-pages", 1);
|
|
22213
23292
|
const homepageResult = await fetcher.fetch({ url, signal });
|
|
23293
|
+
tracker.unitDone(displayUrl);
|
|
22214
23294
|
const homepage$ = homepageResult.status === 200 && homepageResult.body ? parseHtml(homepageResult.body) : null;
|
|
22215
23295
|
const discoverLimit = Math.max(0, MAX_PAGES_PER_SCAN - 1 - overrideUrls.length);
|
|
22216
23296
|
const discoveredUrls = homepage$ ? discoverPages(url, domain, rootFiles, homepage$, new Set(overrideTypeByKey.keys()), discoverLimit) : [];
|
|
@@ -22219,12 +23299,22 @@ async function runScan(url, onProgress, pageOverrides, signal) {
|
|
|
22219
23299
|
"[orchestrator] Page set: overrides + discovered URLs"
|
|
22220
23300
|
);
|
|
22221
23301
|
const extraUrls = [...overrideUrls, ...discoveredUrls];
|
|
22222
|
-
|
|
23302
|
+
tracker.setPhaseTotal(1 + extraUrls.length);
|
|
22223
23303
|
const extraResults = await Promise.all(
|
|
22224
|
-
extraUrls.map(
|
|
23304
|
+
extraUrls.map(
|
|
23305
|
+
(pageUrl) => fetcher.fetch({ url: pageUrl, signal }).then((result) => {
|
|
23306
|
+
tracker.unitDone(pageUrl);
|
|
23307
|
+
return result;
|
|
23308
|
+
})
|
|
23309
|
+
)
|
|
22225
23310
|
);
|
|
23311
|
+
tracker.phaseDone();
|
|
22226
23312
|
const allPageResults = [homepageResult, ...extraResults];
|
|
22227
23313
|
const allPageUrls = [displayUrl, ...extraUrls];
|
|
23314
|
+
tracker.phaseStart(
|
|
23315
|
+
"analyze",
|
|
23316
|
+
allPageResults.filter((r) => r.status === 200 && r.body).length
|
|
23317
|
+
);
|
|
22228
23318
|
const pages = allPageResults.map((r, i) => ({ result: r, url: allPageUrls[i], index: i })).filter((p) => p.result.status === 200 && p.result.body).map((p) => {
|
|
22229
23319
|
const $ = parseHtml(p.result.body);
|
|
22230
23320
|
const jsonLd = extractJsonLd($);
|
|
@@ -22232,6 +23322,7 @@ async function runScan(url, onProgress, pageOverrides, signal) {
|
|
|
22232
23322
|
const meta = extractMetaTags($);
|
|
22233
23323
|
const isFirstPage = p.index === 0;
|
|
22234
23324
|
const forcedType = overrideTypeByKey.get(p.url.replace(/\/$/, ""));
|
|
23325
|
+
tracker.unitDone(p.url);
|
|
22235
23326
|
return {
|
|
22236
23327
|
url: p.url,
|
|
22237
23328
|
pageType: forcedType ?? detectPageType(p.url, $, structuredData, meta, isFirstPage),
|
|
@@ -22249,13 +23340,12 @@ async function runScan(url, onProgress, pageOverrides, signal) {
|
|
|
22249
23340
|
p.a11yResults = await runA11yForHtml(p.fetchResult.body, p.url, A11Y_RULES);
|
|
22250
23341
|
})
|
|
22251
23342
|
);
|
|
22252
|
-
|
|
23343
|
+
tracker.phaseDone();
|
|
22253
23344
|
logger.debug(
|
|
22254
23345
|
{ pagesAnalyzed: pages.length },
|
|
22255
23346
|
"[orchestrator] Phase 2 complete: Page analysis complete"
|
|
22256
23347
|
);
|
|
22257
23348
|
signal?.throwIfAborted();
|
|
22258
|
-
await progress(60, "Running audits");
|
|
22259
23349
|
logger.debug("[orchestrator] Phase 3: Running audits");
|
|
22260
23350
|
const wafProtection = detectWafProtection(url, homepageResult, rootFiles, pages.length);
|
|
22261
23351
|
const ctx = {
|
|
@@ -22263,19 +23353,27 @@ async function runScan(url, onProgress, pageOverrides, signal) {
|
|
|
22263
23353
|
pages,
|
|
22264
23354
|
domain,
|
|
22265
23355
|
baseUrl,
|
|
22266
|
-
fetch: (
|
|
23356
|
+
fetch: (options2) => fetcher.fetch({ ...options2, signal }),
|
|
22267
23357
|
wafProtection: wafProtection ?? void 0
|
|
22268
23358
|
};
|
|
23359
|
+
const auditPlan = planAudits(ctx, defaultConfig);
|
|
23360
|
+
tracker.phaseStart("audits", auditPlan.runnable.length);
|
|
22269
23361
|
const {
|
|
22270
23362
|
checks: allChecks,
|
|
22271
23363
|
categories,
|
|
22272
23364
|
overallScore
|
|
22273
|
-
} = await runAudits(
|
|
22274
|
-
|
|
22275
|
-
|
|
22276
|
-
|
|
23365
|
+
} = await runAudits(
|
|
23366
|
+
ctx,
|
|
23367
|
+
defaultConfig,
|
|
23368
|
+
(event) => {
|
|
23369
|
+
if (event.type === "unit:done") tracker.unitDone(event.label);
|
|
23370
|
+
else tracker.unitFail(event.label, event.error);
|
|
23371
|
+
},
|
|
23372
|
+
auditPlan
|
|
23373
|
+
);
|
|
23374
|
+
tracker.phaseDone();
|
|
22277
23375
|
logger.debug("[orchestrator] Phase 3 complete: Audits finished");
|
|
22278
|
-
|
|
23376
|
+
tracker.phaseStart("report", 1);
|
|
22279
23377
|
logger.debug("[orchestrator] Phase 4: Building final report");
|
|
22280
23378
|
const durationMs = Math.round(performance.now() - start);
|
|
22281
23379
|
const recommendations = allChecks.filter((c) => c.status !== "pass").slice().sort((a, b) => {
|
|
@@ -22292,7 +23390,6 @@ async function runScan(url, onProgress, pageOverrides, signal) {
|
|
|
22292
23390
|
const topPasses = allChecks.filter((c) => c.status === "pass").slice().sort(
|
|
22293
23391
|
(a, b) => (weightMap.get(b.id) ?? 1) - (weightMap.get(a.id) ?? 1)
|
|
22294
23392
|
).slice(0, 10);
|
|
22295
|
-
await progress(100, "Complete");
|
|
22296
23393
|
const readinessVitals = calculateReadinessVitals(allChecks);
|
|
22297
23394
|
const readinessScore = Math.round(
|
|
22298
23395
|
readinessVitals.commerce * READINESS_WEIGHTS.commerce + readinessVitals.content * READINESS_WEIGHTS.content + readinessVitals.botAccessibility * READINESS_WEIGHTS.botAccessibility + readinessVitals.technical * READINESS_WEIGHTS.technical
|
|
@@ -22322,6 +23419,9 @@ async function runScan(url, onProgress, pageOverrides, signal) {
|
|
|
22322
23419
|
productFields: [...overrideTypeByKey.values()].includes("product") ? extractProductFieldVerification(pages) : void 0
|
|
22323
23420
|
};
|
|
22324
23421
|
report.summary = generateScanSummary(report);
|
|
23422
|
+
tracker.unitDone();
|
|
23423
|
+
tracker.phaseDone();
|
|
23424
|
+
tracker.scanDone(overallScore);
|
|
22325
23425
|
logger.debug({ durationMs, score: overallScore }, "[orchestrator] runScan complete");
|
|
22326
23426
|
return report;
|
|
22327
23427
|
}
|
|
@@ -22337,7 +23437,7 @@ function calculateReadinessVitals(checks2) {
|
|
|
22337
23437
|
return Math.round(matching.reduce((sum, c) => sum + c.score, 0) / matching.length * 100);
|
|
22338
23438
|
};
|
|
22339
23439
|
return {
|
|
22340
|
-
commerce: getScore(["3.8", "3.14", "3.21", "3.22", "3.23"]),
|
|
23440
|
+
commerce: getScore(["3.8", "3.14", "3.21", "3.22", "3.23", "3.24"]),
|
|
22341
23441
|
content: getScore([
|
|
22342
23442
|
"1.1",
|
|
22343
23443
|
"1.2",
|
|
@@ -22507,7 +23607,9 @@ function loadConfigFile(customPath) {
|
|
|
22507
23607
|
MAX_PAGES_PER_SCAN,
|
|
22508
23608
|
MAX_RESPONSE_BODY_BYTES,
|
|
22509
23609
|
PAGE_TYPE_LABELS,
|
|
23610
|
+
PHASE_WEIGHTS,
|
|
22510
23611
|
PRESETS,
|
|
23612
|
+
ProgressTracker,
|
|
22511
23613
|
READINESS_WEIGHTS,
|
|
22512
23614
|
REQUEST_TIMEOUT_MS,
|
|
22513
23615
|
SCANNER_USER_AGENT,
|
|
@@ -22551,6 +23653,7 @@ function loadConfigFile(customPath) {
|
|
|
22551
23653
|
logger,
|
|
22552
23654
|
normalizeUrl,
|
|
22553
23655
|
parseHtml,
|
|
23656
|
+
planAudits,
|
|
22554
23657
|
runAudits,
|
|
22555
23658
|
runScan
|
|
22556
23659
|
});
|