@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.
Files changed (55) hide show
  1. package/README.md +79 -0
  2. package/bin/fullscan.js +2 -0
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +239 -0
  5. package/dist/engine/analysis.d.ts +2 -0
  6. package/dist/engine/analysis.js +747 -0
  7. package/dist/engine/browser-inspection.d.ts +143 -0
  8. package/dist/engine/browser-inspection.js +567 -0
  9. package/dist/engine/browser.d.ts +22 -0
  10. package/dist/engine/browser.js +629 -0
  11. package/dist/engine/collect.d.ts +6 -0
  12. package/dist/engine/collect.js +359 -0
  13. package/dist/engine/crawl-scope.d.ts +22 -0
  14. package/dist/engine/crawl-scope.js +145 -0
  15. package/dist/engine/env.d.ts +1 -0
  16. package/dist/engine/env.js +3 -0
  17. package/dist/engine/html.d.ts +307 -0
  18. package/dist/engine/html.js +645 -0
  19. package/dist/engine/language.d.ts +13 -0
  20. package/dist/engine/language.js +75 -0
  21. package/dist/engine/lighthouse-evidence.d.ts +36 -0
  22. package/dist/engine/lighthouse-evidence.js +69 -0
  23. package/dist/engine/lighthouse.d.ts +4 -0
  24. package/dist/engine/lighthouse.js +284 -0
  25. package/dist/engine/log.d.ts +1 -0
  26. package/dist/engine/log.js +4 -0
  27. package/dist/engine/network.d.ts +53 -0
  28. package/dist/engine/network.js +296 -0
  29. package/dist/engine/proxy.d.ts +8 -0
  30. package/dist/engine/proxy.js +95 -0
  31. package/dist/engine/run.d.ts +38 -0
  32. package/dist/engine/run.js +202 -0
  33. package/dist/engine/select.d.ts +6 -0
  34. package/dist/engine/select.js +38 -0
  35. package/dist/engine/site.d.ts +186 -0
  36. package/dist/engine/site.js +758 -0
  37. package/dist/engine/srcset.d.ts +1 -0
  38. package/dist/engine/srcset.js +31 -0
  39. package/dist/engine/state.d.ts +30 -0
  40. package/dist/engine/state.js +198 -0
  41. package/dist/engine/structured-data.d.ts +83 -0
  42. package/dist/engine/structured-data.js +331 -0
  43. package/dist/engine/types.d.ts +128 -0
  44. package/dist/engine/types.js +63 -0
  45. package/dist/engine.d.ts +1 -0
  46. package/dist/engine.js +1 -0
  47. package/dist/index.d.ts +8 -0
  48. package/dist/index.js +7 -0
  49. package/dist/report/build.d.ts +377 -0
  50. package/dist/report/build.js +2263 -0
  51. package/dist/report/evidence.d.ts +58 -0
  52. package/dist/report/evidence.js +192 -0
  53. package/dist/report/rules.d.ts +41 -0
  54. package/dist/report/rules.js +301 -0
  55. 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
+ }