@brandlab-digital/conformance 3.3.2 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/current-release.js +5 -5
- package/dist/current-release.js.map +1 -1
- package/dist/engineering.d.ts +7 -0
- package/dist/engineering.d.ts.map +1 -1
- package/dist/engineering.js +35 -1
- package/dist/engineering.js.map +1 -1
- package/dist/estate.d.ts +45 -1
- package/dist/estate.d.ts.map +1 -1
- package/dist/estate.js +243 -8
- package/dist/estate.js.map +1 -1
- package/dist/families-e2e.d.ts +21 -0
- package/dist/families-e2e.d.ts.map +1 -1
- package/dist/families-e2e.js +69 -1
- package/dist/families-e2e.js.map +1 -1
- package/dist/page-standard.d.ts +198 -0
- package/dist/page-standard.d.ts.map +1 -0
- package/dist/page-standard.js +536 -0
- package/dist/page-standard.js.map +1 -0
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +14 -0
- package/dist/project.js.map +1 -1
- package/dist/rule-registry.d.ts +15 -0
- package/dist/rule-registry.d.ts.map +1 -1
- package/dist/rule-registry.js +3 -0
- package/dist/rule-registry.js.map +1 -1
- package/dist/static-rules.d.ts +2 -0
- package/dist/static-rules.d.ts.map +1 -1
- package/dist/static-rules.js +176 -0
- package/dist/static-rules.js.map +1 -1
- package/package.json +2 -1
package/dist/estate.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"estate.d.ts","sourceRoot":"","sources":["../src/estate.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"estate.d.ts","sourceRoot":"","sources":["../src/estate.ts"],"names":[],"mappings":"AAmCA,OAAO,EAAgB,KAAK,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACxE,OAAO,EAkBL,KAAK,sBAAsB,EAE3B,KAAK,WAAW,EAChB,KAAK,kBAAkB,EACxB,MAAM,oBAAoB,CAAC;AAG5B,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,OAAO,GAAG,gBAAgB,GAAG,kBAAkB,CAAC;IAC1D,+FAA+F;IAC/F,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,yDAAyD;IACzD,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,gBAAgB,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACzF;AAED,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;IACpC,gFAAgF;IAChF,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;GAIG;AACH,MAAM,WAAW,oBAAoB;IACnC,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAC;IAChB,yFAAyF;IACzF,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IACzB,2DAA2D;IAC3D,QAAQ,EAAE,mBAAmB,CAAC,UAAU,CAAC,CAAC;CAC3C;AAED,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE;QAAE,YAAY,EAAE,MAAM,GAAG,GAAG,CAAA;KAAE,CAAC;IACrC,wFAAwF;IACxF,MAAM,EAAE,SAAS,mBAAmB,EAAE,CAAC;IACvC;;;;;OAKG;IACH,SAAS,CAAC,EAAE,SAAS,sBAAsB,EAAE,CAAC;IAC9C,6FAA6F;IAC7F,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,oGAAoG;IACpG,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,SAAS,oBAAoB,EAAE,CAAC;IAC3C;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,sBAAsB,GAAG,IAAI,CAAC;IACjD,8FAA8F;IAC9F,UAAU,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IAC5B,uFAAuF;IACvF,QAAQ,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACjD,qFAAqF;IACrF,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAC9B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,8BAA8B;IAC7C,IAAI,EAAE;QAAE,YAAY,EAAE,MAAM,GAAG,GAAG,CAAA;KAAE,CAAC;IACrC,MAAM,EAAE,SAAS,mBAAmB,EAAE,CAAC;IACvC,2GAA2G;IAC3G,UAAU,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IAC5B,yFAAyF;IACzF,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAC9B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,0BAA0B;IACzC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EACA,UAAU,GACV,OAAO,GACP,cAAc,GACd,iBAAiB,GACjB,UAAU,GACV,aAAa,GACb,oBAAoB,GACpB,uBAAuB,GACvB,WAAW,GACX,YAAY,GACZ,iBAAiB,GACjB,QAAQ,GACR,cAAc,GACd,QAAQ,GACR,wBAAwB,GACxB,iBAAiB,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;CAChB;AAoBD,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,yBAAyB,QA2P3E;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,OAAO,EAAE,GAAG,MAAM,EAAE,CA0B3E;AAED,4HAA4H;AAC5H,wBAAgB,cAAc,CAAC,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAUjH;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAAC,OAAO,EAAE,iBAAiB,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAkB9G;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,iBAAiB,EAC1B,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,WAAW,EAAE,CAAA;CAAE,CAAC,CAenD;AAED,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAG7D;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,SAAS,mBAAmB,EAAE,GAAG,MAAM,EAAE,CAQrF;AAED,oFAAoF;AACpF,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,EAAE,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,GAAG;IAAE,MAAM,EAAE,MAAM,EAAE,CAAC;IAAC,UAAU,EAAE,MAAM,EAAE,CAAA;CAAE,CAK5I;AAED,6GAA6G;AAC7G,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,OAAO,CAAC,EAAE,SAAS,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,MAAM,GAAG,SAAS,CAEhI;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,SAAS,GAAG,OAAO,CAAC,GAAG,MAAM,EAAE,CAQrG;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQ7C;AAED,iHAAiH;AACjH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEvD;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,IAAI,CAAC,sBAAsB,EAAE,QAAQ,GAAG,aAAa,CAAC,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,GAAG,SAAS,CAajK;AAaD;;;;;;;;;GASG;AACH,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,8BAA8B,QAgIrF;AA+CD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAElE"}
|
package/dist/estate.js
CHANGED
|
@@ -10,16 +10,39 @@
|
|
|
10
10
|
* defineBrandlabEstateTests({ site, routes, redirects })
|
|
11
11
|
*
|
|
12
12
|
* It asserts the estate and its routes: the sitemap, each route's status,
|
|
13
|
-
* canonical and
|
|
14
|
-
*
|
|
13
|
+
* canonical, indexing and breadcrumb JSON-LD (every item but the last names its
|
|
14
|
+
* page, from 3.3.3), internal links, redirects, unknown slugs, the page families
|
|
15
|
+
* rendered on request and robots.txt. Page and structured-data
|
|
15
16
|
* quality is reported, never enforced,
|
|
16
17
|
* by defineBrandlabPageQualityTests: a backend release keeps production's pages
|
|
17
18
|
* as they are, which the comparison harness proves. Counts are always derived
|
|
18
19
|
* from the current catalogue, never a historic constant.
|
|
20
|
+
*
|
|
21
|
+
* From 3.4.0 (the Brandlab on-page and location standard) the estate journey
|
|
22
|
+
* also fails on: a sitemap that is not split by page family once the catalogue
|
|
23
|
+
* spans families, or whose lastmod is not the route's content date; an
|
|
24
|
+
* indexable page without a title, meta description or JSON-LD in its server HTML; FAQPage or
|
|
25
|
+
* HowTo markup, or a rating on the business itself; a homepage without a
|
|
26
|
+
* WebSite node (name and url); an unknown slug that does not answer 404 to GET
|
|
27
|
+
* and HEAD; a nearby list on a location page that is not ranked by distance
|
|
28
|
+
* between suburb centres; and a catalogue that adds indexable location pages
|
|
29
|
+
* beyond the recorded capacity (`locationCapacity`). The page-quality journey
|
|
30
|
+
* reports title, heading, description and HTML-size warnings, orphan pages,
|
|
31
|
+
* links from indexable pages to noindex location pages, centerpiece
|
|
32
|
+
* similarity (warn at 0.5, review list at 0.7) and the location review list
|
|
33
|
+
* (location-review.json); pages on that list stay indexed.
|
|
19
34
|
*/
|
|
20
35
|
import { request as httpRequest } from 'node:http';
|
|
21
36
|
import { expect, test } from '@playwright/test';
|
|
37
|
+
import { buildLocationReviewList, centerpieceSimilarity, checkLocationCapacity, descriptionLengthProblem, HTML_MAX_BYTES, headingTexts, loadBrandlabGeography, neighbourOrderProblems, parseSitemapEntries, SIMILARITY_REVIEW, SIMILARITY_WARN, sitemapFamilyProblems, sitemapLastmodProblems, structuredDataProblems, textBlocks, titleFormatProblems, titleHeadingProblems, } from './page-standard.js';
|
|
22
38
|
import { extractSearchCertificationHtmlSnapshot } from './search-certification.js';
|
|
39
|
+
const LOCATION_KINDS = new Set(['location', 'service-location']);
|
|
40
|
+
/** A location route's place slug: the last segment of its path. */
|
|
41
|
+
function routePlace(route) {
|
|
42
|
+
if (!route || !LOCATION_KINDS.has(route.kind ?? ''))
|
|
43
|
+
return undefined;
|
|
44
|
+
return normalisePath(route.path).split('/').filter(Boolean).at(-1);
|
|
45
|
+
}
|
|
23
46
|
const BUSINESS_TYPES = new Set([
|
|
24
47
|
'LocalBusiness', 'HomeAndConstructionBusiness', 'GeneralContractor', 'Plumber', 'Electrician',
|
|
25
48
|
'RoofingContractor', 'HousePainter', 'Locksmith', 'MovingCompany', 'HVACBusiness', 'ProfessionalService',
|
|
@@ -43,6 +66,8 @@ export function defineBrandlabEstateTests(options) {
|
|
|
43
66
|
const timeout = options.timeout ?? 240_000;
|
|
44
67
|
const families = options.families ?? [];
|
|
45
68
|
const familyPaths = new Set(families.flatMap((family) => family.paths.map(normalisePath)));
|
|
69
|
+
const routeByPath = new Map(routes.map((route) => [normalisePath(route.path), route]));
|
|
70
|
+
const homePath = normalisePath(routes.find((route) => route.kind === 'home')?.path ?? '/');
|
|
46
71
|
test.describe('Brandlab technical estate', () => {
|
|
47
72
|
test('the sitemap lists exactly the indexable catalogue routes', async ({ request }) => {
|
|
48
73
|
test.setTimeout(timeout);
|
|
@@ -53,8 +78,27 @@ export function defineBrandlabEstateTests(options) {
|
|
|
53
78
|
expect(listed.length, 'the sitemap repeats a URL').toBe(new Set(listed).size);
|
|
54
79
|
expect([...listed].sort()).toEqual([...new Set(expected)].sort());
|
|
55
80
|
});
|
|
81
|
+
test('the sitemap is split by page family and dated by content, never by the build', async ({ request }, testInfo) => {
|
|
82
|
+
test.setTimeout(timeout);
|
|
83
|
+
const { index, files } = await readSitemapFiles(request, '/sitemap.xml', origin);
|
|
84
|
+
const problems = [
|
|
85
|
+
...sitemapFamilyProblems({ index, files, routes, origin }),
|
|
86
|
+
...sitemapLastmodProblems(files.flatMap((file) => file.entries), routes, origin),
|
|
87
|
+
];
|
|
88
|
+
await testInfo.attach('sitemap-files.json', {
|
|
89
|
+
body: JSON.stringify(files.map((file) => ({ path: file.path, urls: file.entries.length })), null, 2),
|
|
90
|
+
contentType: 'application/json',
|
|
91
|
+
});
|
|
92
|
+
expect(problems).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
test('location pages stay within the recorded capacity', async ({}, testInfo) => {
|
|
95
|
+
const result = checkLocationCapacity(routes, options.locationCapacity);
|
|
96
|
+
testInfo.annotations.push({ type: result.limit === null ? 'warning' : 'location-capacity', description: result.detail });
|
|
97
|
+
expect(result.blocked, result.detail).toBe(false);
|
|
98
|
+
});
|
|
56
99
|
test('every catalogue route serves its canonical identity and links only to live pages', async ({ request }, testInfo) => {
|
|
57
100
|
test.setTimeout(timeout);
|
|
101
|
+
const centreOf = options.centreOf ?? (await loadBrandlabGeography()).centreOf;
|
|
58
102
|
const failures = [];
|
|
59
103
|
const internalLinks = new Map();
|
|
60
104
|
const checked = [];
|
|
@@ -90,6 +134,36 @@ export function defineBrandlabEstateTests(options) {
|
|
|
90
134
|
failures.push(`${route.path}: indexable route is noindex`);
|
|
91
135
|
if (route.indexing !== 'index' && !/noindex/i.test(robots))
|
|
92
136
|
failures.push(`${route.path}: ${route.indexing} route is indexable`);
|
|
137
|
+
for (const problem of breadcrumbListProblems(snapshot.jsonLd))
|
|
138
|
+
failures.push(`${route.path}: ${problem}`);
|
|
139
|
+
if (route.indexing === 'index') {
|
|
140
|
+
if (!snapshot.title)
|
|
141
|
+
failures.push(`${route.path}: no <title> in the server HTML`);
|
|
142
|
+
if (!snapshot.description)
|
|
143
|
+
failures.push(`${route.path}: no meta description in the server HTML`);
|
|
144
|
+
if (snapshot.jsonLd.length === 0)
|
|
145
|
+
failures.push(`${route.path}: no JSON-LD in the server HTML`);
|
|
146
|
+
}
|
|
147
|
+
if (snapshot.jsonLd.some((payload) => payload.parseError))
|
|
148
|
+
failures.push(`${route.path}: invalid JSON-LD`);
|
|
149
|
+
for (const problem of structuredDataProblems(snapshot.jsonLd, {
|
|
150
|
+
origin,
|
|
151
|
+
home: normalisePath(route.path) === homePath,
|
|
152
|
+
businessId: options.businessId,
|
|
153
|
+
business: options.business,
|
|
154
|
+
}))
|
|
155
|
+
failures.push(`${route.path}: ${problem}`);
|
|
156
|
+
const place = routePlace(route);
|
|
157
|
+
if (place && centreOf(place)) {
|
|
158
|
+
for (const problem of neighbourOrderProblems({
|
|
159
|
+
html,
|
|
160
|
+
origin: place,
|
|
161
|
+
pageUrl: expectedCanonical,
|
|
162
|
+
placeOf: (path) => routePlace(routeByPath.get(path)),
|
|
163
|
+
centreOf,
|
|
164
|
+
}))
|
|
165
|
+
failures.push(`${route.path}: ${problem}`);
|
|
166
|
+
}
|
|
93
167
|
for (const href of extractLinks(html)) {
|
|
94
168
|
const path = internalPath(href, expectedCanonical, origin);
|
|
95
169
|
if (path && !internalLinks.has(path))
|
|
@@ -213,9 +287,14 @@ export function defineBrandlabEstateTests(options) {
|
|
|
213
287
|
testInfo.annotations.push({ type: 'skipped', description: `${path}: a redirect rule answers it` });
|
|
214
288
|
const failures = [];
|
|
215
289
|
for (const path of paths) {
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
290
|
+
const [get, head] = await Promise.all([
|
|
291
|
+
request.get(path, { maxRedirects: 0 }),
|
|
292
|
+
request.head(path, { maxRedirects: 0 }),
|
|
293
|
+
]);
|
|
294
|
+
if (get.status() !== 404)
|
|
295
|
+
failures.push(`${path}: GET ${get.status()} (expected 404)`);
|
|
296
|
+
if (head.status() !== 404)
|
|
297
|
+
failures.push(`${path}: HEAD ${head.status()} (expected 404)`);
|
|
219
298
|
}
|
|
220
299
|
expect(failures).toEqual([]);
|
|
221
300
|
});
|
|
@@ -237,6 +316,43 @@ export function defineBrandlabEstateTests(options) {
|
|
|
237
316
|
});
|
|
238
317
|
});
|
|
239
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* Why a page's BreadcrumbList JSON-LD would fail Google's breadcrumb rich result (3.3.3): every
|
|
321
|
+
* ListItem but the last needs its `item` (the page it names), as the shared builder requires;
|
|
322
|
+
* Google reports "Missing field item" otherwise. Lists inside an `@graph` count; an empty list
|
|
323
|
+
* has nothing to judge.
|
|
324
|
+
*/
|
|
325
|
+
export function breadcrumbListProblems(jsonLd) {
|
|
326
|
+
const lists = [];
|
|
327
|
+
const collect = (value) => {
|
|
328
|
+
if (Array.isArray(value)) {
|
|
329
|
+
value.forEach(collect);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (!value || typeof value !== 'object')
|
|
333
|
+
return;
|
|
334
|
+
const node = value;
|
|
335
|
+
const type = node['@type'];
|
|
336
|
+
if (type === 'BreadcrumbList' || (Array.isArray(type) && type.includes('BreadcrumbList')))
|
|
337
|
+
lists.push(node);
|
|
338
|
+
if (Array.isArray(node['@graph']))
|
|
339
|
+
collect(node['@graph']);
|
|
340
|
+
};
|
|
341
|
+
collect(jsonLd);
|
|
342
|
+
const problems = [];
|
|
343
|
+
for (const list of lists) {
|
|
344
|
+
const items = Array.isArray(list.itemListElement) ? list.itemListElement : [];
|
|
345
|
+
items.slice(0, -1).forEach((entry, index) => {
|
|
346
|
+
const item = entry && typeof entry === 'object' ? entry.item : undefined;
|
|
347
|
+
const named = typeof item === 'string' ? item.trim() : item && typeof item === 'object' ? String(item['@id'] ?? item.url ?? '').trim() : '';
|
|
348
|
+
if (named)
|
|
349
|
+
return;
|
|
350
|
+
const name = entry && typeof entry === 'object' ? entry.name : undefined;
|
|
351
|
+
problems.push(`BreadcrumbList item ${index + 1}${typeof name === 'string' && name.trim() ? ` ("${name.trim()}")` : ''} has no URL (item); only the last breadcrumb may omit it, or Google rejects the list`);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
return problems;
|
|
355
|
+
}
|
|
240
356
|
/** One response header for a request that names its own Host (the Playwright request context always sends the server's). */
|
|
241
357
|
export function responseHeader(server, host, path, name) {
|
|
242
358
|
return new Promise((resolve, reject) => {
|
|
@@ -275,6 +391,29 @@ export async function readSitemap(request, path, origin) {
|
|
|
275
391
|
}
|
|
276
392
|
return nested;
|
|
277
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* The sitemap's URL files with their entries: the sitemap itself, or every
|
|
396
|
+
* file a sitemap index lists (one level, same origin, no redirects).
|
|
397
|
+
*/
|
|
398
|
+
export async function readSitemapFiles(request, path, origin) {
|
|
399
|
+
const read = async (target) => {
|
|
400
|
+
const response = await request.get(target, { maxRedirects: 0 });
|
|
401
|
+
if (response.status() !== 200)
|
|
402
|
+
throw new Error(`${target} answers ${response.status()}; a sitemap must answer 200 without redirecting.`);
|
|
403
|
+
return response.text();
|
|
404
|
+
};
|
|
405
|
+
const xml = await read(path);
|
|
406
|
+
if (!/<sitemapindex\b/i.test(xml))
|
|
407
|
+
return { index: false, files: [{ path, entries: parseSitemapEntries(xml) }] };
|
|
408
|
+
const files = [];
|
|
409
|
+
for (const location of extractSitemapLocations(xml)) {
|
|
410
|
+
const url = new URL(location);
|
|
411
|
+
if (url.origin !== new URL(origin).origin)
|
|
412
|
+
throw new Error(`The sitemap index lists ${location}, which is not on ${new URL(origin).origin}.`);
|
|
413
|
+
files.push({ path: url.pathname, entries: parseSitemapEntries(await read(`${url.pathname}${url.search}`)) });
|
|
414
|
+
}
|
|
415
|
+
return { index: true, files };
|
|
416
|
+
}
|
|
278
417
|
export function extractSitemapLocations(xml) {
|
|
279
418
|
return [...xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)].map((match) => new URL(decodeXml(match[1])).href);
|
|
280
419
|
}
|
|
@@ -384,10 +523,17 @@ export function defineBrandlabPageQualityTests(options) {
|
|
|
384
523
|
const businessId = options.businessId ?? `${origin}/#business`;
|
|
385
524
|
const timeout = options.timeout ?? 240_000;
|
|
386
525
|
const routes = options.routes.filter((route) => normalisePath(route.path) !== NOT_FOUND_ROUTE);
|
|
526
|
+
const routeByPath = new Map(routes.map((route) => [normalisePath(route.path), route]));
|
|
387
527
|
test.describe('Brandlab page quality (advisory)', () => {
|
|
388
528
|
test('reports page and structured-data quality without failing', async ({ request }, testInfo) => {
|
|
389
529
|
test.setTimeout(timeout);
|
|
390
530
|
const findings = [];
|
|
531
|
+
const geography = await loadBrandlabGeography();
|
|
532
|
+
const centreOf = options.centreOf ?? geography.centreOf;
|
|
533
|
+
const titles = new Map();
|
|
534
|
+
const descriptions = new Map();
|
|
535
|
+
const similarityPages = [];
|
|
536
|
+
const linksFrom = new Map();
|
|
391
537
|
await eachWithConcurrency(routes, options.concurrency ?? 8, async (route) => {
|
|
392
538
|
const response = await request.get(route.path, { maxRedirects: 0 });
|
|
393
539
|
// The estate journey owns status codes; quality is read from the pages that load.
|
|
@@ -396,13 +542,35 @@ export function defineBrandlabPageQualityTests(options) {
|
|
|
396
542
|
const html = await response.text();
|
|
397
543
|
const snapshot = extractSearchCertificationHtmlSnapshot(html);
|
|
398
544
|
const add = (kind, detail) => findings.push({ path: route.path, kind, detail });
|
|
399
|
-
const
|
|
400
|
-
if (
|
|
401
|
-
add('headings', `${
|
|
545
|
+
const h1s = headingTexts(html);
|
|
546
|
+
if (h1s.length !== 1)
|
|
547
|
+
add('headings', `${h1s.length} h1 headings`);
|
|
402
548
|
if (!snapshot.title)
|
|
403
549
|
add('title', 'no title');
|
|
404
550
|
if (!snapshot.description)
|
|
405
551
|
add('description', 'no meta description');
|
|
552
|
+
const indexable = route.indexing === 'index';
|
|
553
|
+
if (indexable && snapshot.title) {
|
|
554
|
+
const formatProblems = titleFormatProblems(snapshot.title);
|
|
555
|
+
if (formatProblems.length > 0)
|
|
556
|
+
add('title-format', `"${snapshot.title}": ${formatProblems.join('; ')}`);
|
|
557
|
+
titles.set(snapshot.title, [...(titles.get(snapshot.title) ?? []), route.path]);
|
|
558
|
+
const place = routePlace(route);
|
|
559
|
+
if (h1s.length === 1) {
|
|
560
|
+
const problems = titleHeadingProblems(snapshot.title, h1s[0], place ? geography.placeNames.get(place) : undefined);
|
|
561
|
+
if (problems.length > 0)
|
|
562
|
+
add('title-h1', problems.join('; '));
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (indexable && snapshot.description) {
|
|
566
|
+
const problem = descriptionLengthProblem(snapshot.description);
|
|
567
|
+
if (problem)
|
|
568
|
+
add('description-length', problem);
|
|
569
|
+
descriptions.set(snapshot.description, [...(descriptions.get(snapshot.description) ?? []), route.path]);
|
|
570
|
+
}
|
|
571
|
+
const bytes = Buffer.byteLength(html);
|
|
572
|
+
if (bytes > HTML_MAX_BYTES)
|
|
573
|
+
add('html-size', `${(bytes / 1_048_576).toFixed(2)} MB of HTML (investigate anything over 1 MB)`);
|
|
406
574
|
const expectedCanonical = canonical(route.canonicalPath ?? route.path);
|
|
407
575
|
const ogUrl = snapshot.openGraph['og:url'];
|
|
408
576
|
if (ogUrl && new URL(ogUrl, origin).href !== expectedCanonical)
|
|
@@ -412,13 +580,80 @@ export function defineBrandlabPageQualityTests(options) {
|
|
|
412
580
|
for (const failure of inspectStructuredData(route, snapshot.jsonLd, businessId)) {
|
|
413
581
|
add('structured-data', failure.slice(route.path.length + 2));
|
|
414
582
|
}
|
|
583
|
+
const place = routePlace(route);
|
|
584
|
+
if (indexable && place)
|
|
585
|
+
similarityPages.push({ path: route.path, place, blocks: textBlocks(html).map(geography.maskPlaces) });
|
|
586
|
+
const targets = new Set();
|
|
587
|
+
for (const href of extractLinks(html)) {
|
|
588
|
+
const path = internalPath(href, expectedCanonical, origin);
|
|
589
|
+
if (path && path !== normalisePath(route.path))
|
|
590
|
+
targets.add(path);
|
|
591
|
+
}
|
|
592
|
+
linksFrom.set(normalisePath(route.path), targets);
|
|
415
593
|
});
|
|
594
|
+
// Internal links: every indexable page needs a link from another page, and indexable pages link no noindex location page.
|
|
595
|
+
const inbound = new Map();
|
|
596
|
+
for (const [from, targets] of linksFrom) {
|
|
597
|
+
const source = routeByPath.get(from);
|
|
598
|
+
for (const target of targets) {
|
|
599
|
+
inbound.set(target, (inbound.get(target) ?? 0) + 1);
|
|
600
|
+
const linked = routeByPath.get(target);
|
|
601
|
+
if (source?.indexing === 'index' && linked && linked.indexing !== 'index' && LOCATION_KINDS.has(linked.kind ?? '')) {
|
|
602
|
+
findings.push({ path: from, kind: 'noindex-link', detail: `links to ${target}, a noindex location page (list unindexed places as plain text)` });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
for (const route of routes) {
|
|
607
|
+
const path = normalisePath(route.path);
|
|
608
|
+
if (route.indexing === 'index' && route.kind !== 'home' && path !== '/' && linksFrom.has(path) && !inbound.get(path)) {
|
|
609
|
+
findings.push({ path: route.path, kind: 'orphan', detail: 'no other catalogue page links to it' });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
for (const [title, paths] of titles) {
|
|
613
|
+
if (paths.length > 1)
|
|
614
|
+
for (const path of paths)
|
|
615
|
+
findings.push({ path, kind: 'title-duplicate', detail: `"${title}" is also the title of ${paths.filter((other) => other !== path).slice(0, 3).join(', ')}${paths.length > 4 ? ' and others' : ''}` });
|
|
616
|
+
}
|
|
617
|
+
for (const [description, paths] of descriptions) {
|
|
618
|
+
if (paths.length > 1)
|
|
619
|
+
for (const path of paths)
|
|
620
|
+
findings.push({ path, kind: 'description-duplicate', detail: `the meta description is shared with ${paths.length - 1} other page(s) ("${description.slice(0, 60)}…")` });
|
|
621
|
+
}
|
|
622
|
+
// Location pages: centerpiece similarity against near siblings, by route kind.
|
|
623
|
+
const similarity = [...new Set(similarityPages.map((page) => routeByPath.get(normalisePath(page.path))?.kind))]
|
|
624
|
+
.flatMap((kind) => centerpieceSimilarity(similarityPages.filter((page) => routeByPath.get(normalisePath(page.path))?.kind === kind), centreOf));
|
|
625
|
+
for (const score of similarity) {
|
|
626
|
+
if (score.score < SIMILARITY_WARN)
|
|
627
|
+
continue;
|
|
628
|
+
findings.push({
|
|
629
|
+
path: score.path,
|
|
630
|
+
kind: 'similarity',
|
|
631
|
+
detail: `${score.score.toFixed(2)} of its page-specific phrases also appear on ${score.sibling ?? 'a near sibling'}${score.score >= SIMILARITY_REVIEW ? ' (at or above 0.7: on the review list; the page stays indexed)' : ' (at or above 0.5: add page-specific evidence)'}`,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
const reviewList = buildLocationReviewList(routes);
|
|
635
|
+
const flagged = new Set(similarity.filter((score) => score.score >= SIMILARITY_REVIEW).map((score) => score.path));
|
|
636
|
+
const review = [
|
|
637
|
+
...reviewList.map((item) => ({ ...item, reasons: [...item.reasons, ...(flagged.has(item.path) ? ['centerpiece similarity at or above 0.7'] : [])] })),
|
|
638
|
+
...[...flagged].filter((path) => !reviewList.some((item) => item.path === path)).map((path) => ({ path, evidence: routeByPath.get(normalisePath(path))?.locationDecision?.evidence ?? [], reasons: ['centerpiece similarity at or above 0.7'] })),
|
|
639
|
+
].sort((a, b) => a.path.localeCompare(b.path));
|
|
640
|
+
for (const item of review)
|
|
641
|
+
findings.push({ path: item.path, kind: 'location-review', detail: item.reasons.join('; ') });
|
|
416
642
|
findings.sort((a, b) => a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind));
|
|
417
643
|
const byKind = Object.fromEntries([...new Set(findings.map((finding) => finding.kind))].sort().map((kind) => [kind, findings.filter((finding) => finding.kind === kind).length]));
|
|
418
644
|
await testInfo.attach('page-quality.json', {
|
|
419
645
|
body: JSON.stringify({ pages: routes.length, findings: findings.length, byKind, details: findings }, null, 2),
|
|
420
646
|
contentType: 'application/json',
|
|
421
647
|
});
|
|
648
|
+
await testInfo.attach('location-review.json', {
|
|
649
|
+
body: JSON.stringify({
|
|
650
|
+
reviewed: review.length,
|
|
651
|
+
note: 'Pages on the review list stay indexed. Each review ends in adding evidence, merging into the nearest owner or hiding with noindex, follow; merging and hiding are Romie\'s decision.',
|
|
652
|
+
similarity: similarity.sort((a, b) => b.score - a.score),
|
|
653
|
+
review,
|
|
654
|
+
}, null, 2),
|
|
655
|
+
contentType: 'application/json',
|
|
656
|
+
});
|
|
422
657
|
testInfo.annotations.push({
|
|
423
658
|
type: 'advisory',
|
|
424
659
|
description: `${findings.length} page-quality finding(s) across ${routes.length} page(s)${findings.length ? `: ${Object.entries(byKind).map(([kind, count]) => `${kind} ${count}`).join(', ')}` : ''}; see page-quality.json.`,
|