open-local-audit 0.77.0 → 0.79.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/CHANGELOG.md +17 -0
- package/README.md +2 -0
- package/dist/dotnet-stack.d.ts +1 -0
- package/dist/dotnet-stack.js +1 -1
- package/dist/dotnet-stack.js.map +1 -1
- package/dist/local-business-types.d.ts +17 -0
- package/dist/local-business-types.js +324 -0
- package/dist/local-business-types.js.map +1 -0
- package/dist/pitch-brief.js +2 -0
- package/dist/pitch-brief.js.map +1 -1
- package/dist/rules.js +425 -23
- package/dist/rules.js.map +1 -1
- package/package.json +1 -1
package/dist/rules.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { load } from "cheerio";
|
|
2
2
|
import { findPhoneNumbersInText, getCountries, parsePhoneNumberFromString } from "libphonenumber-js/max";
|
|
3
3
|
import { detectPageForms, hasContactOrBookingLink } from "./contact.js";
|
|
4
|
+
import { cookieNames } from "./dotnet-stack.js";
|
|
5
|
+
import { applyTypeContext, contextBase, expandNodeId, isLocalBusinessType, noTypeContext } from "./local-business-types.js";
|
|
4
6
|
function finding(rule, context) {
|
|
5
7
|
return {
|
|
6
8
|
id: rule.id,
|
|
@@ -30,25 +32,39 @@ function jsonLdTypes(node) {
|
|
|
30
32
|
const types = Array.isArray(typeValue) ? typeValue : [typeValue];
|
|
31
33
|
return types.filter((type) => typeof type === "string");
|
|
32
34
|
}
|
|
33
|
-
|
|
35
|
+
const nodeScopes = new WeakMap();
|
|
36
|
+
// Flattens top-level and @graph or @included node objects; with embedded set, also node objects nested
|
|
37
|
+
// as property values, such as an address or a mainEntity.
|
|
38
|
+
function flattenJsonLd(value, block, context = noTypeContext, embedded = false) {
|
|
34
39
|
if (Array.isArray(value)) {
|
|
35
|
-
return value.flatMap((item) => flattenJsonLd(item));
|
|
40
|
+
return value.flatMap((item) => flattenJsonLd(item, block, context, embedded));
|
|
36
41
|
}
|
|
37
42
|
if (!value || typeof value !== "object") {
|
|
38
43
|
return [];
|
|
39
44
|
}
|
|
40
45
|
const node = value;
|
|
41
|
-
const
|
|
42
|
-
|
|
46
|
+
const localContext = node["@context"];
|
|
47
|
+
const nodeContext = applyTypeContext(context, localContext);
|
|
48
|
+
// A context with "@propagate": false, alone or in a context array, applies to this node only; nested
|
|
49
|
+
// nodes return to the previous context, which no longer gets the lenient no-context reading.
|
|
50
|
+
const localContexts = Array.isArray(localContext) ? localContext : [localContext];
|
|
51
|
+
const propagates = !localContexts.some((entry) => hasObjectField(entry) && entry["@propagate"] === false);
|
|
52
|
+
const childContext = propagates ? nodeContext : { ...context, declared: true };
|
|
53
|
+
nodeScopes.set(node, { context: nodeContext, nestedContext: childContext, block });
|
|
54
|
+
// @graph and @included both hold further node objects of the same document.
|
|
55
|
+
const nestedKeys = embedded
|
|
56
|
+
? Object.keys(node).filter((key) => !key.startsWith("@") || ["@graph", "@included", "@list", "@set"].includes(key))
|
|
57
|
+
: ["@graph", "@included"];
|
|
58
|
+
return [node, ...nestedKeys.flatMap((key) => flattenJsonLd(node[key], block, childContext, embedded))];
|
|
43
59
|
}
|
|
44
|
-
function jsonLdNodes(
|
|
60
|
+
function jsonLdNodes($, embedded = false) {
|
|
45
61
|
return $('script[type="application/ld+json"]')
|
|
46
62
|
.toArray()
|
|
47
|
-
.flatMap((element) => {
|
|
63
|
+
.flatMap((element, block) => {
|
|
48
64
|
const raw = $(element).text();
|
|
49
65
|
try {
|
|
50
66
|
const parsed = JSON.parse(raw);
|
|
51
|
-
return flattenJsonLd(parsed);
|
|
67
|
+
return flattenJsonLd(parsed, block, noTypeContext, embedded);
|
|
52
68
|
}
|
|
53
69
|
catch {
|
|
54
70
|
return [];
|
|
@@ -72,7 +88,7 @@ function hasSuccessfulResource(statusCode) {
|
|
|
72
88
|
return typeof statusCode === "number" && statusCode >= 200 && statusCode < 400;
|
|
73
89
|
}
|
|
74
90
|
function localBusinessNodes($) {
|
|
75
|
-
return jsonLdNodes($).filter((node) => jsonLdTypes(node).some((type) => type.
|
|
91
|
+
return jsonLdNodes($).filter((node) => jsonLdTypes(node).some((type) => isLocalBusinessType(type, nodeScopes.get(node)?.context)));
|
|
76
92
|
}
|
|
77
93
|
function hasObjectField(value) {
|
|
78
94
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
@@ -80,12 +96,116 @@ function hasObjectField(value) {
|
|
|
80
96
|
function hasStringField(value) {
|
|
81
97
|
return typeof value === "string" && value.trim().length > 0;
|
|
82
98
|
}
|
|
83
|
-
|
|
99
|
+
// A day or time is a non-empty string, or a JSON-LD value or node object carrying one, such as
|
|
100
|
+
// {"@id": "https://schema.org/Monday"} or {"@value": "09:00"}.
|
|
101
|
+
function hasOpeningHoursValue(value) {
|
|
102
|
+
if (hasStringField(value)) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
if (!hasObjectField(value)) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
const fields = value;
|
|
109
|
+
return ["@id", "@value", "name"].some((key) => hasStringField(fields[key]));
|
|
110
|
+
}
|
|
111
|
+
function hasOpeningHoursData(specification) {
|
|
112
|
+
if (!hasObjectField(specification)) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
const fields = specification;
|
|
116
|
+
return ["dayOfWeek", "opens", "closes"].some((field) => {
|
|
117
|
+
const values = Array.isArray(fields[field]) ? fields[field] : [fields[field]];
|
|
118
|
+
return values.some(hasOpeningHoursValue);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
// JSON-LD @id values are IRIs relative to the document base: the page URL, or its <base href>.
|
|
122
|
+
function documentBase($, snapshot) {
|
|
123
|
+
const pageUrl = snapshot.finalUrl || snapshot.url;
|
|
124
|
+
try {
|
|
125
|
+
return new URL($("base[href]").first().attr("href") ?? "", pageUrl).href;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return pageUrl;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// An @id as a lookup key: compact IRIs expand through the JSON-LD context and relative IRIs resolve
|
|
132
|
+
// against the context's @base or the document base, while blank node labels such as "_:b0" stay local
|
|
133
|
+
// to their script block.
|
|
134
|
+
function nodeKey(id, context, block, documentBaseIri) {
|
|
135
|
+
const expanded = expandNodeId(id, context);
|
|
136
|
+
if (expanded.startsWith("_:")) {
|
|
137
|
+
return `${block} ${expanded}`;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
return new URL(expanded, contextBase(documentBaseIri, context) ?? undefined).href;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return expanded;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function nodeIdKey(node, base) {
|
|
147
|
+
const scope = nodeScopes.get(node);
|
|
148
|
+
return scope && typeof node["@id"] === "string" ? nodeKey(node["@id"], scope.context, scope.block, base) : undefined;
|
|
149
|
+
}
|
|
150
|
+
// The key of a node reference such as {"@id": "#hours"} found in the owner node's property. As in JSON-LD
|
|
151
|
+
// expansion, an object holding only @id keeps the owner's context even when that context does not
|
|
152
|
+
// propagate, while any other nested object returns to the previous context; the property term's own
|
|
153
|
+
// scoped @context then applies, and finally the reference's own @context.
|
|
154
|
+
function referenceKey(reference, owner, property, base) {
|
|
155
|
+
const scope = nodeScopes.get(owner);
|
|
156
|
+
if (!scope || !hasObjectField(reference)) {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
const fields = reference;
|
|
160
|
+
const idOnly = Object.keys(fields).length === 1;
|
|
161
|
+
const propertyContext = applyTypeContext(idOnly ? scope.context : scope.nestedContext, scope.context.terms.get(property)?.scopedContext);
|
|
162
|
+
const context = idOnly ? propertyContext : applyTypeContext(propertyContext, fields["@context"]);
|
|
163
|
+
return typeof fields["@id"] === "string" ? nodeKey(fields["@id"], context, scope.block, base) : undefined;
|
|
164
|
+
}
|
|
165
|
+
// Every JSON-LD node with each @id, embedded ones included, keyed as above; nodes sharing an @id
|
|
166
|
+
// describe the same thing.
|
|
167
|
+
function jsonLdNodesById($, base) {
|
|
168
|
+
const nodesById = new Map();
|
|
169
|
+
for (const node of jsonLdNodes($, true)) {
|
|
170
|
+
const key = nodeIdKey(node, base);
|
|
171
|
+
if (key !== undefined) {
|
|
172
|
+
nodesById.set(key, [...(nodesById.get(key) ?? []), node]);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return nodesById;
|
|
176
|
+
}
|
|
177
|
+
// A node together with every other node that shares its @id.
|
|
178
|
+
function nodeFragments(node, nodesById, base) {
|
|
179
|
+
const key = nodeIdKey(node, base);
|
|
180
|
+
return key === undefined ? [node] : (nodesById.get(key) ?? [node]);
|
|
181
|
+
}
|
|
182
|
+
// openingHours may be one string or a list; openingHoursSpecification is one object or a list of them,
|
|
183
|
+
// each with a day or time, or an {"@id": ...} reference to nodes elsewhere in the graph that have one.
|
|
184
|
+
function hasOpeningHoursField(node, nodesById, base) {
|
|
185
|
+
const specifications = Array.isArray(node.openingHoursSpecification)
|
|
186
|
+
? node.openingHoursSpecification
|
|
187
|
+
: [node.openingHoursSpecification];
|
|
188
|
+
return (stringValues(node.openingHours).length > 0 ||
|
|
189
|
+
specifications.some((specification) => {
|
|
190
|
+
const reference = referenceKey(specification, node, "openingHoursSpecification", base);
|
|
191
|
+
const referenced = reference === undefined ? [] : (nodesById.get(reference) ?? []);
|
|
192
|
+
return hasOpeningHoursData(specification) || referenced.some(hasOpeningHoursData);
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
// The business's fields may be split across nodes that share its @id.
|
|
196
|
+
function hasLocalBusinessContactFields($, snapshot) {
|
|
84
197
|
const nodes = localBusinessNodes($);
|
|
85
198
|
if (nodes.length === 0) {
|
|
86
199
|
return true;
|
|
87
200
|
}
|
|
88
|
-
|
|
201
|
+
const base = documentBase($, snapshot);
|
|
202
|
+
const nodesById = jsonLdNodesById($, base);
|
|
203
|
+
return nodes.some((node) => {
|
|
204
|
+
const fragments = nodeFragments(node, nodesById, base);
|
|
205
|
+
return (fragments.some((fragment) => hasStringField(fragment.telephone)) &&
|
|
206
|
+
fragments.some((fragment) => hasObjectField(fragment.address)) &&
|
|
207
|
+
fragments.some((fragment) => hasOpeningHoursField(fragment, nodesById, base)));
|
|
208
|
+
});
|
|
89
209
|
}
|
|
90
210
|
function stringValues(value) {
|
|
91
211
|
const values = Array.isArray(value) ? value : [value];
|
|
@@ -622,18 +742,23 @@ function streetAddressVisible(streetAddress, regions) {
|
|
|
622
742
|
return false;
|
|
623
743
|
});
|
|
624
744
|
}
|
|
625
|
-
|
|
745
|
+
// As with the contact fields, the business's telephone and address may sit on any node sharing its @id.
|
|
746
|
+
function localBusinessNapMismatches($, snapshot) {
|
|
626
747
|
const nodes = localBusinessNodes($);
|
|
627
748
|
if (nodes.length === 0) {
|
|
628
749
|
return [];
|
|
629
750
|
}
|
|
630
751
|
const index = jsonLdIndex($);
|
|
752
|
+
const base = documentBase($, snapshot);
|
|
753
|
+
const nodesById = jsonLdNodesById($, base);
|
|
631
754
|
const body = visibleBody($);
|
|
632
755
|
const regions = visibleAddressRegions($, body);
|
|
633
756
|
const mismatches = [];
|
|
634
757
|
for (const node of nodes) {
|
|
635
|
-
|
|
636
|
-
|
|
758
|
+
const fragments = nodeFragments(node, nodesById, base);
|
|
759
|
+
const fragmentCountry = fragments.map((fragment) => schemaCountry(fragment, index)).find((code) => code !== undefined);
|
|
760
|
+
for (const telephone of fragments.flatMap((fragment) => stringValues(fragment.telephone))) {
|
|
761
|
+
const country = fragmentCountry ?? parsePhoneNumberFromString(telephone.trim())?.country;
|
|
637
762
|
const phones = visiblePhones($, body, country);
|
|
638
763
|
if (phones.length === 0) {
|
|
639
764
|
continue;
|
|
@@ -651,7 +776,7 @@ function localBusinessNapMismatches($) {
|
|
|
651
776
|
}
|
|
652
777
|
}
|
|
653
778
|
if (regions.length > 0) {
|
|
654
|
-
for (const streetAddress of streetAddresses(
|
|
779
|
+
for (const streetAddress of fragments.flatMap((fragment) => streetAddresses(fragment, index))) {
|
|
655
780
|
if (!streetAddressVisible(streetAddress, regions)) {
|
|
656
781
|
mismatches.push(`Schema streetAddress "${streetAddress.trim()}" not found in visible page text`);
|
|
657
782
|
}
|
|
@@ -849,6 +974,202 @@ function tlsCertificateEvidence(tls) {
|
|
|
849
974
|
: `Certificate expires ${date} (${tls.daysRemaining} day${tls.daysRemaining === 1 ? "" : "s"})`;
|
|
850
975
|
return tls.issuer ? `${status}; issuer ${tls.issuer}` : status;
|
|
851
976
|
}
|
|
977
|
+
const languageCode = /^[a-z]{2,3}(?:-[a-z0-9]{1,8})*$/i;
|
|
978
|
+
function htmlLangEvidence($) {
|
|
979
|
+
const attribute = $("html").attr("lang");
|
|
980
|
+
const lang = (attribute ?? "").trim();
|
|
981
|
+
if (!lang) {
|
|
982
|
+
return attribute === undefined ? "No lang attribute on the <html> element" : "The lang attribute on the <html> element is empty";
|
|
983
|
+
}
|
|
984
|
+
return languageCode.test(lang) ? undefined : `lang="${lang}" is not a valid language code`;
|
|
985
|
+
}
|
|
986
|
+
const unlabelledInputTypes = new Set(["hidden", "submit", "button", "reset", "image"]);
|
|
987
|
+
function textOf($, element) {
|
|
988
|
+
const copy = $(element).clone();
|
|
989
|
+
copy.find("input, select, textarea").remove();
|
|
990
|
+
return copy.text().replace(/\s+/g, " ").trim();
|
|
991
|
+
}
|
|
992
|
+
function hasAccessibleName($, element) {
|
|
993
|
+
const control = $(element);
|
|
994
|
+
const ids = (control.attr("aria-labelledby") ?? "").split(/\s+/).filter(Boolean);
|
|
995
|
+
if (ids.some((id) => $("[id]").toArray().some((target) => $(target).attr("id") === id && textOf($, target)))) {
|
|
996
|
+
return true;
|
|
997
|
+
}
|
|
998
|
+
if (["aria-label", "title", "placeholder"].some((name) => (control.attr(name) ?? "").trim())) {
|
|
999
|
+
return true;
|
|
1000
|
+
}
|
|
1001
|
+
const id = control.attr("id");
|
|
1002
|
+
const labels = [
|
|
1003
|
+
...control.closest("label").toArray(),
|
|
1004
|
+
...(id ? $("label[for]").toArray().filter((label) => $(label).attr("for") === id) : [])
|
|
1005
|
+
];
|
|
1006
|
+
return labels.some((label) => textOf($, label));
|
|
1007
|
+
}
|
|
1008
|
+
function describeControl($, element) {
|
|
1009
|
+
const control = $(element);
|
|
1010
|
+
const tag = element.tagName?.toLowerCase() ?? "input";
|
|
1011
|
+
const type = control.attr("type");
|
|
1012
|
+
const name = control.attr("name");
|
|
1013
|
+
const id = control.attr("id");
|
|
1014
|
+
return `${tag}${type ? `[type=${type}]` : ""}${name ? `[name=${name}]` : id ? `#${id}` : ""}`;
|
|
1015
|
+
}
|
|
1016
|
+
function formFields($) {
|
|
1017
|
+
const fields = visibleBody($)
|
|
1018
|
+
.find("input, select, textarea")
|
|
1019
|
+
.toArray()
|
|
1020
|
+
// Fields under aria-hidden, such as the off-screen Mailchimp spam trap, are not exposed to assistive technology.
|
|
1021
|
+
.filter((element) => !unlabelledInputTypes.has(($(element).attr("type") ?? "").trim().toLowerCase()) && $(element).closest('[aria-hidden="true"]').length === 0);
|
|
1022
|
+
const unlabelled = fields.filter((element) => !hasAccessibleName($, element)).map((element) => describeControl($, element));
|
|
1023
|
+
return { total: fields.length, unlabelled };
|
|
1024
|
+
}
|
|
1025
|
+
// Lighthouse's English list of non-descriptive link text, plus "continue reading", "find out more", and common Turkish and German equivalents.
|
|
1026
|
+
const genericLinkText = new Set([
|
|
1027
|
+
"click here",
|
|
1028
|
+
"click this",
|
|
1029
|
+
"go",
|
|
1030
|
+
"here",
|
|
1031
|
+
"information",
|
|
1032
|
+
"learn more",
|
|
1033
|
+
"more",
|
|
1034
|
+
"more info",
|
|
1035
|
+
"more information",
|
|
1036
|
+
"right here",
|
|
1037
|
+
"read more",
|
|
1038
|
+
"see more",
|
|
1039
|
+
"start",
|
|
1040
|
+
"this",
|
|
1041
|
+
"continue reading",
|
|
1042
|
+
"find out more",
|
|
1043
|
+
"buraya tıklayın",
|
|
1044
|
+
"buraya tıkla",
|
|
1045
|
+
"tıklayın",
|
|
1046
|
+
"tıkla",
|
|
1047
|
+
"devamı",
|
|
1048
|
+
"devamını oku",
|
|
1049
|
+
"daha fazla",
|
|
1050
|
+
"detaylar",
|
|
1051
|
+
"ayrıntılar",
|
|
1052
|
+
"hier",
|
|
1053
|
+
"hier klicken",
|
|
1054
|
+
"mehr",
|
|
1055
|
+
"mehr erfahren",
|
|
1056
|
+
"mehr lesen",
|
|
1057
|
+
"weiterlesen"
|
|
1058
|
+
]);
|
|
1059
|
+
function genericLinks($) {
|
|
1060
|
+
return visibleBody($)
|
|
1061
|
+
.find("a[href]")
|
|
1062
|
+
.toArray()
|
|
1063
|
+
.flatMap((element) => {
|
|
1064
|
+
const link = $(element);
|
|
1065
|
+
const href = (link.attr("href") ?? "").trim();
|
|
1066
|
+
if (!href || href.startsWith("#") || /^javascript:/i.test(href) || link.attr("aria-labelledby")) {
|
|
1067
|
+
return [];
|
|
1068
|
+
}
|
|
1069
|
+
const label = (link.attr("aria-label") ?? "").trim() ||
|
|
1070
|
+
[link.text(), ...link.find("img[alt]").toArray().map((image) => $(image).attr("alt") ?? "")].join(" ");
|
|
1071
|
+
const shown = label.replace(/\s+/g, " ").trim();
|
|
1072
|
+
const key = shown.toLowerCase().replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
|
|
1073
|
+
return genericLinkText.has(key) ? [`"${shown}" (${href})`] : [];
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
function firstFive(items) {
|
|
1077
|
+
return [...items.slice(0, 5), ...(items.length > 5 ? [`and ${items.length - 5} more`] : [])].join("; ");
|
|
1078
|
+
}
|
|
1079
|
+
const trackers = [
|
|
1080
|
+
[
|
|
1081
|
+
"Google Analytics or Ads",
|
|
1082
|
+
/googletagmanager\.com\/gtag\/js|google-analytics\.com\/(?:analytics|ga|urchin)\.js|\bgtag\(\s*['"]config['"]|\bga\(\s*['"]create['"]/i
|
|
1083
|
+
],
|
|
1084
|
+
["Google Tag Manager", /googletagmanager\.com\/gtm\.js/i],
|
|
1085
|
+
["Meta Pixel", /connect\.facebook\.net\/[\w-]+\/fbevents\.js|\bfbq\(\s*['"]init['"]/i],
|
|
1086
|
+
["Hotjar", /static\.hotjar\.com|\b_hjSettings\b/i],
|
|
1087
|
+
["Microsoft Clarity", /clarity\.ms\/tag/i],
|
|
1088
|
+
["LinkedIn Insight Tag", /snap\.licdn\.com\/li\.lms-analytics|\b_linkedin_partner_id\b/i],
|
|
1089
|
+
["TikTok Pixel", /analytics\.tiktok\.com/i],
|
|
1090
|
+
["Microsoft Advertising", /bat\.bing\.com\/bat\.js/i]
|
|
1091
|
+
];
|
|
1092
|
+
const trackingCookie = /^(?:_ga(?:_\w+)?|_gid|_gat(?:_\w+)?|_gcl_\w+|_fbp|_fbc|_hj\w+|_clck|_clsk|_uetsid|_uetvid|_ttp|li_fat_id)$/;
|
|
1093
|
+
// Consent platforms, IAB TCF, Google consent mode defaults, website-builder banners, and common cookie banner markup.
|
|
1094
|
+
// Matching is deliberately broad: a page that shows any sign of a consent step is not flagged.
|
|
1095
|
+
const consentTool = /cookiebot|cookielaw\.org|onetrust|optanon|cookieyes|cookie-law-info|termly\.io|iubenda|complianz|cmplz|osano|usercentrics|cmp\.quantcast\.com|__tcfapi|didomi|cookiefirst|cookie-script\.com|civiccomputing|cookiecontrol|borlabs|klaro|real-cookie-banner|moove[_-]gdpr|cookie[_-]?(?:notice|consent|banner|bar|law)|consent[_-]?(?:banner|manager)|cookiehub|axeptio|tarteaucitron|trustarc|consentmanager|gdpr[_-]?cookie|cc-window|cc-banner|sqs-cookie-banner|\bfs-cc\b|fundingchoicesmessages\.google\.com|hs-banner|hs-eu-cookie|consentPolicy|customerPrivacy|\bgtag\(\s*['"]consent['"]\s*,\s*['"]default['"]/i;
|
|
1096
|
+
// Consent tools hold tags back as type="text/plain" until visitors accept, so only executable scripts count.
|
|
1097
|
+
const executableScriptTypes = new Set(["", "text/javascript", "application/javascript", "module"]);
|
|
1098
|
+
function loadedTrackers($) {
|
|
1099
|
+
const scripts = $("script")
|
|
1100
|
+
.toArray()
|
|
1101
|
+
.filter((element) => executableScriptTypes.has(($(element).attr("type") ?? "").trim().toLowerCase()))
|
|
1102
|
+
.map((element) => `${$(element).attr("src") ?? ""} ${$(element).text()}`)
|
|
1103
|
+
.join("\n");
|
|
1104
|
+
return trackers.filter(([, pattern]) => pattern.test(scripts)).map(([name]) => name);
|
|
1105
|
+
}
|
|
1106
|
+
function serverTrackingCookies(snapshot) {
|
|
1107
|
+
return Array.from(new Set(cookieNames(snapshot.headers["set-cookie"]).filter((name) => trackingCookie.test(name))));
|
|
1108
|
+
}
|
|
1109
|
+
function hasConsentTool(snapshot) {
|
|
1110
|
+
return consentTool.test(snapshot.html);
|
|
1111
|
+
}
|
|
1112
|
+
function consentEvidence($, snapshot) {
|
|
1113
|
+
const names = loadedTrackers($);
|
|
1114
|
+
const cookies = serverTrackingCookies(snapshot);
|
|
1115
|
+
const scripts = names.length > 0 ? `${names.join(", ")} ${names.length === 1 ? "loads" : "load"} with no consent tool found` : "No consent tool found";
|
|
1116
|
+
return cookies.length > 0 ? `${scripts}; tracking cookies set by the server: ${cookies.join(", ")}` : scripts;
|
|
1117
|
+
}
|
|
1118
|
+
const privacyLinkText = /privacy|privacidad|privacit|gizlilik|kvkk|kişisel veri|datenschutz|confidentialit|données personnelles|protección de datos|data protection|gdpr|privacybeleid|privacyverklaring/i;
|
|
1119
|
+
const privacyLinkPath = /privacy|privacidad|gizlilik|kvkk|aydinlatma|datenschutz|confidentialit|gdpr|data-protection/i;
|
|
1120
|
+
const privacyPolicyHosts = ["iubenda.com", "termly.io", "privacypolicies.com", "freeprivacypolicy.com"];
|
|
1121
|
+
function bareHost(host) {
|
|
1122
|
+
return host.toLowerCase().replace(/^www\./, "");
|
|
1123
|
+
}
|
|
1124
|
+
function onHost(host, domain) {
|
|
1125
|
+
return host === domain || host.endsWith(`.${domain}`);
|
|
1126
|
+
}
|
|
1127
|
+
// The URL parser keeps percent sequences that do not decode, such as "/20%-off", so fall back to the raw path.
|
|
1128
|
+
function decodedPath(url) {
|
|
1129
|
+
try {
|
|
1130
|
+
return decodeURIComponent(url.pathname);
|
|
1131
|
+
}
|
|
1132
|
+
catch {
|
|
1133
|
+
return url.pathname;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
function hasPrivacyPolicyLink($, snapshot) {
|
|
1137
|
+
if ($("[rel~='privacy-policy'][href]").length > 0) {
|
|
1138
|
+
return true;
|
|
1139
|
+
}
|
|
1140
|
+
let pageHost;
|
|
1141
|
+
try {
|
|
1142
|
+
pageHost = bareHost(new URL(snapshot.finalUrl).hostname);
|
|
1143
|
+
}
|
|
1144
|
+
catch {
|
|
1145
|
+
pageHost = undefined;
|
|
1146
|
+
}
|
|
1147
|
+
return $("a[href]")
|
|
1148
|
+
.toArray()
|
|
1149
|
+
.some((element) => {
|
|
1150
|
+
let url;
|
|
1151
|
+
try {
|
|
1152
|
+
url = new URL(($(element).attr("href") ?? "").trim(), snapshot.finalUrl);
|
|
1153
|
+
}
|
|
1154
|
+
catch {
|
|
1155
|
+
return false;
|
|
1156
|
+
}
|
|
1157
|
+
const host = bareHost(url.hostname);
|
|
1158
|
+
const ownSite = pageHost !== undefined && (onHost(host, pageHost) || onHost(pageHost, host));
|
|
1159
|
+
if (!/^https?:$/.test(url.protocol) || !(ownSite || privacyPolicyHosts.some((domain) => onHost(host, domain)))) {
|
|
1160
|
+
return false;
|
|
1161
|
+
}
|
|
1162
|
+
return privacyLinkText.test($(element).text()) || privacyLinkPath.test(decodedPath(url));
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
function privacyEvidence($, snapshot) {
|
|
1166
|
+
const names = loadedTrackers($);
|
|
1167
|
+
const context = [
|
|
1168
|
+
...(detectPageForms($, snapshot.finalUrl).enquiryForm ? ["has an enquiry form"] : []),
|
|
1169
|
+
...(names.length > 0 ? [`loads ${names.join(", ")}`] : [])
|
|
1170
|
+
];
|
|
1171
|
+
return `No privacy policy link found${context.length > 0 ? `; the page ${context.join(" and ")}` : ""}`;
|
|
1172
|
+
}
|
|
852
1173
|
function hasPlaceholderSocialLinks($) {
|
|
853
1174
|
return $("a")
|
|
854
1175
|
.toArray()
|
|
@@ -1027,7 +1348,7 @@ const rules = [
|
|
|
1027
1348
|
severity: "medium",
|
|
1028
1349
|
source: "JSON-LD",
|
|
1029
1350
|
recommendation: "Add LocalBusiness schema when the page represents a local business location.",
|
|
1030
|
-
check: ({ $ }) =>
|
|
1351
|
+
check: ({ $ }) => localBusinessNodes($).length > 0,
|
|
1031
1352
|
evidence: () => "No LocalBusiness JSON-LD type found"
|
|
1032
1353
|
},
|
|
1033
1354
|
{
|
|
@@ -1036,9 +1357,9 @@ const rules = [
|
|
|
1036
1357
|
category: "search-basics",
|
|
1037
1358
|
severity: "medium",
|
|
1038
1359
|
source: "JSON-LD",
|
|
1039
|
-
recommendation: "Add telephone, address, and openingHours fields to LocalBusiness schema.",
|
|
1040
|
-
check: ({
|
|
1041
|
-
evidence: () => "LocalBusiness schema is missing telephone, address, or
|
|
1360
|
+
recommendation: "Add telephone, address, and openingHours or openingHoursSpecification fields to LocalBusiness schema.",
|
|
1361
|
+
check: ({ $, snapshot }) => hasLocalBusinessContactFields($, snapshot),
|
|
1362
|
+
evidence: () => "LocalBusiness schema is missing telephone, address, or opening hours"
|
|
1042
1363
|
},
|
|
1043
1364
|
{
|
|
1044
1365
|
id: "localbusiness-schema-nap-consistency",
|
|
@@ -1047,8 +1368,8 @@ const rules = [
|
|
|
1047
1368
|
severity: "medium",
|
|
1048
1369
|
source: "JSON-LD",
|
|
1049
1370
|
recommendation: "Make the LocalBusiness schema telephone and streetAddress match the phone number and address shown on the page, so search engines and customers see one consistent listing.",
|
|
1050
|
-
check: ({
|
|
1051
|
-
evidence: ({
|
|
1371
|
+
check: ({ $, snapshot }) => localBusinessNapMismatches($, snapshot).length === 0,
|
|
1372
|
+
evidence: ({ $, snapshot }) => localBusinessNapMismatches($, snapshot).join("; ")
|
|
1052
1373
|
},
|
|
1053
1374
|
{
|
|
1054
1375
|
id: "organization-schema-present",
|
|
@@ -1112,6 +1433,26 @@ const rules = [
|
|
|
1112
1433
|
hasLink($, (href) => href.toLowerCase().startsWith("mailto:")),
|
|
1113
1434
|
evidence: ({ $, snapshot }) => `No enquiry form, contact page link, or email link${detectPageForms($, snapshot.finalUrl).newsletterForm ? "; only a newsletter signup form" : ""}`
|
|
1114
1435
|
},
|
|
1436
|
+
{
|
|
1437
|
+
id: "privacy-policy-link-present",
|
|
1438
|
+
title: "Privacy policy link is missing",
|
|
1439
|
+
category: "trust-contact",
|
|
1440
|
+
severity: "medium",
|
|
1441
|
+
source: "Privacy links",
|
|
1442
|
+
recommendation: "Link to a privacy policy from every page, usually in the footer, explaining what personal data the site collects through forms, analytics, and cookies and how visitors can contact you about it.",
|
|
1443
|
+
check: ({ $, snapshot }) => hasPrivacyPolicyLink($, snapshot),
|
|
1444
|
+
evidence: ({ $, snapshot }) => privacyEvidence($, snapshot)
|
|
1445
|
+
},
|
|
1446
|
+
{
|
|
1447
|
+
id: "cookie-consent-present",
|
|
1448
|
+
title: "Tracking loads without a cookie consent tool",
|
|
1449
|
+
category: "trust-contact",
|
|
1450
|
+
severity: "medium",
|
|
1451
|
+
source: "Tracking and consent",
|
|
1452
|
+
recommendation: "Add a cookie consent banner that holds advertising and non-essential analytics tags until visitors accept, as UK and EU cookie rules (PECR and the ePrivacy Directive) generally expect, or remove trackers you do not use.",
|
|
1453
|
+
check: ({ $, snapshot }) => (loadedTrackers($).length === 0 && serverTrackingCookies(snapshot).length === 0) || hasConsentTool(snapshot),
|
|
1454
|
+
evidence: ({ $, snapshot }) => consentEvidence($, snapshot)
|
|
1455
|
+
},
|
|
1115
1456
|
{
|
|
1116
1457
|
id: "placeholder-copy-absent",
|
|
1117
1458
|
title: "Placeholder copy is still visible",
|
|
@@ -1246,6 +1587,42 @@ const rules = [
|
|
|
1246
1587
|
.filter((element) => $(element).attr("alt") === undefined).length;
|
|
1247
1588
|
return `${missing} of ${images} images missing alt attributes`;
|
|
1248
1589
|
}
|
|
1590
|
+
},
|
|
1591
|
+
{
|
|
1592
|
+
id: "html-lang-present",
|
|
1593
|
+
title: "Page language is not declared",
|
|
1594
|
+
category: "mobile-usability",
|
|
1595
|
+
severity: "low",
|
|
1596
|
+
source: "HTML lang",
|
|
1597
|
+
recommendation: 'Set the page language on the <html> element, such as lang="en-GB", so screen readers pronounce the text correctly and browsers offer the right translation.',
|
|
1598
|
+
check: ({ $ }) => htmlLangEvidence($) === undefined,
|
|
1599
|
+
evidence: ({ $ }) => htmlLangEvidence($) ?? ""
|
|
1600
|
+
},
|
|
1601
|
+
{
|
|
1602
|
+
id: "form-labels-present",
|
|
1603
|
+
title: "Some form fields have no label",
|
|
1604
|
+
category: "mobile-usability",
|
|
1605
|
+
severity: "low",
|
|
1606
|
+
source: "Form labels",
|
|
1607
|
+
recommendation: "Give every form field a visible label, or at least an aria-label, so screen reader users and people using voice control know what to enter.",
|
|
1608
|
+
check: ({ $ }) => formFields($).unlabelled.length === 0,
|
|
1609
|
+
evidence: ({ $ }) => {
|
|
1610
|
+
const { total, unlabelled } = formFields($);
|
|
1611
|
+
return `${unlabelled.length} of ${total} form field${total === 1 ? "" : "s"} ${unlabelled.length === 1 ? "has" : "have"} no label: ${firstFive(unlabelled)}`;
|
|
1612
|
+
}
|
|
1613
|
+
},
|
|
1614
|
+
{
|
|
1615
|
+
id: "link-text-descriptive",
|
|
1616
|
+
title: "Some links use generic text",
|
|
1617
|
+
category: "search-basics",
|
|
1618
|
+
severity: "low",
|
|
1619
|
+
source: "Link text",
|
|
1620
|
+
recommendation: 'Replace link text such as "Read more" or "Click here" with words that say where the link goes, such as "Dental implant prices".',
|
|
1621
|
+
check: ({ $ }) => genericLinks($).length === 0,
|
|
1622
|
+
evidence: ({ $ }) => {
|
|
1623
|
+
const links = genericLinks($);
|
|
1624
|
+
return `${links.length} link${links.length === 1 ? " uses" : "s use"} generic text: ${firstFive(links)}`;
|
|
1625
|
+
}
|
|
1249
1626
|
}
|
|
1250
1627
|
];
|
|
1251
1628
|
export function runRules(snapshot) {
|
|
@@ -1260,18 +1637,43 @@ export function runRules(snapshot) {
|
|
|
1260
1637
|
export const ruleCount = rules.length;
|
|
1261
1638
|
// Real sites can mention these phrases, so only short pages count as placeholders. Maintenance copy must be
|
|
1262
1639
|
// about the whole site ("Website temporarily down", "Our site is offline") or open the page ("Under maintenance."), so a short page
|
|
1263
|
-
// saying "Our booking system is temporarily unavailable" keeps its normal audit.
|
|
1640
|
+
// saying "Our booking system is temporarily unavailable" keeps its normal audit. Conditional copy such as
|
|
1641
|
+
// "If our site is offline, call us" describes a fallback, not the site's state.
|
|
1264
1642
|
const placeholderTextLimit = 600;
|
|
1265
1643
|
const maintenanceCopy = /(?:^|[.!?]\s+|\b(?:our|this|the) )(?:web)?site (?:is )?(?:temporarily |currently )?(?:down|unavailable|offline|under maintenance)\b|^(?:we(?:'|’)re |we are )?(?:currently |temporarily )?(?:down for|under(?:going)?) (?:scheduled )?maintenance\b/i;
|
|
1266
1644
|
const parkedCopy = /\b(?:(?:this )?domain (?:name )?(?:may be|is) for sale|buy this domain|domain is parked|parked (?:free,? )?(?:courtesy of|by)|this domain has been registered)\b/i;
|
|
1645
|
+
// "Should" is left out: as a condition it inverts the sentence ("Should our site go offline"), which the
|
|
1646
|
+
// notice pattern never matches, and otherwise it is advice ("You should be aware that our site is offline").
|
|
1647
|
+
const conditionalWords = /\b(?:if|when|whenever|unless|in case)\b/i;
|
|
1648
|
+
// The first maintenance notice whose sentence is not conditional; "If for any reason our site is offline,
|
|
1649
|
+
// call us" describes a fallback, not the site's state. Semicolons and line breaks between visible blocks end the clause, so
|
|
1650
|
+
// a "When to contact us" menu item does not hide the notice under it.
|
|
1651
|
+
function maintenanceNotice(text) {
|
|
1652
|
+
for (const match of text.matchAll(new RegExp(maintenanceCopy.source, "gi"))) {
|
|
1653
|
+
// A match opening with sentence punctuation starts a new sentence, so nothing before it applies.
|
|
1654
|
+
const sentence = /^[.!?]/.test(match[0]) ? "" : (text.slice(0, match.index).split(/[.!?;\n]/).pop() ?? "");
|
|
1655
|
+
// A condition right after the notice governs it too ("Our site is offline when scheduled maintenance
|
|
1656
|
+
// occurs"), but a later, separate clause does not ("Our site is offline, so if you need help call us").
|
|
1657
|
+
const governedAfter = /^\s*,?\s*(?:(?:but|and|yet)\s+)?(?:(?:only|just|even|except|usually|sometimes)\s+)?(?:if|when|whenever|unless|in case)\b/i.test(text.slice((match.index ?? 0) + match[0].length));
|
|
1658
|
+
if (!governedAfter && !conditionalWords.test(`${sentence} ${match[0]}`)) {
|
|
1659
|
+
return match;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
return undefined;
|
|
1663
|
+
}
|
|
1267
1664
|
export function detectPlaceholderPage(snapshot) {
|
|
1268
1665
|
const $ = load(snapshot.html);
|
|
1269
|
-
const
|
|
1666
|
+
const title = $("title").text().replace(/\s+/g, " ").trim();
|
|
1667
|
+
const lines = visibleLines(visibleBody($));
|
|
1668
|
+
const body = lines.join("\n");
|
|
1669
|
+
const text = `${title} ${lines.join(" ")}`.trim();
|
|
1270
1670
|
if (text.length > placeholderTextLimit) {
|
|
1271
1671
|
return undefined;
|
|
1272
1672
|
}
|
|
1673
|
+
// The title and body are matched separately, so a notice opening the body is not read as continuing the
|
|
1674
|
+
// title ("Acme Dental" then "Site is temporarily offline.").
|
|
1273
1675
|
const parked = text.match(parkedCopy);
|
|
1274
|
-
const maintenance = parked ? undefined :
|
|
1676
|
+
const maintenance = parked ? undefined : (maintenanceNotice(body) ?? maintenanceNotice(title));
|
|
1275
1677
|
const match = parked ?? maintenance;
|
|
1276
1678
|
if (!match) {
|
|
1277
1679
|
return undefined;
|