lighthouse 13.5.0-dev.20261008 → 13.5.0-dev.20261012
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/.agents/skills/update-ard-port/SKILL.md +170 -0
- package/cli/cli-flags.js +0 -1
- package/cli/sentry-prompt.js +3 -4
- package/cli/test/smokehouse/config/exclusions.js +1 -1
- package/core/audits/agentic/ard-schema.d.ts +13 -0
- package/core/audits/agentic/ard-schema.js +81 -33
- package/core/audits/agentic/llms-txt.js +5 -2
- package/core/audits/long-tasks.js +2 -1
- package/core/audits/screenshot-thumbnails.d.ts +0 -13
- package/core/audits/screenshot-thumbnails.js +0 -45
- package/core/audits/seo/robots-txt.js +3 -8
- package/core/audits/unsized-images.d.ts +0 -7
- package/core/audits/unsized-images.js +7 -26
- package/core/computed/page-dependency-graph.js +0 -4
- package/core/computed/speedline.js +20 -5
- package/core/gather/gatherers/agentic/ard.d.ts +59 -4
- package/core/gather/gatherers/agentic/ard.js +218 -56
- package/core/gather/gatherers/image-elements.d.ts +7 -0
- package/core/gather/gatherers/image-elements.js +75 -24
- package/dist/report/flow.js +13 -13
- package/package.json +5 -8
- package/shared/localization/locales/en-US.json +13 -4
- package/shared/localization/locales/en-XL.json +13 -4
- package/third-party/ard/README.md +18 -9
- package/third-party/ard/ard-test.js +183 -266
- package/third-party/ard/ard.d.ts +17 -1
- package/third-party/ard/ard.js +276 -45
- package/third-party/ard/schema-validator.d.ts +14 -5
- package/third-party/ard/schema-validator.js +921 -1177
- package/third-party/ard/spec/schemas/ard-entry.schema.json +176 -0
- package/types/artifacts.d.ts +35 -6
- package/build-tracker.config.js +0 -16
- package/third-party/ard/spec/schemas/ai-catalog.schema.json +0 -292
|
@@ -36,10 +36,6 @@ class PageDependencyGraph {
|
|
|
36
36
|
return graph;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
// TODO: currently the trace version has no requests that failed, or requests that have "Preflight".
|
|
40
|
-
// so the following gets the devtools log version _closer_ to the exact same results as the trace.
|
|
41
|
-
// const lanternRequests = networkRecords.map(NetworkRequest.asLanternNetworkRequest).filter(r => !r.failed && r.resourceType !== 'Preflight');
|
|
42
|
-
|
|
43
39
|
const lanternRequests = networkRecords.map(NetworkRequest.asLanternNetworkRequest);
|
|
44
40
|
return Lantern.Graph.PageDependencyGraph.createGraph(mainThreadEvents, lanternRequests, URL);
|
|
45
41
|
}
|
|
@@ -20,13 +20,10 @@ class Speedline {
|
|
|
20
20
|
// speedline() may throw without a promise, so we resolve immediately
|
|
21
21
|
// to get in a promise chain.
|
|
22
22
|
return ProcessedTrace.request(trace, context).then(processedTrace => {
|
|
23
|
-
// Use a shallow copy of traceEvents so speedline can sort as it pleases.
|
|
24
|
-
// See https://github.com/GoogleChrome/lighthouse/issues/2333
|
|
25
|
-
const traceEvents = trace.traceEvents.slice();
|
|
26
23
|
// Force use of timeOrigin as reference point for speedline
|
|
27
24
|
// See https://github.com/GoogleChrome/lighthouse/issues/2095
|
|
28
25
|
const timeOrigin = processedTrace.timestamps.timeOrigin;
|
|
29
|
-
return speedline(traceEvents, {
|
|
26
|
+
return speedline(trace.traceEvents, {
|
|
30
27
|
timeOrigin,
|
|
31
28
|
fastMode: true,
|
|
32
29
|
include: 'speedIndex',
|
|
@@ -46,7 +43,25 @@ class Speedline {
|
|
|
46
43
|
throw new LighthouseError(LighthouseError.errors.SPEEDINDEX_OF_ZERO);
|
|
47
44
|
}
|
|
48
45
|
|
|
49
|
-
|
|
46
|
+
// Evict the ~1 MB per-frame decoded RGBA buffers and histograms cached inside
|
|
47
|
+
// speedline-core's frame closures, keeping only the getters Lighthouse uses.
|
|
48
|
+
// TODO(https://github.com/paulirish/speedline/issues/122): Upstream to speedline-core
|
|
49
|
+
// so `frame()` in `lib/frame.js` does not cache `_parsedImage` on frame closures.
|
|
50
|
+
const frames = speedline.frames.map(frame => {
|
|
51
|
+
const imgBuff = frame.getImage();
|
|
52
|
+
const timeStamp = frame.getTimeStamp();
|
|
53
|
+
const isProgressInterpolated = frame.isProgressInterpolated();
|
|
54
|
+
return {
|
|
55
|
+
getImage: () => imgBuff,
|
|
56
|
+
getTimeStamp: () => timeStamp,
|
|
57
|
+
isProgressInterpolated: () => isProgressInterpolated,
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
...speedline,
|
|
63
|
+
frames,
|
|
64
|
+
};
|
|
50
65
|
});
|
|
51
66
|
}
|
|
52
67
|
}
|
|
@@ -1,26 +1,81 @@
|
|
|
1
1
|
export default AgentResourceDiscovery;
|
|
2
|
+
export type ArdLinks = {
|
|
3
|
+
ard: string | null;
|
|
4
|
+
legacy: string | null;
|
|
5
|
+
};
|
|
6
|
+
export type DiscoverySignals = LH.Artifacts["AgentResourceDiscovery"]["discoverySignals"];
|
|
7
|
+
export type FetchResult = {
|
|
8
|
+
status: number | null;
|
|
9
|
+
content: string | null;
|
|
10
|
+
headers: Record<string, string> | null;
|
|
11
|
+
errorMessage?: string | undefined;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* A location to try when resolving the manifest. `advertised` locations are ones the site
|
|
15
|
+
* points at (robots.txt, link tags, Link headers); the well-known paths are probed by
|
|
16
|
+
* Lighthouse, so a failure there is not the site's fault and is not reported.
|
|
17
|
+
*/
|
|
18
|
+
export type ManifestCandidate = {
|
|
19
|
+
source: LH.Artifacts.ArdDiscoverySource;
|
|
20
|
+
url: string | null;
|
|
21
|
+
advertised: boolean;
|
|
22
|
+
};
|
|
2
23
|
declare class AgentResourceDiscovery extends BaseGatherer {
|
|
3
24
|
/**
|
|
25
|
+
* Returns the absolute URL from the first `Agentmap:` directive in robots.txt, if any.
|
|
4
26
|
* @param {LH.Artifacts['RobotsTxt']|null|undefined} robotsTxt
|
|
5
27
|
* @param {string} finalDisplayedUrl
|
|
6
28
|
* @return {string|null}
|
|
7
29
|
*/
|
|
8
30
|
static getRobotsTxtAgentmap(robotsTxt: LH.Artifacts["RobotsTxt"] | null | undefined, finalDisplayedUrl: string): string | null;
|
|
9
31
|
/**
|
|
32
|
+
* Finds `<link rel="ard">` and `<link rel="ai-catalog">` in the page.
|
|
10
33
|
* @param {LH.Gatherer.Context} context
|
|
11
34
|
* @param {string} finalDisplayedUrl
|
|
12
|
-
* @return {Promise<
|
|
35
|
+
* @return {Promise<ArdLinks>}
|
|
13
36
|
*/
|
|
14
|
-
static
|
|
37
|
+
static getHtmlLinksFromDom(context: LH.Gatherer.Context, finalDisplayedUrl: string): Promise<ArdLinks>;
|
|
15
38
|
/**
|
|
39
|
+
* Finds `rel="ard"` and `rel="ai-catalog"` in the main document's HTTP `Link` header.
|
|
16
40
|
* @param {LH.Gatherer.Context} context
|
|
17
41
|
* @param {string} finalDisplayedUrl
|
|
18
|
-
* @return {Promise<
|
|
42
|
+
* @return {Promise<ArdLinks>}
|
|
19
43
|
*/
|
|
20
|
-
static
|
|
44
|
+
static getHttpHeaderLinks(context: LH.Gatherer.Context, finalDisplayedUrl: string): Promise<ArdLinks>;
|
|
45
|
+
/**
|
|
46
|
+
* Fetches a candidate manifest URL. Never throws; network errors are returned as a
|
|
47
|
+
* null status with an `errorMessage`.
|
|
48
|
+
* @param {LH.Gatherer.Context} context
|
|
49
|
+
* @param {string} url
|
|
50
|
+
* @return {Promise<FetchResult>}
|
|
51
|
+
*/
|
|
52
|
+
static fetchManifest(context: LH.Gatherer.Context, url: string): Promise<FetchResult>;
|
|
53
|
+
/**
|
|
54
|
+
* Tries each candidate in order and returns the first that loads. Advertised candidates
|
|
55
|
+
* that fail are recorded in `failedSources` and skipped. If nothing loads, the result
|
|
56
|
+
* describes the first advertised location that failed, or else the `/.well-known/ard.json`
|
|
57
|
+
* path consumers are required to fetch.
|
|
58
|
+
* @param {LH.Gatherer.Context} context
|
|
59
|
+
* @param {ManifestCandidate[]} candidates
|
|
60
|
+
* @return {Promise<{
|
|
61
|
+
* discoverySource: LH.Artifacts.ArdDiscoverySource,
|
|
62
|
+
* catalogUrl: string,
|
|
63
|
+
* fetchResult: FetchResult,
|
|
64
|
+
* failedSources: LH.Artifacts['AgentResourceDiscovery']['failedSources'],
|
|
65
|
+
* }>}
|
|
66
|
+
*/
|
|
67
|
+
static resolveManifest(context: LH.Gatherer.Context, candidates: ManifestCandidate[]): Promise<{
|
|
68
|
+
discoverySource: LH.Artifacts.ArdDiscoverySource;
|
|
69
|
+
catalogUrl: string;
|
|
70
|
+
fetchResult: FetchResult;
|
|
71
|
+
failedSources: LH.Artifacts["AgentResourceDiscovery"]["failedSources"];
|
|
72
|
+
}>;
|
|
21
73
|
/** @type {LH.Gatherer.GathererMeta<'RobotsTxt'>} */
|
|
22
74
|
meta: LH.Gatherer.GathererMeta<"RobotsTxt">;
|
|
23
75
|
/**
|
|
76
|
+
* Resolves the manifest following ARD §5.1: explicit ARD signals first, then the
|
|
77
|
+
* `/.well-known/ard.json` path consumers MUST fetch, and only then the predecessor
|
|
78
|
+
* `ai-catalog` relation and well-known path, which consumers MAY consult.
|
|
24
79
|
* @param {LH.Gatherer.Context<'RobotsTxt'>} context
|
|
25
80
|
* @return {Promise<LH.Artifacts['AgentResourceDiscovery']>}
|
|
26
81
|
*/
|
|
@@ -4,17 +4,112 @@
|
|
|
4
4
|
* SPDX-License-Identifier: Apache-2.0
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* @fileoverview Gatherer that discovers and fetches a site's Agentic Resource Discovery (ARD)
|
|
9
|
+
* manifest. It follows the discovery mechanisms in ARD §5.1 and also consults the predecessor
|
|
10
|
+
* `ai-catalog` names so that sites that have not yet migrated are still audited.
|
|
11
|
+
* @see https://agenticresourcediscovery.org/spec/
|
|
12
|
+
*/
|
|
13
|
+
|
|
7
14
|
import log from 'lighthouse-logger';
|
|
8
15
|
import LinkHeader from 'http-link-header';
|
|
9
16
|
|
|
10
17
|
import BaseGatherer from '../../base-gatherer.js';
|
|
11
18
|
import RobotsTxt from '../seo/robots-txt.js';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Link relations for ARD entry sources. `ai-catalog` is the predecessor relation, which
|
|
22
|
+
* consumers MAY consult but are not required to (ARD §5.1).
|
|
23
|
+
*/
|
|
24
|
+
const REL_ARD = 'ard';
|
|
25
|
+
const REL_LEGACY = 'ai-catalog';
|
|
26
|
+
|
|
27
|
+
const WELL_KNOWN_PATH = '/.well-known/ard.json';
|
|
28
|
+
const LEGACY_WELL_KNOWN_PATH = '/.well-known/ai-catalog.json';
|
|
29
|
+
|
|
30
|
+
const HTTP_OK = 200;
|
|
31
|
+
|
|
32
|
+
/** @typedef {{ard: string|null, legacy: string|null}} ArdLinks */
|
|
33
|
+
|
|
34
|
+
/** @typedef {LH.Artifacts['AgentResourceDiscovery']['discoverySignals']} DiscoverySignals */
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef FetchResult
|
|
38
|
+
* @property {number|null} status
|
|
39
|
+
* @property {string|null} content
|
|
40
|
+
* @property {Record<string, string>|null} headers
|
|
41
|
+
* @property {string} [errorMessage]
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A location to try when resolving the manifest. `advertised` locations are ones the site
|
|
46
|
+
* points at (robots.txt, link tags, Link headers); the well-known paths are probed by
|
|
47
|
+
* Lighthouse, so a failure there is not the site's fault and is not reported.
|
|
48
|
+
* @typedef ManifestCandidate
|
|
49
|
+
* @property {LH.Artifacts.ArdDiscoverySource} source
|
|
50
|
+
* @property {string|null} url
|
|
51
|
+
* @property {boolean} advertised
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/** @type {ReadonlyArray<LH.Artifacts.ArdDiscoverySource>} */
|
|
55
|
+
const SOURCE_PRIORITY = [
|
|
56
|
+
'robotsTxtAgentmap',
|
|
57
|
+
'htmlLink',
|
|
58
|
+
'httpHeaderLink',
|
|
59
|
+
'wellKnown',
|
|
60
|
+
'legacyHtmlLink',
|
|
61
|
+
'legacyHttpHeaderLink',
|
|
62
|
+
'legacyWellKnown',
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** @type {Set<LH.Artifacts.ArdDiscoverySource>} */
|
|
66
|
+
const PROBED_SOURCES = new Set(['wellKnown', 'legacyWellKnown']);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {DiscoverySignals} discoverySignals
|
|
70
|
+
* @return {ManifestCandidate[]}
|
|
71
|
+
*/
|
|
72
|
+
function getManifestCandidates(discoverySignals) {
|
|
73
|
+
return SOURCE_PRIORITY.map(source => ({
|
|
74
|
+
source,
|
|
75
|
+
url: discoverySignals[source],
|
|
76
|
+
advertised: !PROBED_SOURCES.has(source),
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolves `href` against `baseUrl`, returning null for empty or unparseable values.
|
|
82
|
+
* @param {string|null|undefined} href
|
|
83
|
+
* @param {string} baseUrl
|
|
84
|
+
* @return {string|null}
|
|
85
|
+
*/
|
|
86
|
+
function resolveUrl(href, baseUrl) {
|
|
87
|
+
if (!href) return null;
|
|
88
|
+
try {
|
|
89
|
+
return new URL(href, baseUrl).href;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Runs in the page. Returns the `href` of the first `<link>` for each relation, or null.
|
|
97
|
+
* @param {string} relArd
|
|
98
|
+
* @param {string} relLegacy
|
|
99
|
+
* @return {ArdLinks}
|
|
100
|
+
*/
|
|
101
|
+
function getArdLinksInDOM(relArd, relLegacy) {
|
|
102
|
+
/** @param {string} rel */
|
|
103
|
+
const getHref = rel => {
|
|
104
|
+
const link = document.querySelector(`link[rel~="${rel}" i]`);
|
|
105
|
+
return link instanceof HTMLLinkElement ? link.href : null;
|
|
106
|
+
};
|
|
107
|
+
return {ard: getHref(relArd), legacy: getHref(relLegacy)};
|
|
16
108
|
}
|
|
17
109
|
|
|
110
|
+
/** @type {ArdLinks} */
|
|
111
|
+
const NO_LINKS = {ard: null, legacy: null};
|
|
112
|
+
|
|
18
113
|
class AgentResourceDiscovery extends BaseGatherer {
|
|
19
114
|
/** @type {LH.Gatherer.GathererMeta<'RobotsTxt'>} */
|
|
20
115
|
meta = {
|
|
@@ -23,6 +118,7 @@ class AgentResourceDiscovery extends BaseGatherer {
|
|
|
23
118
|
};
|
|
24
119
|
|
|
25
120
|
/**
|
|
121
|
+
* Returns the absolute URL from the first `Agentmap:` directive in robots.txt, if any.
|
|
26
122
|
* @param {LH.Artifacts['RobotsTxt']|null|undefined} robotsTxt
|
|
27
123
|
* @param {string} finalDisplayedUrl
|
|
28
124
|
* @return {string|null}
|
|
@@ -31,93 +127,159 @@ class AgentResourceDiscovery extends BaseGatherer {
|
|
|
31
127
|
if (!robotsTxt?.content) return null;
|
|
32
128
|
const match = robotsTxt.content.match(/^\s*Agentmap:\s*(\S+)/im);
|
|
33
129
|
if (!match) return null;
|
|
34
|
-
|
|
35
|
-
return new URL(match[1], finalDisplayedUrl).href;
|
|
36
|
-
} catch {
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
130
|
+
return resolveUrl(match[1], finalDisplayedUrl);
|
|
39
131
|
}
|
|
40
132
|
|
|
41
133
|
/**
|
|
134
|
+
* Finds `<link rel="ard">` and `<link rel="ai-catalog">` in the page.
|
|
42
135
|
* @param {LH.Gatherer.Context} context
|
|
43
136
|
* @param {string} finalDisplayedUrl
|
|
44
|
-
* @return {Promise<
|
|
137
|
+
* @return {Promise<ArdLinks>}
|
|
45
138
|
*/
|
|
46
|
-
static async
|
|
139
|
+
static async getHtmlLinksFromDom(context, finalDisplayedUrl) {
|
|
47
140
|
try {
|
|
48
|
-
const
|
|
49
|
-
args: [],
|
|
141
|
+
const links = await context.driver.executionContext.evaluate(getArdLinksInDOM, {
|
|
142
|
+
args: [REL_ARD, REL_LEGACY],
|
|
50
143
|
useIsolation: true,
|
|
51
|
-
deps: [pageFunctions.getNodeDetails],
|
|
52
144
|
});
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
145
|
+
return {
|
|
146
|
+
ard: resolveUrl(links?.ard, finalDisplayedUrl),
|
|
147
|
+
legacy: resolveUrl(links?.legacy, finalDisplayedUrl),
|
|
148
|
+
};
|
|
149
|
+
} catch (err) {
|
|
150
|
+
log.verbose('AgentResourceDiscovery', `Could not read link tags: ${err.message}`);
|
|
151
|
+
return NO_LINKS;
|
|
57
152
|
}
|
|
58
153
|
}
|
|
59
154
|
|
|
60
155
|
/**
|
|
156
|
+
* Finds `rel="ard"` and `rel="ai-catalog"` in the main document's HTTP `Link` header.
|
|
61
157
|
* @param {LH.Gatherer.Context} context
|
|
62
158
|
* @param {string} finalDisplayedUrl
|
|
63
|
-
* @return {Promise<
|
|
159
|
+
* @return {Promise<ArdLinks>}
|
|
64
160
|
*/
|
|
65
|
-
static async
|
|
66
|
-
|
|
67
|
-
if (context.gatherMode !== 'navigation') return null;
|
|
161
|
+
static async getHttpHeaderLinks(context, finalDisplayedUrl) {
|
|
162
|
+
if (context.gatherMode !== 'navigation') return NO_LINKS;
|
|
68
163
|
|
|
69
164
|
try {
|
|
70
165
|
const mainResourceResponse = await context.driver.fetcher.fetchResource(finalDisplayedUrl);
|
|
71
|
-
const linkHeader = mainResourceResponse.headers?.
|
|
72
|
-
if (!linkHeader) return
|
|
166
|
+
const linkHeader = mainResourceResponse.headers?.link;
|
|
167
|
+
if (!linkHeader) return NO_LINKS;
|
|
168
|
+
|
|
169
|
+
const parsed = LinkHeader.parse(linkHeader.replace(/\n/g, ','));
|
|
170
|
+
/** @param {string} rel */
|
|
171
|
+
const getUri = rel =>
|
|
172
|
+
resolveUrl(parsed.refs.find(ref => ref.rel?.toLowerCase() === rel)?.uri, finalDisplayedUrl);
|
|
173
|
+
return {ard: getUri(REL_ARD), legacy: getUri(REL_LEGACY)};
|
|
174
|
+
} catch (err) {
|
|
175
|
+
log.verbose('AgentResourceDiscovery', `Could not read Link header: ${err.message}`);
|
|
176
|
+
return NO_LINKS;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Fetches a candidate manifest URL. Never throws; network errors are returned as a
|
|
182
|
+
* null status with an `errorMessage`.
|
|
183
|
+
* @param {LH.Gatherer.Context} context
|
|
184
|
+
* @param {string} url
|
|
185
|
+
* @return {Promise<FetchResult>}
|
|
186
|
+
*/
|
|
187
|
+
static async fetchManifest(context, url) {
|
|
188
|
+
try {
|
|
189
|
+
const {status, content, headers} = await context.driver.fetcher.fetchResource(url);
|
|
190
|
+
return {status, content, headers: headers || null};
|
|
191
|
+
} catch (err) {
|
|
192
|
+
log.error('AgentResourceDiscovery', err);
|
|
193
|
+
return {status: null, content: null, headers: null, errorMessage: err.message};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
73
196
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
197
|
+
/**
|
|
198
|
+
* Tries each candidate in order and returns the first that loads. Advertised candidates
|
|
199
|
+
* that fail are recorded in `failedSources` and skipped. If nothing loads, the result
|
|
200
|
+
* describes the first advertised location that failed, or else the `/.well-known/ard.json`
|
|
201
|
+
* path consumers are required to fetch.
|
|
202
|
+
* @param {LH.Gatherer.Context} context
|
|
203
|
+
* @param {ManifestCandidate[]} candidates
|
|
204
|
+
* @return {Promise<{
|
|
205
|
+
* discoverySource: LH.Artifacts.ArdDiscoverySource,
|
|
206
|
+
* catalogUrl: string,
|
|
207
|
+
* fetchResult: FetchResult,
|
|
208
|
+
* failedSources: LH.Artifacts['AgentResourceDiscovery']['failedSources'],
|
|
209
|
+
* }>}
|
|
210
|
+
*/
|
|
211
|
+
static async resolveManifest(context, candidates) {
|
|
212
|
+
/** @type {Array<{source: LH.Artifacts.ArdDiscoverySource, url: string, advertised: boolean, fetchResult: FetchResult}>} */
|
|
213
|
+
const attempts = [];
|
|
214
|
+
const failedSources = () => attempts
|
|
215
|
+
.filter(attempt => attempt.advertised)
|
|
216
|
+
.map(({source, url, fetchResult}) => ({
|
|
217
|
+
source,
|
|
218
|
+
url,
|
|
219
|
+
status: fetchResult.status,
|
|
220
|
+
errorMessage: fetchResult.errorMessage,
|
|
221
|
+
}));
|
|
222
|
+
|
|
223
|
+
for (const {source, url, advertised} of candidates) {
|
|
224
|
+
if (!url || attempts.some(attempt => attempt.url === url)) continue;
|
|
77
225
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
226
|
+
const fetchResult = await AgentResourceDiscovery.fetchManifest(context, url);
|
|
227
|
+
if (fetchResult.status === HTTP_OK && fetchResult.content) {
|
|
228
|
+
return {discoverySource: source, catalogUrl: url, fetchResult,
|
|
229
|
+
failedSources: failedSources()};
|
|
230
|
+
}
|
|
231
|
+
attempts.push({source, url, advertised, fetchResult});
|
|
81
232
|
}
|
|
233
|
+
|
|
234
|
+
const fallback = attempts.find(attempt => attempt.advertised) ||
|
|
235
|
+
attempts.find(attempt => attempt.source === 'wellKnown');
|
|
236
|
+
if (!fallback) throw new Error('ARD discovery attempted no candidates');
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
discoverySource: fallback.source,
|
|
240
|
+
catalogUrl: fallback.url,
|
|
241
|
+
fetchResult: fallback.fetchResult,
|
|
242
|
+
failedSources: failedSources(),
|
|
243
|
+
};
|
|
82
244
|
}
|
|
83
245
|
|
|
84
246
|
/**
|
|
247
|
+
* Resolves the manifest following ARD §5.1: explicit ARD signals first, then the
|
|
248
|
+
* `/.well-known/ard.json` path consumers MUST fetch, and only then the predecessor
|
|
249
|
+
* `ai-catalog` relation and well-known path, which consumers MAY consult.
|
|
85
250
|
* @param {LH.Gatherer.Context<'RobotsTxt'>} context
|
|
86
251
|
* @return {Promise<LH.Artifacts['AgentResourceDiscovery']>}
|
|
87
252
|
*/
|
|
88
253
|
async getArtifact(context) {
|
|
89
254
|
const {finalDisplayedUrl} = context.baseArtifacts.URL;
|
|
90
|
-
const robotsTxt = context.dependencies.RobotsTxt;
|
|
91
255
|
const robotsTxtAgentmap = AgentResourceDiscovery.getRobotsTxtAgentmap(
|
|
92
|
-
|
|
93
|
-
const [
|
|
94
|
-
AgentResourceDiscovery.
|
|
95
|
-
AgentResourceDiscovery.
|
|
256
|
+
context.dependencies.RobotsTxt, finalDisplayedUrl);
|
|
257
|
+
const [htmlLinks, httpHeaderLinks] = await Promise.all([
|
|
258
|
+
AgentResourceDiscovery.getHtmlLinksFromDom(context, finalDisplayedUrl),
|
|
259
|
+
AgentResourceDiscovery.getHttpHeaderLinks(context, finalDisplayedUrl),
|
|
96
260
|
]);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
261
|
+
|
|
262
|
+
/** @type {DiscoverySignals} */
|
|
263
|
+
const discoverySignals = {
|
|
264
|
+
robotsTxtAgentmap,
|
|
265
|
+
htmlLink: htmlLinks.ard,
|
|
266
|
+
httpHeaderLink: httpHeaderLinks.ard,
|
|
267
|
+
legacyHtmlLink: htmlLinks.legacy,
|
|
268
|
+
legacyHttpHeaderLink: httpHeaderLinks.legacy,
|
|
269
|
+
wellKnown: new URL(WELL_KNOWN_PATH, finalDisplayedUrl).href,
|
|
270
|
+
legacyWellKnown: new URL(LEGACY_WELL_KNOWN_PATH, finalDisplayedUrl).href,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const candidates = getManifestCandidates(discoverySignals);
|
|
274
|
+
const {discoverySource, catalogUrl, fetchResult, failedSources} =
|
|
275
|
+
await AgentResourceDiscovery.resolveManifest(context, candidates);
|
|
108
276
|
|
|
109
277
|
return {
|
|
110
|
-
|
|
111
|
-
content: fetchResult.content,
|
|
112
|
-
headers: fetchResult.headers || null,
|
|
278
|
+
...fetchResult,
|
|
113
279
|
catalogUrl,
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
httpHeaderLink,
|
|
118
|
-
wellKnown,
|
|
119
|
-
},
|
|
120
|
-
errorMessage,
|
|
280
|
+
discoverySource,
|
|
281
|
+
discoverySignals,
|
|
282
|
+
failedSources,
|
|
121
283
|
};
|
|
122
284
|
}
|
|
123
285
|
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export default ImageElements;
|
|
2
2
|
declare class ImageElements extends BaseGatherer {
|
|
3
|
+
/**
|
|
4
|
+
* An img size attribute prevents layout shifts if it is a non-negative integer (incl zero!).
|
|
5
|
+
* @url https://html.spec.whatwg.org/multipage/embedded-content-other.html#dimension-attributes
|
|
6
|
+
* @param {string | null} attrValue
|
|
7
|
+
* @return {boolean}
|
|
8
|
+
*/
|
|
9
|
+
static doesHtmlAttrProvideExplicitSize(attrValue: string | null): boolean;
|
|
3
10
|
/** @type {Map<string, {naturalWidth: number, naturalHeight: number}>} */
|
|
4
11
|
_naturalSizeCache: Map<string, {
|
|
5
12
|
naturalWidth: number;
|
|
@@ -238,6 +238,21 @@ function getEffectiveSizingRule({attributesStyle, inlineStyle, matchedCSSRules},
|
|
|
238
238
|
return null;
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
/**
|
|
242
|
+
* @param {LH.Artifacts.ImageElement} element
|
|
243
|
+
* @return {boolean}
|
|
244
|
+
*/
|
|
245
|
+
function needsSourceRules(element) {
|
|
246
|
+
if (element.isInShadowDOM || element.isCss) return false;
|
|
247
|
+
if (
|
|
248
|
+
ImageElements.doesHtmlAttrProvideExplicitSize(element.attributeWidth) &&
|
|
249
|
+
ImageElements.doesHtmlAttrProvideExplicitSize(element.attributeHeight)
|
|
250
|
+
) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
241
256
|
/**
|
|
242
257
|
* @param {LH.Artifacts.ImageElement} element
|
|
243
258
|
* @return {number}
|
|
@@ -255,6 +270,28 @@ class ImageElements extends BaseGatherer {
|
|
|
255
270
|
supportedModes: ['snapshot', 'timespan', 'navigation'],
|
|
256
271
|
};
|
|
257
272
|
|
|
273
|
+
/**
|
|
274
|
+
* An img size attribute prevents layout shifts if it is a non-negative integer (incl zero!).
|
|
275
|
+
* @url https://html.spec.whatwg.org/multipage/embedded-content-other.html#dimension-attributes
|
|
276
|
+
* @param {string | null} attrValue
|
|
277
|
+
* @return {boolean}
|
|
278
|
+
*/
|
|
279
|
+
static doesHtmlAttrProvideExplicitSize(attrValue) {
|
|
280
|
+
if (!attrValue) return false;
|
|
281
|
+
|
|
282
|
+
// First, superweird edge case of using the positive-sign. The spec _sorta_ says it's valid...
|
|
283
|
+
// https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#rules-for-parsing-integers
|
|
284
|
+
// > Otherwise, if the character is … (+): Advance position to the next character.
|
|
285
|
+
// > (The "+" is ignored, but it is not conforming.)
|
|
286
|
+
// lol. Irrelevant though b/c Chrome (at least) doesn't ignore. It rejects this as a non-conforming value.
|
|
287
|
+
if (attrValue.startsWith('+')) return false;
|
|
288
|
+
|
|
289
|
+
// parseInt isn't exactly the same as the html's spec for parsing integers, but it's close enough
|
|
290
|
+
// https://tc39.es/ecma262/#sec-parseint-string-radix
|
|
291
|
+
const int = parseInt(attrValue, 10);
|
|
292
|
+
return int >= 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
258
295
|
constructor() {
|
|
259
296
|
super();
|
|
260
297
|
/** @type {Map<string, {naturalWidth: number, naturalHeight: number}>} */
|
|
@@ -322,24 +359,32 @@ class ImageElements extends BaseGatherer {
|
|
|
322
359
|
async collectExtraDetails(driver, elements) {
|
|
323
360
|
// Don't do more than 5s of this expensive devtools protocol work. See #11289
|
|
324
361
|
let reachedGatheringBudget = false;
|
|
325
|
-
setTimeout(_ => (reachedGatheringBudget = true), 5000);
|
|
362
|
+
const timeoutId = setTimeout(_ => (reachedGatheringBudget = true), 5000);
|
|
326
363
|
let skippedCount = 0;
|
|
327
364
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
365
|
+
try {
|
|
366
|
+
for (const element of elements) {
|
|
367
|
+
if (reachedGatheringBudget) {
|
|
368
|
+
skippedCount++;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Need source rules to determine if sized via CSS (for unsized-images).
|
|
373
|
+
if (needsSourceRules(element)) {
|
|
374
|
+
await this.fetchSourceRules(
|
|
375
|
+
driver.defaultSession,
|
|
376
|
+
element.node.devtoolsNodePath,
|
|
377
|
+
element
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
// Images within `picture` behave strangely and natural size information isn't accurate,
|
|
381
|
+
// CSS images have no natural size information at all. Try to get the actual size if we can.
|
|
382
|
+
if (element.isPicture || element.isCss || element.srcset) {
|
|
383
|
+
await this.fetchElementWithSizeInformation(driver, element);
|
|
384
|
+
}
|
|
342
385
|
}
|
|
386
|
+
} finally {
|
|
387
|
+
clearTimeout(timeoutId);
|
|
343
388
|
}
|
|
344
389
|
|
|
345
390
|
if (reachedGatheringBudget) {
|
|
@@ -369,11 +414,14 @@ class ImageElements extends BaseGatherer {
|
|
|
369
414
|
],
|
|
370
415
|
});
|
|
371
416
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
417
|
+
const shouldFetchSourceRules = elements.some(needsSourceRules);
|
|
418
|
+
if (shouldFetchSourceRules) {
|
|
419
|
+
await Promise.all([
|
|
420
|
+
session.sendCommand('DOM.enable'),
|
|
421
|
+
session.sendCommand('CSS.enable'),
|
|
422
|
+
session.sendCommand('DOM.getDocument', {depth: -1, pierce: true}),
|
|
423
|
+
]);
|
|
424
|
+
}
|
|
377
425
|
|
|
378
426
|
// Spend our extra details budget on highest impact images.
|
|
379
427
|
// Our best approximation of impact without network records is to use pixel area.
|
|
@@ -381,11 +429,14 @@ class ImageElements extends BaseGatherer {
|
|
|
381
429
|
|
|
382
430
|
await this.collectExtraDetails(context.driver, elements);
|
|
383
431
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
432
|
+
if (shouldFetchSourceRules) {
|
|
433
|
+
await Promise.all([
|
|
434
|
+
session.sendCommand('DOM.disable'),
|
|
435
|
+
session.sendCommand('CSS.disable'),
|
|
436
|
+
]);
|
|
437
|
+
}
|
|
388
438
|
|
|
439
|
+
this._naturalSizeCache.clear();
|
|
389
440
|
return elements;
|
|
390
441
|
}
|
|
391
442
|
}
|