@fulldotdev/scan 0.1.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/README.md +79 -0
- package/bin/fullscan.js +2 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +239 -0
- package/dist/engine/analysis.d.ts +2 -0
- package/dist/engine/analysis.js +747 -0
- package/dist/engine/browser-inspection.d.ts +143 -0
- package/dist/engine/browser-inspection.js +567 -0
- package/dist/engine/browser.d.ts +22 -0
- package/dist/engine/browser.js +629 -0
- package/dist/engine/collect.d.ts +6 -0
- package/dist/engine/collect.js +359 -0
- package/dist/engine/crawl-scope.d.ts +22 -0
- package/dist/engine/crawl-scope.js +145 -0
- package/dist/engine/env.d.ts +1 -0
- package/dist/engine/env.js +3 -0
- package/dist/engine/html.d.ts +307 -0
- package/dist/engine/html.js +645 -0
- package/dist/engine/language.d.ts +13 -0
- package/dist/engine/language.js +75 -0
- package/dist/engine/lighthouse-evidence.d.ts +36 -0
- package/dist/engine/lighthouse-evidence.js +69 -0
- package/dist/engine/lighthouse.d.ts +4 -0
- package/dist/engine/lighthouse.js +284 -0
- package/dist/engine/log.d.ts +1 -0
- package/dist/engine/log.js +4 -0
- package/dist/engine/network.d.ts +53 -0
- package/dist/engine/network.js +296 -0
- package/dist/engine/proxy.d.ts +8 -0
- package/dist/engine/proxy.js +95 -0
- package/dist/engine/run.d.ts +38 -0
- package/dist/engine/run.js +202 -0
- package/dist/engine/select.d.ts +6 -0
- package/dist/engine/select.js +38 -0
- package/dist/engine/site.d.ts +186 -0
- package/dist/engine/site.js +758 -0
- package/dist/engine/srcset.d.ts +1 -0
- package/dist/engine/srcset.js +31 -0
- package/dist/engine/state.d.ts +30 -0
- package/dist/engine/state.js +198 -0
- package/dist/engine/structured-data.d.ts +83 -0
- package/dist/engine/structured-data.js +331 -0
- package/dist/engine/types.d.ts +128 -0
- package/dist/engine/types.js +63 -0
- package/dist/engine.d.ts +1 -0
- package/dist/engine.js +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/report/build.d.ts +377 -0
- package/dist/report/build.js +2263 -0
- package/dist/report/evidence.d.ts +58 -0
- package/dist/report/evidence.js +192 -0
- package/dist/report/rules.d.ts +41 -0
- package/dist/report/rules.js +301 -0
- package/package.json +52 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function srcsetUrls(value: string): string[];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// URLs in srcset may contain commas (for example image-CDN parameters).
|
|
2
|
+
// A URL runs until whitespace; trailing commas terminate a bare candidate.
|
|
3
|
+
export function srcsetUrls(value) {
|
|
4
|
+
const urls = [];
|
|
5
|
+
let at = 0;
|
|
6
|
+
const space = (c) => /[\t\n\f\r ]/.test(c);
|
|
7
|
+
while (at < value.length) {
|
|
8
|
+
while (at < value.length && (space(value[at]) || value[at] === ","))
|
|
9
|
+
at++;
|
|
10
|
+
const start = at;
|
|
11
|
+
while (at < value.length && !space(value[at]))
|
|
12
|
+
at++;
|
|
13
|
+
const token = value.slice(start, at);
|
|
14
|
+
if (!token)
|
|
15
|
+
break;
|
|
16
|
+
urls.push(token.replace(/,+$/, ""));
|
|
17
|
+
if (token.endsWith(","))
|
|
18
|
+
continue;
|
|
19
|
+
let parentheses = 0;
|
|
20
|
+
while (at < value.length) {
|
|
21
|
+
const c = value[at++];
|
|
22
|
+
if (c === "(")
|
|
23
|
+
parentheses++;
|
|
24
|
+
if (c === ")")
|
|
25
|
+
parentheses = Math.max(0, parentheses - 1);
|
|
26
|
+
if (c === "," && parentheses === 0)
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return urls.filter(Boolean);
|
|
31
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Artifact, Job, JobKind, JobResult, Observation, Scan, UrlRow } from "./types.js";
|
|
2
|
+
export interface StoredArtifact extends Artifact {
|
|
3
|
+
body: Buffer;
|
|
4
|
+
sha256: string;
|
|
5
|
+
bytes: number;
|
|
6
|
+
}
|
|
7
|
+
export declare class ScanState {
|
|
8
|
+
readonly scan: Scan;
|
|
9
|
+
readonly observations: Map<string, Observation>;
|
|
10
|
+
readonly artifacts: Map<string, StoredArtifact>;
|
|
11
|
+
readonly urls: Map<string, UrlRow>;
|
|
12
|
+
readonly jobs: Job[];
|
|
13
|
+
private readonly jobIndex;
|
|
14
|
+
constructor(scan: Scan);
|
|
15
|
+
enqueue(kind: JobKind, name: string): void;
|
|
16
|
+
next(kinds: JobKind[]): Job | undefined;
|
|
17
|
+
pending(kinds: JobKind[]): number;
|
|
18
|
+
cancelQueued(): number;
|
|
19
|
+
jobCounts(): {
|
|
20
|
+
kind: string;
|
|
21
|
+
status: string;
|
|
22
|
+
count: number;
|
|
23
|
+
}[];
|
|
24
|
+
get(kind: string, name: string): Observation | undefined;
|
|
25
|
+
records(kinds?: string[]): Observation[];
|
|
26
|
+
urlRows(): UrlRow[];
|
|
27
|
+
hasUsableMarkdown(): boolean;
|
|
28
|
+
record(result: JobResult): void;
|
|
29
|
+
twinIsPage(url: string): boolean;
|
|
30
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { isPaginationQuery, scopeRule } from "./crawl-scope.js";
|
|
3
|
+
import { slashTwin } from "./network.js";
|
|
4
|
+
const key = (kind, name) => `${kind}\0${name}`;
|
|
5
|
+
// Everything one scan knows, in memory: observations by kind and key,
|
|
6
|
+
// artifacts, the discovered URLs with why they were or were not followed,
|
|
7
|
+
// and the job queue. Collectors return results; `record` admits them and
|
|
8
|
+
// turns candidates into new jobs under the crawl-scope rules.
|
|
9
|
+
export class ScanState {
|
|
10
|
+
scan;
|
|
11
|
+
observations = new Map();
|
|
12
|
+
artifacts = new Map();
|
|
13
|
+
urls = new Map();
|
|
14
|
+
jobs = [];
|
|
15
|
+
jobIndex = new Map();
|
|
16
|
+
constructor(scan) {
|
|
17
|
+
this.scan = scan;
|
|
18
|
+
}
|
|
19
|
+
enqueue(kind, name) {
|
|
20
|
+
const id = key(kind, name);
|
|
21
|
+
if (this.jobIndex.has(id))
|
|
22
|
+
return;
|
|
23
|
+
const job = { kind, key: name, status: "queued", attempts: 0 };
|
|
24
|
+
this.jobs.push(job);
|
|
25
|
+
this.jobIndex.set(id, job);
|
|
26
|
+
}
|
|
27
|
+
next(kinds) {
|
|
28
|
+
for (const kind of kinds) {
|
|
29
|
+
const job = this.jobs.find((j) => j.kind === kind && j.status === "queued");
|
|
30
|
+
if (job)
|
|
31
|
+
return job;
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
pending(kinds) {
|
|
36
|
+
return this.jobs.filter((j) => kinds.includes(j.kind) && j.status === "queued").length;
|
|
37
|
+
}
|
|
38
|
+
cancelQueued() {
|
|
39
|
+
let count = 0;
|
|
40
|
+
for (const job of this.jobs)
|
|
41
|
+
if (job.status === "queued") {
|
|
42
|
+
job.status = "cancelled";
|
|
43
|
+
count++;
|
|
44
|
+
}
|
|
45
|
+
return count;
|
|
46
|
+
}
|
|
47
|
+
jobCounts() {
|
|
48
|
+
const counts = new Map();
|
|
49
|
+
for (const job of this.jobs) {
|
|
50
|
+
const id = `${job.kind}\0${job.status}`;
|
|
51
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
52
|
+
}
|
|
53
|
+
return [...counts].map(([id, count]) => {
|
|
54
|
+
const [kind, status] = id.split("\0");
|
|
55
|
+
return { kind, status, count };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
get(kind, name) {
|
|
59
|
+
return this.observations.get(key(kind, name));
|
|
60
|
+
}
|
|
61
|
+
records(kinds) {
|
|
62
|
+
const all = [...this.observations.values()];
|
|
63
|
+
return kinds ? all.filter((o) => kinds.includes(o.kind)) : all;
|
|
64
|
+
}
|
|
65
|
+
urlRows() {
|
|
66
|
+
return [...this.urls.values()];
|
|
67
|
+
}
|
|
68
|
+
hasUsableMarkdown() {
|
|
69
|
+
return this.records(["markdown"]).some((m) => m.data?.usable);
|
|
70
|
+
}
|
|
71
|
+
record(result) {
|
|
72
|
+
const { scan } = this;
|
|
73
|
+
for (const item of result.observations)
|
|
74
|
+
this.observations.set(key(item.kind, item.key), item);
|
|
75
|
+
for (const item of result.artifacts ?? []) {
|
|
76
|
+
const body = Buffer.isBuffer(item.body)
|
|
77
|
+
? item.body
|
|
78
|
+
: Buffer.from(item.body);
|
|
79
|
+
this.artifacts.set(key(item.kind, item.key), {
|
|
80
|
+
...item,
|
|
81
|
+
body,
|
|
82
|
+
sha256: createHash("sha256").update(body).digest("hex"),
|
|
83
|
+
bytes: body.length,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
// Twins and HTML destinations this job already fetched need no job.
|
|
87
|
+
const observedResources = new Set(result.observations
|
|
88
|
+
.filter((o) => o.kind === "resource")
|
|
89
|
+
.map((o) => o.key));
|
|
90
|
+
const scheduled = {
|
|
91
|
+
page: 0,
|
|
92
|
+
resource: 0,
|
|
93
|
+
sitemap: 0,
|
|
94
|
+
};
|
|
95
|
+
for (const row of this.urls.values())
|
|
96
|
+
if (row.scheduled)
|
|
97
|
+
scheduled[row.kind]++;
|
|
98
|
+
let total = this.urls.size;
|
|
99
|
+
const grouped = new Map();
|
|
100
|
+
for (const candidate of result.candidates ?? []) {
|
|
101
|
+
const id = key(candidate.kind, candidate.url);
|
|
102
|
+
const row = grouped.get(id) ?? {
|
|
103
|
+
url: candidate.url,
|
|
104
|
+
kind: candidate.kind,
|
|
105
|
+
sources: new Set(),
|
|
106
|
+
};
|
|
107
|
+
row.sources.add(candidate.source);
|
|
108
|
+
grouped.set(id, row);
|
|
109
|
+
}
|
|
110
|
+
// Query-string variants per path that are not pagination: the existing
|
|
111
|
+
// count decides whether another variant is still followed.
|
|
112
|
+
const variantCounts = new Map();
|
|
113
|
+
for (const row of this.urls.values())
|
|
114
|
+
if (row.kind === "page" &&
|
|
115
|
+
row.scheduled &&
|
|
116
|
+
row.url.includes("?") &&
|
|
117
|
+
!isPaginationQuery(row.url)) {
|
|
118
|
+
const path = row.url.split("?")[0];
|
|
119
|
+
variantCounts.set(path, (variantCounts.get(path) ?? 0) + 1);
|
|
120
|
+
}
|
|
121
|
+
const limits = {
|
|
122
|
+
page: scan.options.maxPages ?? Infinity,
|
|
123
|
+
resource: scan.options.maxResources ?? Infinity,
|
|
124
|
+
sitemap: scan.options.maxSitemaps ?? Infinity,
|
|
125
|
+
};
|
|
126
|
+
for (const [id, candidate] of grouped) {
|
|
127
|
+
const previous = this.urls.get(id);
|
|
128
|
+
if (previous) {
|
|
129
|
+
for (const source of candidate.sources)
|
|
130
|
+
previous.sources.push(source);
|
|
131
|
+
previous.sources = [...new Set(previous.sources)];
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (scan.options.maxDiscoveredUrls !== undefined &&
|
|
135
|
+
total >= scan.options.maxDiscoveredUrls) {
|
|
136
|
+
scan.coverage.omittedCandidates++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
let skip = null;
|
|
140
|
+
const rule = candidate.kind === "page" ? scopeRule(candidate.url) : null;
|
|
141
|
+
if (rule)
|
|
142
|
+
skip = rule;
|
|
143
|
+
else if (candidate.kind === "page" &&
|
|
144
|
+
new URL(candidate.url).search &&
|
|
145
|
+
!isPaginationQuery(candidate.url)) {
|
|
146
|
+
const path = candidate.url.split("?")[0];
|
|
147
|
+
const n = variantCounts.get(path) ?? 0;
|
|
148
|
+
if (n >= (scan.options.maxQueryVariantsPerPath ?? Infinity))
|
|
149
|
+
skip = "query-variants-limited";
|
|
150
|
+
else
|
|
151
|
+
variantCounts.set(path, n + 1);
|
|
152
|
+
}
|
|
153
|
+
if (!skip &&
|
|
154
|
+
candidate.kind === "resource" &&
|
|
155
|
+
(observedResources.has(candidate.url) ||
|
|
156
|
+
this.get("resource", candidate.url)))
|
|
157
|
+
skip = "observed-by-page-job";
|
|
158
|
+
else if (candidate.kind === "resource" &&
|
|
159
|
+
(this.urls.has(key("page", candidate.url)) ||
|
|
160
|
+
grouped.has(key("page", candidate.url))))
|
|
161
|
+
skip = "crawled-as-page";
|
|
162
|
+
else if (!skip && scheduled[candidate.kind] >= limits[candidate.kind])
|
|
163
|
+
skip = "explicit-limit";
|
|
164
|
+
const shouldSchedule = !skip;
|
|
165
|
+
total++;
|
|
166
|
+
this.urls.set(id, {
|
|
167
|
+
url: candidate.url,
|
|
168
|
+
kind: candidate.kind,
|
|
169
|
+
sources: [...candidate.sources],
|
|
170
|
+
scheduled: shouldSchedule,
|
|
171
|
+
skipReason: skip,
|
|
172
|
+
});
|
|
173
|
+
if (shouldSchedule) {
|
|
174
|
+
scheduled[candidate.kind]++;
|
|
175
|
+
this.enqueue(candidate.kind, candidate.url);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Every HTML page gets the browser pass unless explicitly limited.
|
|
179
|
+
if (scan.options.browser) {
|
|
180
|
+
const limit = scan.options.maxBrowserPages ?? Infinity;
|
|
181
|
+
let browserJobs = this.jobs.filter((j) => j.kind === "browser").length;
|
|
182
|
+
for (const page of result.observations)
|
|
183
|
+
if (page.kind === "page" &&
|
|
184
|
+
page.data?.html !== false &&
|
|
185
|
+
page.data?.http?.outcome === "ok" &&
|
|
186
|
+
browserJobs < limit) {
|
|
187
|
+
browserJobs++;
|
|
188
|
+
this.enqueue("browser", page.key);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// The trailing-slash twin of a page is only a resource when the twin was
|
|
193
|
+
// not crawled as a page itself.
|
|
194
|
+
twinIsPage(url) {
|
|
195
|
+
const twin = slashTwin(url);
|
|
196
|
+
return !!twin && this.observations.has(key("page", twin));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export interface Entity {
|
|
2
|
+
type: string;
|
|
3
|
+
types: string[];
|
|
4
|
+
node: Record<string, any>;
|
|
5
|
+
path: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function entities(structuredData: {
|
|
8
|
+
parseValid?: boolean;
|
|
9
|
+
data?: any;
|
|
10
|
+
}[]): Entity[];
|
|
11
|
+
export declare function richResultChecks(found: Entity[]): {
|
|
12
|
+
type: string;
|
|
13
|
+
result: string;
|
|
14
|
+
path: string;
|
|
15
|
+
missingRequired: string[];
|
|
16
|
+
missingRecommended: string[];
|
|
17
|
+
problems: string[];
|
|
18
|
+
scope: string;
|
|
19
|
+
}[];
|
|
20
|
+
export interface OfferFacts {
|
|
21
|
+
name: string | null;
|
|
22
|
+
offers: {
|
|
23
|
+
price: number | null;
|
|
24
|
+
priceCurrency: string | null;
|
|
25
|
+
availability: string | null;
|
|
26
|
+
raw: any;
|
|
27
|
+
}[];
|
|
28
|
+
aggregate: {
|
|
29
|
+
low: number | null;
|
|
30
|
+
high: number | null;
|
|
31
|
+
currency: string | null;
|
|
32
|
+
} | null;
|
|
33
|
+
path: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function offerFacts(found: Entity[]): OfferFacts[];
|
|
36
|
+
export type Outcome = "match" | "mismatch" | "review" | "unknown";
|
|
37
|
+
export declare function compareOffers(products: OfferFacts[], page: {
|
|
38
|
+
prices: {
|
|
39
|
+
currency: string;
|
|
40
|
+
amount: number | null;
|
|
41
|
+
}[];
|
|
42
|
+
availability: {
|
|
43
|
+
inStock: boolean;
|
|
44
|
+
outOfStock: boolean;
|
|
45
|
+
inStockText: string | null;
|
|
46
|
+
outOfStockText: string | null;
|
|
47
|
+
};
|
|
48
|
+
headings: {
|
|
49
|
+
level: number;
|
|
50
|
+
text: string;
|
|
51
|
+
}[];
|
|
52
|
+
title: string;
|
|
53
|
+
}): {
|
|
54
|
+
products: {
|
|
55
|
+
name: string | null;
|
|
56
|
+
path: string;
|
|
57
|
+
declared: {
|
|
58
|
+
price: number;
|
|
59
|
+
prices: number[];
|
|
60
|
+
range: {
|
|
61
|
+
low: number | null;
|
|
62
|
+
high: number | null;
|
|
63
|
+
currency: string | null;
|
|
64
|
+
} | null;
|
|
65
|
+
currency: string | null;
|
|
66
|
+
availability: string | null;
|
|
67
|
+
};
|
|
68
|
+
visible: {
|
|
69
|
+
prices: number[];
|
|
70
|
+
currencies: string[];
|
|
71
|
+
availability: string | null;
|
|
72
|
+
h1: string;
|
|
73
|
+
};
|
|
74
|
+
ambiguous: boolean;
|
|
75
|
+
outcomes: {
|
|
76
|
+
price: Outcome;
|
|
77
|
+
currency: Outcome;
|
|
78
|
+
availability: Outcome;
|
|
79
|
+
name: "match" | "review" | "unknown";
|
|
80
|
+
};
|
|
81
|
+
}[];
|
|
82
|
+
scope: string;
|
|
83
|
+
} | null;
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// JSON-LD entities, the properties Google documents as required for the
|
|
2
|
+
// rich results Scan recognises, and the comparison between declared
|
|
3
|
+
// offers and what the page visibly shows. Presence checks only: nothing
|
|
4
|
+
// here establishes rich-result eligibility.
|
|
5
|
+
const asArray = (v) => v === undefined || v === null ? [] : Array.isArray(v) ? v : [v];
|
|
6
|
+
export function entities(structuredData) {
|
|
7
|
+
const found = [];
|
|
8
|
+
const walk = (node, path, depth) => {
|
|
9
|
+
if (depth > 8 || !node)
|
|
10
|
+
return;
|
|
11
|
+
if (Array.isArray(node)) {
|
|
12
|
+
node.forEach((n, i) => walk(n, `${path}[${i}]`, depth + 1));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (typeof node !== "object")
|
|
16
|
+
return;
|
|
17
|
+
const types = asArray(node["@type"]).filter((t) => typeof t === "string");
|
|
18
|
+
if (types.length)
|
|
19
|
+
found.push({ type: types[0], types, node, path });
|
|
20
|
+
for (const [key, value] of Object.entries(node))
|
|
21
|
+
if (key !== "@type" && typeof value === "object")
|
|
22
|
+
walk(value, `${path}.${key}`, depth + 1);
|
|
23
|
+
};
|
|
24
|
+
structuredData.forEach((block, i) => {
|
|
25
|
+
if (block.parseValid && block.data)
|
|
26
|
+
walk(block.data, `$[${i}]`, 0);
|
|
27
|
+
});
|
|
28
|
+
return found;
|
|
29
|
+
}
|
|
30
|
+
// Properties documented as required by Google's structured-data guides for
|
|
31
|
+
// each result type. Recommended properties are listed separately and only
|
|
32
|
+
// ever produce informational evidence.
|
|
33
|
+
const richResults = {
|
|
34
|
+
Product: {
|
|
35
|
+
required: ["name"],
|
|
36
|
+
recommended: ["image", "description", "offers", "sku", "brand"],
|
|
37
|
+
result: "Product snippet / merchant listing",
|
|
38
|
+
},
|
|
39
|
+
Offer: {
|
|
40
|
+
required: ["price|priceSpecification", "priceCurrency"],
|
|
41
|
+
recommended: ["availability", "url", "itemCondition"],
|
|
42
|
+
result: "Product offer",
|
|
43
|
+
},
|
|
44
|
+
AggregateOffer: {
|
|
45
|
+
required: ["lowPrice", "priceCurrency"],
|
|
46
|
+
recommended: ["highPrice", "offerCount"],
|
|
47
|
+
result: "Product offer range",
|
|
48
|
+
},
|
|
49
|
+
Article: {
|
|
50
|
+
required: ["headline"],
|
|
51
|
+
recommended: ["image", "author", "datePublished", "dateModified"],
|
|
52
|
+
result: "Article",
|
|
53
|
+
},
|
|
54
|
+
NewsArticle: {
|
|
55
|
+
required: ["headline"],
|
|
56
|
+
recommended: ["image", "author", "datePublished", "dateModified"],
|
|
57
|
+
result: "Article",
|
|
58
|
+
},
|
|
59
|
+
BlogPosting: {
|
|
60
|
+
required: ["headline"],
|
|
61
|
+
recommended: ["image", "author", "datePublished", "dateModified"],
|
|
62
|
+
result: "Article",
|
|
63
|
+
},
|
|
64
|
+
Organization: {
|
|
65
|
+
required: ["name"],
|
|
66
|
+
recommended: ["url", "logo", "sameAs", "contactPoint", "address"],
|
|
67
|
+
result: "Organization",
|
|
68
|
+
},
|
|
69
|
+
LocalBusiness: {
|
|
70
|
+
required: ["name", "address"],
|
|
71
|
+
recommended: ["telephone", "openingHoursSpecification", "url", "geo"],
|
|
72
|
+
result: "Local business",
|
|
73
|
+
},
|
|
74
|
+
BreadcrumbList: {
|
|
75
|
+
required: ["itemListElement"],
|
|
76
|
+
recommended: [],
|
|
77
|
+
result: "Breadcrumb",
|
|
78
|
+
},
|
|
79
|
+
FAQPage: { required: ["mainEntity"], recommended: [], result: "FAQ" },
|
|
80
|
+
Question: {
|
|
81
|
+
required: ["name", "acceptedAnswer"],
|
|
82
|
+
recommended: [],
|
|
83
|
+
result: "FAQ question",
|
|
84
|
+
},
|
|
85
|
+
Event: {
|
|
86
|
+
required: ["name", "startDate", "location"],
|
|
87
|
+
recommended: ["endDate", "image", "description", "offers"],
|
|
88
|
+
result: "Event",
|
|
89
|
+
},
|
|
90
|
+
Recipe: {
|
|
91
|
+
required: ["name", "image"],
|
|
92
|
+
recommended: ["author", "datePublished", "recipeIngredient"],
|
|
93
|
+
result: "Recipe",
|
|
94
|
+
},
|
|
95
|
+
JobPosting: {
|
|
96
|
+
required: [
|
|
97
|
+
"title",
|
|
98
|
+
"description",
|
|
99
|
+
"datePosted",
|
|
100
|
+
"hiringOrganization",
|
|
101
|
+
"jobLocation",
|
|
102
|
+
],
|
|
103
|
+
recommended: ["validThrough", "employmentType", "baseSalary"],
|
|
104
|
+
result: "Job posting",
|
|
105
|
+
},
|
|
106
|
+
VideoObject: {
|
|
107
|
+
required: ["name", "thumbnailUrl", "uploadDate"],
|
|
108
|
+
recommended: ["description", "contentUrl", "duration"],
|
|
109
|
+
result: "Video",
|
|
110
|
+
},
|
|
111
|
+
Review: {
|
|
112
|
+
required: ["itemReviewed", "reviewRating", "author"],
|
|
113
|
+
recommended: [],
|
|
114
|
+
result: "Review snippet",
|
|
115
|
+
},
|
|
116
|
+
WebSite: {
|
|
117
|
+
required: ["name", "url"],
|
|
118
|
+
recommended: ["potentialAction"],
|
|
119
|
+
result: "Site name / sitelinks search box",
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
export function richResultChecks(found) {
|
|
123
|
+
return found.flatMap((entity) => {
|
|
124
|
+
const spec = richResults[entity.type];
|
|
125
|
+
if (!spec)
|
|
126
|
+
return [];
|
|
127
|
+
const has = (property) => property
|
|
128
|
+
.split("|")
|
|
129
|
+
.some((p) => entity.node[p] !== undefined &&
|
|
130
|
+
entity.node[p] !== null &&
|
|
131
|
+
entity.node[p] !== "");
|
|
132
|
+
const missingRequired = spec.required.filter((p) => !has(p));
|
|
133
|
+
const missingRecommended = spec.recommended.filter((p) => !has(p));
|
|
134
|
+
const problems = [];
|
|
135
|
+
if (entity.type === "BreadcrumbList") {
|
|
136
|
+
const items = asArray(entity.node.itemListElement);
|
|
137
|
+
items.forEach((item, i) => {
|
|
138
|
+
if (item?.position === undefined)
|
|
139
|
+
problems.push(`item ${i + 1} has no position`);
|
|
140
|
+
if (!item?.name && !item?.item?.name)
|
|
141
|
+
problems.push(`item ${i + 1} has no name`);
|
|
142
|
+
if (i < items.length - 1 && !item?.item)
|
|
143
|
+
problems.push(`item ${i + 1} has no item URL`);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (entity.type === "FAQPage") {
|
|
147
|
+
const questions = asArray(entity.node.mainEntity);
|
|
148
|
+
if (!questions.length)
|
|
149
|
+
problems.push("mainEntity is empty");
|
|
150
|
+
}
|
|
151
|
+
return [
|
|
152
|
+
{
|
|
153
|
+
type: entity.type,
|
|
154
|
+
result: spec.result,
|
|
155
|
+
path: entity.path,
|
|
156
|
+
missingRequired,
|
|
157
|
+
missingRecommended,
|
|
158
|
+
problems,
|
|
159
|
+
scope: "Presence of properties Google documents for this result type; not a validation of values or eligibility",
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const availabilityValue = (value) => String(value ?? "")
|
|
165
|
+
.replace(/^https?:\/\/schema\.org\//i, "")
|
|
166
|
+
.replace(/^schema:/i, "");
|
|
167
|
+
export function offerFacts(found) {
|
|
168
|
+
const number = (v) => {
|
|
169
|
+
if (typeof v === "number")
|
|
170
|
+
return v;
|
|
171
|
+
if (typeof v === "string") {
|
|
172
|
+
const n = Number(v.replace(/[^\d.,-]/g, "").replace(",", "."));
|
|
173
|
+
return Number.isFinite(n) ? n : null;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
};
|
|
177
|
+
return found
|
|
178
|
+
.filter((e) => e.types.some((t) => /^Product(Group)?$/.test(t)))
|
|
179
|
+
.map((product) => {
|
|
180
|
+
const offers = asArray(product.node.offers).flatMap((offer) => offer && typeof offer === "object" ? [offer] : []);
|
|
181
|
+
const aggregate = offers.find((o) => asArray(o["@type"]).includes("AggregateOffer"));
|
|
182
|
+
const single = offers.filter((o) => !asArray(o["@type"]).includes("AggregateOffer"));
|
|
183
|
+
const nested = aggregate ? asArray(aggregate.offers) : [];
|
|
184
|
+
return {
|
|
185
|
+
name: typeof product.node.name === "string" ? product.node.name : null,
|
|
186
|
+
offers: [...single, ...nested].map((o) => ({
|
|
187
|
+
price: number(o.price ??
|
|
188
|
+
o.priceSpecification?.price ??
|
|
189
|
+
asArray(o.priceSpecification)[0]?.price),
|
|
190
|
+
priceCurrency: o.priceCurrency ??
|
|
191
|
+
o.priceSpecification?.priceCurrency ??
|
|
192
|
+
asArray(o.priceSpecification)[0]?.priceCurrency ??
|
|
193
|
+
null,
|
|
194
|
+
availability: o.availability
|
|
195
|
+
? availabilityValue(o.availability)
|
|
196
|
+
: null,
|
|
197
|
+
raw: {
|
|
198
|
+
price: o.price,
|
|
199
|
+
priceCurrency: o.priceCurrency,
|
|
200
|
+
availability: o.availability,
|
|
201
|
+
},
|
|
202
|
+
})),
|
|
203
|
+
aggregate: aggregate
|
|
204
|
+
? {
|
|
205
|
+
low: number(aggregate.lowPrice),
|
|
206
|
+
high: number(aggregate.highPrice),
|
|
207
|
+
currency: aggregate.priceCurrency ?? null,
|
|
208
|
+
}
|
|
209
|
+
: null,
|
|
210
|
+
path: product.path,
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
// Declared offers against the visible page. Several products, price ranges
|
|
215
|
+
// or many visible prices make the comparison ambiguous, which is reported as
|
|
216
|
+
// review, never as a mismatch.
|
|
217
|
+
export function compareOffers(products, page) {
|
|
218
|
+
if (!products.length)
|
|
219
|
+
return null;
|
|
220
|
+
const visibleAmounts = [
|
|
221
|
+
...new Set(page.prices.map((p) => p.amount).filter((a) => a !== null)),
|
|
222
|
+
];
|
|
223
|
+
const visibleCurrencies = [...new Set(page.prices.map((p) => p.currency))];
|
|
224
|
+
const normalize = (s) => s
|
|
225
|
+
.toLowerCase()
|
|
226
|
+
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
|
227
|
+
.replace(/\s+/g, " ")
|
|
228
|
+
.trim();
|
|
229
|
+
const h1 = page.headings.find((h) => h.level === 1)?.text ?? "";
|
|
230
|
+
const results = products.map((product) => {
|
|
231
|
+
const offers = product.offers;
|
|
232
|
+
const distinctPrices = [
|
|
233
|
+
...new Set(offers.map((o) => o.price).filter((p) => p !== null)),
|
|
234
|
+
];
|
|
235
|
+
const ambiguous = products.length > 1 ||
|
|
236
|
+
!!product.aggregate ||
|
|
237
|
+
distinctPrices.length > 1 ||
|
|
238
|
+
visibleAmounts.length > 6;
|
|
239
|
+
const declaredPrice = distinctPrices[0] ?? product.aggregate?.low ?? null;
|
|
240
|
+
let price = "unknown";
|
|
241
|
+
if (declaredPrice !== null && visibleAmounts.length) {
|
|
242
|
+
const shown = visibleAmounts.some((a) => Math.abs(a - declaredPrice) < 0.005);
|
|
243
|
+
price = shown ? "match" : ambiguous ? "review" : "mismatch";
|
|
244
|
+
}
|
|
245
|
+
else if (declaredPrice !== null || visibleAmounts.length)
|
|
246
|
+
price = "review";
|
|
247
|
+
const declaredCurrency = offers.find((o) => o.priceCurrency)?.priceCurrency ??
|
|
248
|
+
product.aggregate?.currency ??
|
|
249
|
+
null;
|
|
250
|
+
let currency = "unknown";
|
|
251
|
+
if (declaredCurrency && visibleCurrencies.length) {
|
|
252
|
+
const matches = visibleCurrencies.some((c) => c === declaredCurrency || c.split("/").includes(declaredCurrency));
|
|
253
|
+
currency = visibleCurrencies.includes("$")
|
|
254
|
+
? "review"
|
|
255
|
+
: matches
|
|
256
|
+
? visibleCurrencies.length === 1
|
|
257
|
+
? "match"
|
|
258
|
+
: "review"
|
|
259
|
+
: visibleCurrencies.length === 1 && !ambiguous
|
|
260
|
+
? "mismatch"
|
|
261
|
+
: "review";
|
|
262
|
+
}
|
|
263
|
+
// Several offers (variants) or both stock phrasings on one page (a sold
|
|
264
|
+
// out variant, related products) cannot be matched to one declaration
|
|
265
|
+
// from page text alone: review, never a mismatch.
|
|
266
|
+
const declaredAvailabilities = [
|
|
267
|
+
...new Set(offers.map((o) => o.availability).filter(Boolean)),
|
|
268
|
+
];
|
|
269
|
+
const declaredAvailability = declaredAvailabilities[0] ?? null;
|
|
270
|
+
let availability = "unknown";
|
|
271
|
+
if (declaredAvailability) {
|
|
272
|
+
const says = /InStock|InStoreOnly|OnlineOnly|LimitedAvailability|PreOrder|BackOrder/i.test(declaredAvailability)
|
|
273
|
+
? "in"
|
|
274
|
+
: /OutOfStock|SoldOut|Discontinued/i.test(declaredAvailability)
|
|
275
|
+
? "out"
|
|
276
|
+
: null;
|
|
277
|
+
const mixedVisible = page.availability.outOfStock && page.availability.inStock;
|
|
278
|
+
const visible = mixedVisible
|
|
279
|
+
? "mixed"
|
|
280
|
+
: page.availability.outOfStock
|
|
281
|
+
? "out"
|
|
282
|
+
: page.availability.inStock
|
|
283
|
+
? "in"
|
|
284
|
+
: null;
|
|
285
|
+
if (!says || !visible)
|
|
286
|
+
availability = "unknown";
|
|
287
|
+
else if (visible === "mixed" ||
|
|
288
|
+
offers.length > 1 ||
|
|
289
|
+
declaredAvailabilities.length > 1)
|
|
290
|
+
availability = says === visible ? "match" : "review";
|
|
291
|
+
else if (says === visible)
|
|
292
|
+
availability = "match";
|
|
293
|
+
else
|
|
294
|
+
availability = ambiguous ? "review" : "mismatch";
|
|
295
|
+
}
|
|
296
|
+
let name = "unknown";
|
|
297
|
+
if (product.name) {
|
|
298
|
+
const declared = normalize(product.name);
|
|
299
|
+
const visibleNames = [h1, page.title].map(normalize).filter(Boolean);
|
|
300
|
+
name = visibleNames.some((v) => v.includes(declared) || declared.includes(v))
|
|
301
|
+
? "match"
|
|
302
|
+
: "review";
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
name: product.name,
|
|
306
|
+
path: product.path,
|
|
307
|
+
declared: {
|
|
308
|
+
price: declaredPrice,
|
|
309
|
+
prices: distinctPrices,
|
|
310
|
+
range: product.aggregate,
|
|
311
|
+
currency: declaredCurrency,
|
|
312
|
+
availability: declaredAvailability,
|
|
313
|
+
},
|
|
314
|
+
visible: {
|
|
315
|
+
prices: visibleAmounts.slice(0, 20),
|
|
316
|
+
currencies: visibleCurrencies,
|
|
317
|
+
availability: page.availability.outOfStock && page.availability.inStock
|
|
318
|
+
? `${page.availability.inStockText} and ${page.availability.outOfStockText}`
|
|
319
|
+
: (page.availability.outOfStockText ??
|
|
320
|
+
page.availability.inStockText),
|
|
321
|
+
h1,
|
|
322
|
+
},
|
|
323
|
+
ambiguous,
|
|
324
|
+
outcomes: { price, currency, availability, name },
|
|
325
|
+
};
|
|
326
|
+
});
|
|
327
|
+
return {
|
|
328
|
+
products: results,
|
|
329
|
+
scope: "Visible prices and stock wording in the main text compared with JSON-LD offers; several products, price ranges or many visible prices are reported for review rather than as mismatches",
|
|
330
|
+
};
|
|
331
|
+
}
|