optbrew 0.1.2 → 0.2.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/.claude-plugin/marketplace.json +4 -4
- package/.claude-plugin/plugin.json +2 -1
- package/.codex-plugin/plugin.json +2 -2
- package/AGENT_INSTALL.md +94 -0
- package/CHANGELOG.md +74 -1
- package/README.ko.md +129 -105
- package/README.md +129 -105
- package/THIRD_PARTY_NOTICES.md +88 -0
- package/docs/platforms.md +45 -8
- package/docs/release.md +37 -4
- package/lib/cli.mjs +5 -2
- package/lib/install.mjs +2 -1
- package/lib/records.mjs +2 -1
- package/lib/transaction.mjs +12 -5
- package/lib/update.mjs +3 -2
- package/package.json +29 -9
- package/plugin.json +2 -2
- package/skills/.claude-plugin/icon.png +0 -0
- package/skills/.claude-plugin/plugin.json +23 -0
- package/skills/README.md +27 -0
- package/skills/optbrew/SKILL.md +223 -8
- package/skills/optbrew/THIRD_PARTY_NOTICES.md +88 -0
- package/skills/optbrew/bundle-manifest.json +409 -49
- package/skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs +4 -4
- package/skills/optbrew/references/ai-crawlers.json +1 -1
- package/skills/optbrew/references/ai-crawlers.md +1 -1
- package/skills/optbrew/references/api.md +6 -4
- package/skills/optbrew/references/check-registry.json +16 -16
- package/skills/optbrew/references/chrome-paths.json +31 -0
- package/skills/optbrew/references/endpoints.json +10 -0
- package/skills/optbrew/references/harness-signals.json +5 -0
- package/skills/optbrew/references/intake.md +76 -5
- package/skills/optbrew/references/judging.md +233 -0
- package/skills/optbrew/references/limits.json +26 -0
- package/skills/optbrew/references/measurement.md +1 -1
- package/skills/optbrew/references/messages.ko.json +170 -0
- package/skills/optbrew/references/report.md +18 -6
- package/skills/optbrew/references/routing.md +2 -2
- package/skills/optbrew/references/runbooks/anthropic-api-key.md +34 -0
- package/skills/optbrew/references/runbooks/chrome-install.md +56 -0
- package/skills/optbrew/references/runbooks/cwv-site-collection.md +38 -0
- package/skills/optbrew/references/runbooks/google-api-key.md +51 -0
- package/skills/optbrew/references/runbooks/gsc-export.md +32 -0
- package/skills/optbrew/references/runbooks/index.json +18 -0
- package/skills/optbrew/references/runbooks/key-set-claude-cloud.md +32 -0
- package/skills/optbrew/references/runbooks/key-set-codex.md +33 -0
- package/skills/optbrew/references/runbooks/key-set-local.md +40 -0
- package/skills/optbrew/references/runbooks/naver-searchadvisor-export.md +32 -0
- package/skills/optbrew/references/runbooks/network-claude-cloud-custom.md +33 -0
- package/skills/optbrew/references/runbooks/network-codex-approval.md +26 -0
- package/skills/optbrew/references/runbooks/network-codex-config.md +36 -0
- package/skills/optbrew/references/runbooks/seranking.md +38 -0
- package/skills/optbrew/references/schemas/contract.schema.json +42 -15
- package/skills/optbrew/references/scoring.md +40 -5
- package/skills/optbrew/references/source-analysis.md +40 -0
- package/skills/optbrew/references/versions.json +9 -0
- package/skills/optbrew/scripts/adapters/anthropic.mjs +5 -5
- package/skills/optbrew/scripts/adapters/imports.mjs +138 -1
- package/skills/optbrew/scripts/audit.mjs +10 -96
- package/skills/optbrew/scripts/cli.mjs +5 -2
- package/skills/optbrew/scripts/core/bundle.mjs +30 -13
- package/skills/optbrew/scripts/core/capabilities.mjs +8 -4
- package/skills/optbrew/scripts/core/cdp.mjs +105 -0
- package/skills/optbrew/scripts/core/checks/canonical-consistency.mjs +56 -0
- package/skills/optbrew/scripts/core/checks/cwv-field.mjs +77 -0
- package/skills/optbrew/scripts/core/checks/discovery.mjs +43 -0
- package/skills/optbrew/scripts/core/checks/document-title.mjs +62 -0
- package/skills/optbrew/scripts/core/checks/entity-identity.mjs +31 -0
- package/skills/optbrew/scripts/core/checks/fact-consistency.mjs +31 -0
- package/skills/optbrew/scripts/core/checks/fetch-response.mjs +18 -0
- package/skills/optbrew/scripts/core/checks/index-policy.mjs +16 -0
- package/skills/optbrew/scripts/core/checks/index.mjs +18 -0
- package/skills/optbrew/scripts/core/checks/internal-navigation.mjs +67 -0
- package/skills/optbrew/scripts/core/checks/locale-links.mjs +55 -0
- package/skills/optbrew/scripts/core/checks/naver-markup.mjs +21 -0
- package/skills/optbrew/scripts/core/checks/observation.mjs +37 -0
- package/skills/optbrew/scripts/core/checks/prepare.mjs +103 -0
- package/skills/optbrew/scripts/core/checks/render-content.mjs +104 -0
- package/skills/optbrew/scripts/core/checks/search-crawl-policy.mjs +29 -0
- package/skills/optbrew/scripts/core/checks/structured-data-truth.mjs +46 -0
- package/skills/optbrew/scripts/core/chrome-temp.mjs +68 -0
- package/skills/optbrew/scripts/core/chrome.mjs +546 -0
- package/skills/optbrew/scripts/core/collector.mjs +65 -59
- package/skills/optbrew/scripts/core/compare.mjs +18 -1
- package/skills/optbrew/scripts/core/config.mjs +31 -0
- package/skills/optbrew/scripts/core/contracts.mjs +31 -2
- package/skills/optbrew/scripts/core/cwv.mjs +100 -0
- package/skills/optbrew/scripts/core/derive.mjs +10 -161
- package/skills/optbrew/scripts/core/evidence-store.mjs +112 -0
- package/skills/optbrew/scripts/core/facts.mjs +203 -19
- package/skills/optbrew/scripts/core/findings.mjs +19 -1
- package/skills/optbrew/scripts/core/html.mjs +41 -3
- package/skills/optbrew/scripts/core/index-targets.mjs +118 -0
- package/skills/optbrew/scripts/core/judgments.mjs +675 -0
- package/skills/optbrew/scripts/core/keys.mjs +86 -0
- package/skills/optbrew/scripts/core/link-graph.mjs +93 -0
- package/skills/optbrew/scripts/core/messages.mjs +23 -0
- package/skills/optbrew/scripts/core/metrics.mjs +4 -2
- package/skills/optbrew/scripts/core/migration.mjs +37 -0
- package/skills/optbrew/scripts/core/page-select.mjs +136 -0
- package/skills/optbrew/scripts/core/probe.mjs +83 -0
- package/skills/optbrew/scripts/core/protocol.mjs +38 -14
- package/skills/optbrew/scripts/core/render.mjs +160 -0
- package/skills/optbrew/scripts/core/report-compose.mjs +377 -0
- package/skills/optbrew/scripts/core/report-html.mjs +198 -0
- package/skills/optbrew/scripts/core/report-page.mjs +14 -0
- package/skills/optbrew/scripts/core/report.mjs +357 -131
- package/skills/optbrew/scripts/core/runbooks.mjs +63 -0
- package/skills/optbrew/scripts/core/scope.mjs +23 -5
- package/skills/optbrew/scripts/core/score.mjs +12 -4
- package/skills/optbrew/scripts/core/secrets.mjs +2 -0
- package/skills/optbrew/scripts/core/stages.mjs +694 -0
- package/skills/optbrew/scripts/core/transport.mjs +157 -0
- package/skills/optbrew/scripts/core/weights.mjs +2 -1
- package/skills/optbrew/scripts/key.mjs +90 -0
- package/skills/optbrew/scripts/preflight.mjs +176 -0
- package/skills/optbrew/scripts/run.mjs +81 -0
- package/skills/optbrew/source-lock.json +1 -1
- package/source-lock.json +1 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { cwvVerdict } from '../contracts.mjs';
|
|
2
|
+
import { thresholds } from '../config.mjs';
|
|
3
|
+
import { HTTP_OK } from '../cwv.mjs';
|
|
4
|
+
import { makeObservation, problem, unitKey } from './observation.mjs';
|
|
5
|
+
|
|
6
|
+
const UNAVAILABLE = 'field_data_unavailable';
|
|
7
|
+
const P75 = 75;
|
|
8
|
+
// PageSpeed Insights gives the CLS percentile as an integer, 100 times the score (0.1 reads 10).
|
|
9
|
+
const PSI_CLS_PERCENTILE_SCALE = 100;
|
|
10
|
+
const SCALE_TOLERANCE = 1e-9;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Where each family's 75th percentile sits in a stored answer. CrUX gives the value itself (a string for CLS); PageSpeed
|
|
14
|
+
* Insights gives an integer, and for CLS one scaled by PSI_CLS_PERCENTILE_SCALE. Google does not document that scale, so it is
|
|
15
|
+
* trusted only while the first distribution bucket ends at the good bound times the scale (10 for 0.1); otherwise there is no value.
|
|
16
|
+
*/
|
|
17
|
+
const FIELDS = {
|
|
18
|
+
'lcp-field': { crux: 'largest_contentful_paint', psi: 'LARGEST_CONTENTFUL_PAINT_MS', scaled: false },
|
|
19
|
+
'inp-field': { crux: 'interaction_to_next_paint', psi: 'INTERACTION_TO_NEXT_PAINT', scaled: false },
|
|
20
|
+
'cls-field': { crux: 'cumulative_layout_shift', psi: 'CUMULATIVE_LAYOUT_SHIFT_SCORE', scaled: true },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** The families that need visitor field data; a run without a Google key leaves them unknown. */
|
|
24
|
+
export const CWV_FAMILIES = Object.keys(FIELDS);
|
|
25
|
+
|
|
26
|
+
const finite = value => value !== null && value !== '' && Number.isFinite(Number(value)) ? Number(value) : null;
|
|
27
|
+
|
|
28
|
+
/** The p75 a stored answer holds for a family, or null when it holds none. */
|
|
29
|
+
function p75Of(record, familyId) {
|
|
30
|
+
const names = FIELDS[familyId];
|
|
31
|
+
if (record.status !== HTTP_OK || !record.body || typeof record.body !== 'object') return null;
|
|
32
|
+
if (record.source === 'crux') return finite(record.body.record?.metrics?.[names.crux]?.percentiles?.p75);
|
|
33
|
+
const metric = record.body.originLoadingExperience?.metrics?.[names.psi];
|
|
34
|
+
const value = finite(metric?.percentile);
|
|
35
|
+
if (value === null || !names.scaled) return value;
|
|
36
|
+
const bound = finite(metric.distributions?.[0]?.max);
|
|
37
|
+
return Math.abs(bound - thresholds(familyId).good * PSI_CLS_PERCENTILE_SCALE) < SCALE_TOLERANCE ? value / PSI_CLS_PERCENTILE_SCALE : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* With no CWV answer to point at, an unknown points at a collected response of the origin: collector evidence, as every collector verdict needs.
|
|
42
|
+
* A page of the origin comes before its robots or sitemap, so the anchor does not depend on the order the collector fetched them in.
|
|
43
|
+
*/
|
|
44
|
+
function anchorOf(artifacts, origin) {
|
|
45
|
+
const ofOrigin = artifacts.filter(artifact => artifact.normalized_url && new URL(artifact.normalized_url).origin === origin);
|
|
46
|
+
return (ofOrigin.find(artifact => artifact.capability === 'raw_html') ?? ofOrigin[0])?.evidence_ref;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One family's verdict at the 75th percentile of real users, one per origin, from the stored CWV answers (`prepared.cwv`)
|
|
51
|
+
* alone. An origin with no usable p75 is `unknown`: absent field data is not a failure. The first stored answer of an
|
|
52
|
+
* origin that holds a value decides, so replay picks the same one the audit did.
|
|
53
|
+
*/
|
|
54
|
+
const fieldBlock = familyId => ({
|
|
55
|
+
family_id: familyId,
|
|
56
|
+
message_keys: [`${familyId}.slow`],
|
|
57
|
+
derive({ context, artifacts, selected, cwv }) {
|
|
58
|
+
const { unit, good, poor } = thresholds(familyId);
|
|
59
|
+
return [...new Set([...selected.keys()].map(url => new URL(url).origin))].map(origin => {
|
|
60
|
+
const mine = cwv.filter(record => record.origin === origin);
|
|
61
|
+
const source = mine.find(record => p75Of(record, familyId) !== null);
|
|
62
|
+
const observation = { family_id: familyId, unit_key: unitKey(familyId, origin) };
|
|
63
|
+
if (!source) return makeObservation({ ...observation, state: 'unknown', reason: UNAVAILABLE,
|
|
64
|
+
evidence_ref: mine[0]?.evidence_ref ?? anchorOf(artifacts, origin) ?? context.evidence_ref });
|
|
65
|
+
const value = p75Of(source, familyId);
|
|
66
|
+
const measurement = { value, percentile: P75, source_type: 'field', unit };
|
|
67
|
+
const { state, q, partial_basis } = cwvVerdict(familyId, value, measurement);
|
|
68
|
+
const reason = `p75 ${value} ${unit} from ${source.source}`;
|
|
69
|
+
return makeObservation({ ...observation, state, q, partial_basis, evidence_ref: source.evidence_ref, extra: { measurement },
|
|
70
|
+
...(state === 'fail' ? { problems: [problem(reason, `${familyId}.slow`, { value, source: source.source, good, poor })] } : { reason }) });
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export const lcpField = fieldBlock('lcp-field');
|
|
76
|
+
export const inpField = fieldBlock('inp-field');
|
|
77
|
+
export const clsField = fieldBlock('cls-field');
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { canonicalUrl } from '../link-graph.mjs';
|
|
2
|
+
import { makeObservation, problem, unitKey } from './observation.mjs';
|
|
3
|
+
|
|
4
|
+
const FAMILY = 'discovery';
|
|
5
|
+
const ORPHAN = 'discovery.orphan';
|
|
6
|
+
const SITEMAP_UNUSABLE = 'discovery.orphan-sitemap-unusable';
|
|
7
|
+
|
|
8
|
+
// A declared sitemap that was never fetched may list the page, so its absence from the sitemaps that were read proves nothing.
|
|
9
|
+
const unreadReason = unread => `declared sitemap not read: ${[...new Set(unread.map(x => x.reason))].join(', ')}; not linked from a collected page, and the evidence cannot show that nothing else leads to it`;
|
|
10
|
+
|
|
11
|
+
const UNPROVEN = 'Not in a read sitemap and not linked from a collected page, but the evidence cannot show that nothing else leads to it';
|
|
12
|
+
|
|
13
|
+
/** A page listed in a declared sitemap or linked from another collected page passes; a proven orphan fails; the rest is unknown. */
|
|
14
|
+
export const discovery = {
|
|
15
|
+
family_id: FAMILY,
|
|
16
|
+
message_keys: [ORPHAN, SITEMAP_UNUSABLE],
|
|
17
|
+
derive({ pageRows, linkGraph, orphans, sitemaps, crawlComplete, htmlPages }) {
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const row of pageRows) {
|
|
20
|
+
if (!row.publicIndexing) continue;
|
|
21
|
+
const { url, artifact } = row;
|
|
22
|
+
const keys = [canonicalUrl(url), canonicalUrl(artifact.final_url)];
|
|
23
|
+
const inSitemap = keys.some(key => sitemaps.urls.has(key));
|
|
24
|
+
const observe = (state, reason) => out.push(makeObservation({ family_id: FAMILY, unit_key: unitKey(FAMILY, url), state, reason,
|
|
25
|
+
evidence_ref: artifact.evidence_ref }));
|
|
26
|
+
if (inSitemap || keys.some(key => linkGraph.inbound(key))) observe('pass', inSitemap ? 'Listed in a declared sitemap' : 'Linked from another collected page');
|
|
27
|
+
else if (orphans.has(url)) {
|
|
28
|
+
const unlinked = 'Not linked from any collected page and not in a sitemap';
|
|
29
|
+
const broken = sitemaps.broken.join(', ');
|
|
30
|
+
out.push(makeObservation({ family_id: FAMILY, unit_key: unitKey(FAMILY, url), state: 'fail', evidence_ref: artifact.evidence_ref,
|
|
31
|
+
problems: [broken ? problem(`${unlinked}; declared sitemap unusable: ${broken}`, SITEMAP_UNUSABLE, { broken }) : problem(unlinked, ORPHAN)] }));
|
|
32
|
+
} else {
|
|
33
|
+
// Reaching a page because the run selected it is not discoverability, and an orphan is only proven on complete evidence
|
|
34
|
+
// (see findOrphans). A page neither listed nor linked that cannot be proven an orphan is unknown, and says what leaves it open.
|
|
35
|
+
out.push(makeObservation({ family_id: FAMILY, unit_key: unitKey(FAMILY, url), state: 'unknown', evidence_ref: artifact.evidence_ref,
|
|
36
|
+
reason: sitemaps.unread.length ? unreadReason(sitemaps.unread) : UNPROVEN,
|
|
37
|
+
extra: { measurement: { crawl_complete: crawlComplete, sitemap_index_unread: sitemaps.index, script_gap: linkGraph.scriptGap, collected_pages: htmlPages,
|
|
38
|
+
...(sitemaps.unread.length ? { sitemap_unread: sitemaps.unread } : {}) } } }));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { thresholds } from '../config.mjs';
|
|
2
|
+
import { messageOf } from '../messages.mjs';
|
|
3
|
+
import { makeObservation, problem, unitKey } from './observation.mjs';
|
|
4
|
+
|
|
5
|
+
const FAMILY = 'document-title';
|
|
6
|
+
const MISSING = 'document-title.missing';
|
|
7
|
+
const DUPLICATE = 'document-title.duplicate';
|
|
8
|
+
const SAME_DOCUMENT = 'document-title.same-document';
|
|
9
|
+
|
|
10
|
+
/** A title or host name as letters and digits only, so case, spacing and punctuation never tell two spellings apart. */
|
|
11
|
+
const plain = value => String(value).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
12
|
+
const OG_SITE_NAME = [/<meta\b[^>]*\bproperty\s*=\s*["']og:site_name["'][^>]*\bcontent\s*=\s*["']([^"']*)["']/i, /<meta\b[^>]*\bcontent\s*=\s*["']([^"']*)["'][^>]*\bproperty\s*=\s*["']og:site_name["']/i];
|
|
13
|
+
/**
|
|
14
|
+
* The forms of the site's own name a title can consist of alone: the host and its first label, the `og:site_name` of any audited
|
|
15
|
+
* page, and the name of the organization any audited page's JSON-LD speaks for.
|
|
16
|
+
*/
|
|
17
|
+
function siteNames(pageRows, facts) {
|
|
18
|
+
const names = pageRows.flatMap(({ artifact, html }) => {
|
|
19
|
+
const host = new URL(artifact.final_url).hostname.replace(/^www\./, '');
|
|
20
|
+
return [host, host.split('.')[0], ...OG_SITE_NAME.map(pattern => pattern.exec(html)?.[1])];
|
|
21
|
+
});
|
|
22
|
+
for (const { facts: page } of facts) names.push(...page.linked.name.values());
|
|
23
|
+
return new Set(names.filter(Boolean).map(plain).filter(Boolean));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A missing or duplicated title fails. A title that is present, shared with no other audited page (and there are at least two audited
|
|
28
|
+
* pages) and not only the site's name or a generic word passes, on those facts alone. With a single audited page, or a title that is
|
|
29
|
+
* only a name or a generic word, it stays unknown, because whether it describes the page is not established by code.
|
|
30
|
+
*/
|
|
31
|
+
export const documentTitle = {
|
|
32
|
+
family_id: FAMILY,
|
|
33
|
+
message_keys: [MISSING, DUPLICATE],
|
|
34
|
+
derive({ pageRows, titleCounts, facts }) {
|
|
35
|
+
const generic = new Set(thresholds(FAMILY).generic_titles.map(plain));
|
|
36
|
+
const names = siteNames(pageRows, facts);
|
|
37
|
+
return pageRows.map(row => {
|
|
38
|
+
const { url, artifact, info } = row;
|
|
39
|
+
const others = info.title ? titleCounts.get(row.titleKey).filter(other => other !== row && other.artifact.final_url !== artifact.final_url) : [];
|
|
40
|
+
// Query variants of one page (same document, R98) share their title by being one page; they are not duplicates of each other.
|
|
41
|
+
const variants = others.filter(other => row.documentKey && other.documentKey === row.documentKey);
|
|
42
|
+
const duplicates = others.filter(other => !variants.includes(other));
|
|
43
|
+
const observed = { family_id: FAMILY, unit_key: unitKey(FAMILY, url), evidence_ref: artifact.evidence_ref };
|
|
44
|
+
if (!info.title) return makeObservation({ ...observed, state: 'fail', problems: [problem('Raw HTML has no title', MISSING)] });
|
|
45
|
+
// The other pages are page-specific, so they travel in the measurement and the reason stays the same for every page with the problem.
|
|
46
|
+
if (duplicates.length) return makeObservation({ ...observed, state: 'fail', problems: [problem('Title duplicates the title of other pages', DUPLICATE)],
|
|
47
|
+
extra: { measurement: { duplicate_pages: duplicates.map(x => x.url) } } });
|
|
48
|
+
const key = plain(info.title);
|
|
49
|
+
const measurement = { title: info.title, audited_pages: pageRows.length, site_name_only: names.has(key), generic_word_only: key === '' || generic.has(key) };
|
|
50
|
+
// With one audited page, "no other page has this title" is true of any title and proves nothing.
|
|
51
|
+
if (pageRows.length < 2 || measurement.site_name_only || measurement.generic_word_only) return makeObservation({ ...observed, state: 'unknown', extra: { measurement },
|
|
52
|
+
reason: 'Raw HTML has a title; uniqueness and descriptiveness are not established' });
|
|
53
|
+
// A title shared only with query variants of the same page says so: it is not "different from every other page".
|
|
54
|
+
if (variants.length) return makeObservation({ ...observed, state: 'pass',
|
|
55
|
+
extra: { measurement: { ...measurement, same_document: { document: row.documentKey, pages: variants.map(x => x.url) },
|
|
56
|
+
...messageOf(SAME_DOCUMENT, { pages: variants.map(x => x.url).join(', ') }) } },
|
|
57
|
+
reason: 'The title is present, shared only with query variants of the same page (the same canonical URL), and is not only the site name or a generic word' });
|
|
58
|
+
return makeObservation({ ...observed, state: 'pass', extra: { measurement },
|
|
59
|
+
reason: 'The title is present, differs from the title of every other audited page, and is not only the site name or a generic word' });
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { makeObservation, problem, unitKey } from './observation.mjs';
|
|
2
|
+
|
|
3
|
+
const FAMILY = 'entity-identity';
|
|
4
|
+
const NAME_DIFFERS = problem('Entity identity: the business name differs between pages', 'entity-identity.name-differs');
|
|
5
|
+
const NO_SAME_AS = problem('Entity identity: Organization JSON-LD lacks sameAs on some pages', 'entity-identity.no-same-as');
|
|
6
|
+
const NO_HOME_ORGANIZATION = problem('Entity identity: no Organization or LocalBusiness JSON-LD on the home page', 'entity-identity.no-home-organization');
|
|
7
|
+
|
|
8
|
+
/** One site-level verdict: Organization JSON-LD with sameAs on the home page, and one name across pages. */
|
|
9
|
+
export const entityIdentity = {
|
|
10
|
+
family_id: FAMILY,
|
|
11
|
+
message_keys: [NAME_DIFFERS, NO_SAME_AS, NO_HOME_ORGANIZATION].map(item => item.message_key),
|
|
12
|
+
derive({ facts, comparison }) {
|
|
13
|
+
if (!facts.length) return [];
|
|
14
|
+
const problems = [];
|
|
15
|
+
const home = facts.find(x => new URL(x.row.artifact.final_url).pathname === '/') ?? facts[0];
|
|
16
|
+
const orgPages = facts.filter(x => x.facts.orgs.length && !comparison.separate.has(x.url));
|
|
17
|
+
const nameReference = comparison.references.name;
|
|
18
|
+
const nameDeviants = facts.filter(x => (comparison.deviations.get(x.url) ?? []).some(text => text.startsWith('name ')));
|
|
19
|
+
// The sentences are fixed; which names and pages they concern travel in the measurement.
|
|
20
|
+
const withoutSameAs = orgPages.filter(page => page.facts.orgs.some(org => !org.sameAs));
|
|
21
|
+
if (nameDeviants.length) problems.push(NAME_DIFFERS);
|
|
22
|
+
if (withoutSameAs.length) problems.push(NO_SAME_AS);
|
|
23
|
+
if (orgPages.length && !home.facts.orgs.length) problems.push(NO_HOME_ORGANIZATION);
|
|
24
|
+
const measurement = { name_reference: nameReference?.label ?? null, name_differs_on: nameDeviants.map(x => x.url),
|
|
25
|
+
without_same_as_on: withoutSameAs.map(x => x.url), home_page: home.url };
|
|
26
|
+
const observe = (state, stated, evidence_ref) => [makeObservation({ family_id: FAMILY, unit_key: unitKey(FAMILY, 'site'), state, evidence_ref, ...stated, extra: { measurement } })];
|
|
27
|
+
if (problems.length) return observe('fail', { problems }, (nameDeviants[0] ?? home).row.artifact.evidence_ref);
|
|
28
|
+
if (orgPages.length) return observe('pass', { reason: 'Organization JSON-LD with name and sameAs is present on the home page and names agree' }, home.row.artifact.evidence_ref);
|
|
29
|
+
return [];
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { FACT_MESSAGE_KEYS } from '../facts.mjs';
|
|
2
|
+
import { makeObservation, problem, unitKey } from './observation.mjs';
|
|
3
|
+
|
|
4
|
+
const FAMILY = 'fact-consistency';
|
|
5
|
+
const PREFIX = 'Cross-page fact comparison (no approved fact list): ';
|
|
6
|
+
const NO_FACTS = 'The page has no labelled, marked-up or tel: contact fact (name, phone number, address, founding year) that this check can compare';
|
|
7
|
+
|
|
8
|
+
/** Contact facts compared across pages. Agreement is consistency, not accuracy, and a branch page is never compared. */
|
|
9
|
+
export const factConsistency = {
|
|
10
|
+
family_id: FAMILY,
|
|
11
|
+
message_keys: Object.values(FACT_MESSAGE_KEYS),
|
|
12
|
+
derive({ facts, comparison }) {
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const page of facts) {
|
|
15
|
+
if (comparison.separate.has(page.url)) continue;
|
|
16
|
+
const found = comparison.deviations.get(page.url) ?? [];
|
|
17
|
+
const stated = comparison.deviation_messages.get(page.url) ?? [];
|
|
18
|
+
const comparable = Object.keys(comparison.references).some(type => page.facts.visible[type].size || page.facts.linked[type].size || (type === 'phone' && page.facts.tel.phone.size));
|
|
19
|
+
// A page that states no contact fact has nothing to compare. A page that states one but has no reference to meet stays unknown:
|
|
20
|
+
// a missing reference is a missing fact list, not an inapplicable page.
|
|
21
|
+
const stating = Object.keys(page.facts.visible).some(type => page.facts.visible[type].size || page.facts.linked[type].size) || page.facts.tel.phone.size > 0;
|
|
22
|
+
const observe = (state, judged) => out.push(makeObservation({ family_id: FAMILY, unit_key: unitKey(FAMILY, page.url), state, evidence_ref: page.row.artifact.evidence_ref, ...judged }));
|
|
23
|
+
// Each conflict is its own problem: the values are the problem itself (R63), so a page with two conflicts has two fixes.
|
|
24
|
+
if (found.length) observe('fail', { problems: found.map((text, index) => problem(`${PREFIX}${text}`, stated[index].message_key, stated[index].message_values)) });
|
|
25
|
+
else if (comparable) observe('pass', { reason: 'Facts on this page match the cross-page reference values (no approved fact list; consistency is not accuracy)' });
|
|
26
|
+
else if (!stating) observe('not_applicable', { reason: NO_FACTS, extra: { measurement: { facts_found: 0 },
|
|
27
|
+
applicability_decision: { condition: 'no_evaluable_facts', reason: NO_FACTS, evidence_ref: page.row.artifact.evidence_ref } } });
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { makeObservation, problem, unitKey } from './observation.mjs';
|
|
2
|
+
|
|
3
|
+
const FAMILY = 'fetch-response';
|
|
4
|
+
const HTTP_ERROR = 'fetch-response.http-error';
|
|
5
|
+
|
|
6
|
+
/** Every selected page, collected or not: a finished 2xx passes, a 4xx or 5xx fails, anything else is unknown. */
|
|
7
|
+
export const fetchResponse = {
|
|
8
|
+
family_id: FAMILY,
|
|
9
|
+
message_keys: [HTTP_ERROR],
|
|
10
|
+
derive({ artifacts, selected }) {
|
|
11
|
+
return artifacts.filter(artifact => selected.has(artifact.normalized_url)).map(artifact => {
|
|
12
|
+
const state = artifact.error || artifact.status < 200 || artifact.status >= 300 && artifact.status < 400 ? 'unknown' : artifact.status >= 400 ? 'fail' : 'pass';
|
|
13
|
+
return makeObservation({ family_id: FAMILY, unit_key: unitKey(FAMILY, artifact.normalized_url), state, evidence_ref: artifact.evidence_ref,
|
|
14
|
+
...(state === 'fail' ? { problems: [problem(`HTTP ${artifact.status}`, HTTP_ERROR, { status: artifact.status })] }
|
|
15
|
+
: { reason: artifact.error ? 'Request did not finish' : `HTTP ${artifact.status}` }) });
|
|
16
|
+
});
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { makeObservation, problem, unitKey } from './observation.mjs';
|
|
2
|
+
|
|
3
|
+
const FAMILY = 'index-policy';
|
|
4
|
+
const NOINDEX = 'index-policy.noindex';
|
|
5
|
+
|
|
6
|
+
/** Whether the raw response blocks indexing, for pages meant to be indexed. */
|
|
7
|
+
export const indexPolicy = {
|
|
8
|
+
family_id: FAMILY,
|
|
9
|
+
message_keys: [NOINDEX],
|
|
10
|
+
derive({ pageRows }) {
|
|
11
|
+
return pageRows.filter(row => row.publicIndexing).map(({ url, artifact, info }) => makeObservation({
|
|
12
|
+
family_id: FAMILY, unit_key: unitKey(FAMILY, url), state: info.index_blocked ? 'fail' : 'pass', evidence_ref: artifact.evidence_ref,
|
|
13
|
+
...(info.index_blocked ? { problems: [problem('Raw response blocks indexing for the target engine', NOINDEX)] }
|
|
14
|
+
: { reason: 'Raw response has no applicable noindex directive' }) }));
|
|
15
|
+
},
|
|
16
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { fetchResponse } from './fetch-response.mjs';
|
|
2
|
+
import { searchCrawlPolicy } from './search-crawl-policy.mjs';
|
|
3
|
+
import { indexPolicy } from './index-policy.mjs';
|
|
4
|
+
import { renderContent } from './render-content.mjs';
|
|
5
|
+
import { discovery } from './discovery.mjs';
|
|
6
|
+
import { documentTitle } from './document-title.mjs';
|
|
7
|
+
import { canonicalConsistency } from './canonical-consistency.mjs';
|
|
8
|
+
import { internalNavigation } from './internal-navigation.mjs';
|
|
9
|
+
import { structuredDataTruth } from './structured-data-truth.mjs';
|
|
10
|
+
import { localeLinks } from './locale-links.mjs';
|
|
11
|
+
import { naverMarkup } from './naver-markup.mjs';
|
|
12
|
+
import { entityIdentity } from './entity-identity.mjs';
|
|
13
|
+
import { factConsistency } from './fact-consistency.mjs';
|
|
14
|
+
import { lcpField, inpField, clsField } from './cwv-field.mjs';
|
|
15
|
+
|
|
16
|
+
/** The one registration list: every check block, in the order of check-registry.json. A block is `{ family_id, derive(prepared) }`. */
|
|
17
|
+
export const blocks = [fetchResponse, searchCrawlPolicy, indexPolicy, renderContent, discovery, documentTitle, canonicalConsistency,
|
|
18
|
+
internalNavigation, structuredDataTruth, localeLinks, naverMarkup, lcpField, inpField, clsField, entityIdentity, factConsistency];
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { thresholds } from '../config.mjs';
|
|
2
|
+
import { anchorTexts } from '../html.mjs';
|
|
3
|
+
import { canonicalUrl, sameOriginLinks } from '../link-graph.mjs';
|
|
4
|
+
import { makeObservation, problem, unitKey } from './observation.mjs';
|
|
5
|
+
|
|
6
|
+
const FAMILY = 'internal-navigation';
|
|
7
|
+
const TOO_DEEP = problem('The home page reaches this page in more clicks than the limit', 'internal-navigation.too-deep');
|
|
8
|
+
const WEAK_ANCHORS = problem('Linked pages have too many empty or generic anchors', 'internal-navigation.weak-anchors');
|
|
9
|
+
|
|
10
|
+
/** Same-origin links of a page to other pages, each with its anchor label. */
|
|
11
|
+
function internalAnchors({ artifact, html }) {
|
|
12
|
+
const anchors = anchorTexts(html, artifact.final_url);
|
|
13
|
+
const internal = new Set(sameOriginLinks(anchors.map(({ href }) => href), artifact.final_url));
|
|
14
|
+
return anchors.filter(({ href }) => {
|
|
15
|
+
const target = new URL(href); target.hash = '';
|
|
16
|
+
return internal.has(target.href) && canonicalUrl(href) !== canonicalUrl(artifact.final_url);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A page that is too many clicks from the home page, or whose linked pages mostly have no descriptive anchor, fails. A linked page
|
|
22
|
+
* counts as weak only when every anchor to it on the page is empty or generic: an icon or a "more" link beside a descriptive link
|
|
23
|
+
* to the same target is normal navigation. A page nothing links to has no click depth: it is left to `discovery`, so it is judged
|
|
24
|
+
* on its anchors only. A site whose pages carry no internal link is not link based, unless a script that was not collected could be
|
|
25
|
+
* building the links.
|
|
26
|
+
*/
|
|
27
|
+
export const internalNavigation = {
|
|
28
|
+
family_id: FAMILY,
|
|
29
|
+
message_keys: [TOO_DEEP, WEAK_ANCHORS].map(item => item.message_key),
|
|
30
|
+
derive({ pageRows, linkGraph }) {
|
|
31
|
+
const { max_click_depth, empty_anchor_ratio, generic_anchor_terms } = thresholds(FAMILY);
|
|
32
|
+
const generic = new Set(generic_anchor_terms.map(term => term.toLowerCase()));
|
|
33
|
+
const rows = pageRows.map(row => ({ row, anchors: internalAnchors(row) }));
|
|
34
|
+
const linkBased = rows.some(({ anchors }) => anchors.length > 0);
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const { row: { url, artifact }, anchors } of rows) {
|
|
37
|
+
const observe = (state, reason, extra = {}) => out.push(makeObservation({ family_id: FAMILY, unit_key: unitKey(FAMILY, url), state, reason,
|
|
38
|
+
evidence_ref: artifact.evidence_ref, extra }));
|
|
39
|
+
if (!linkBased) {
|
|
40
|
+
if (linkGraph.scriptGap) observe('unknown', 'No links in the raw HTML; a script that was not collected may build the navigation');
|
|
41
|
+
else {
|
|
42
|
+
const reason = 'No collected page links to another page of the site';
|
|
43
|
+
observe('not_applicable', reason, { applicability_decision: { condition: 'not_link_based', reason, evidence_ref: artifact.evidence_ref } });
|
|
44
|
+
}
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const descriptive = new Map();
|
|
48
|
+
for (const { href, label } of anchors) {
|
|
49
|
+
const target = canonicalUrl(href);
|
|
50
|
+
descriptive.set(target, descriptive.get(target) || (Boolean(label) && !generic.has(label.toLowerCase())));
|
|
51
|
+
}
|
|
52
|
+
const weak = [...descriptive.values()].filter(ok => !ok).length;
|
|
53
|
+
const depth = linkGraph.depthOf(url);
|
|
54
|
+
// The sentences are fixed, so the same problem reads the same on every page; the numbers are page-specific and travel in the measurement.
|
|
55
|
+
const measurement = { click_depth: depth, max_click_depth, weak_anchor_pages: weak, linked_pages: descriptive.size, allowed_weak_ratio: empty_anchor_ratio };
|
|
56
|
+
const problems = [];
|
|
57
|
+
if (depth !== null && depth > max_click_depth) problems.push(TOO_DEEP);
|
|
58
|
+
if (descriptive.size && weak / descriptive.size > empty_anchor_ratio) problems.push(WEAK_ANCHORS);
|
|
59
|
+
if (problems.length) out.push(makeObservation({ family_id: FAMILY, unit_key: unitKey(FAMILY, url), state: 'fail', evidence_ref: artifact.evidence_ref, problems,
|
|
60
|
+
extra: { measurement: { ...measurement, problems: problems.map(item => item.text) } } }));
|
|
61
|
+
// A page with no click depth and no internal link has nothing to judge: it gets no observation on purpose (discovery covers it).
|
|
62
|
+
else if (depth !== null || descriptive.size)
|
|
63
|
+
observe('pass', depth === null ? 'Click depth not measured (left to discovery); anchors are within the limit' : 'Reached within the click limit; anchors are within the limit', { measurement });
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { canonicalUrl } from '../link-graph.mjs';
|
|
2
|
+
import { makeObservation, problem, unitKey } from './observation.mjs';
|
|
3
|
+
|
|
4
|
+
const FAMILY = 'locale-links';
|
|
5
|
+
const X_DEFAULT = 'x-default';
|
|
6
|
+
const NO_X_DEFAULT = problem('No x-default alternate is declared', 'locale-links.no-x-default');
|
|
7
|
+
const NO_RETURN = problem('An alternate page does not link back to this page', 'locale-links.no-return');
|
|
8
|
+
const NOT_LISTED_BACK = problem('A page that lists this page as an alternate is not listed back', 'locale-links.not-listed-back');
|
|
9
|
+
|
|
10
|
+
const points = (href, page) => page.urls.includes(canonicalUrl(href));
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A page that declares hreflang alternates needs an x-default and a return link from every alternate it names; a page that
|
|
14
|
+
* another page lists as its alternate must list that page in turn. An alternate that was not collected cannot be checked,
|
|
15
|
+
* so the page is unknown rather than passed. A page with no hreflang, or only one that names itself, that no page lists belongs to a single-locale site.
|
|
16
|
+
*/
|
|
17
|
+
export const localeLinks = {
|
|
18
|
+
family_id: FAMILY,
|
|
19
|
+
message_keys: [NO_X_DEFAULT, NO_RETURN, NOT_LISTED_BACK].map(item => item.message_key),
|
|
20
|
+
derive({ pageRows, alternates }) {
|
|
21
|
+
const pages = new Set(alternates.values());
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const { url, artifact } of pageRows) {
|
|
24
|
+
const self = alternates.get(canonicalUrl(artifact.normalized_url));
|
|
25
|
+
const mine = self.alternates;
|
|
26
|
+
const listedBy = [...pages].filter(other => other !== self && other.alternates.some(alternate => points(alternate.href, self)));
|
|
27
|
+
const observe = (state, reason, extra = {}) => out.push(makeObservation({ family_id: FAMILY, unit_key: unitKey(FAMILY, url), state, reason,
|
|
28
|
+
evidence_ref: artifact.evidence_ref, extra }));
|
|
29
|
+
if (mine.every(alternate => points(alternate.href, self)) && !listedBy.length) {
|
|
30
|
+
const reason = 'No hreflang link on this page and no collected page lists it as an alternate';
|
|
31
|
+
observe('not_applicable', reason, { applicability_decision: { condition: 'single_locale', reason, evidence_ref: artifact.evidence_ref } });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
// Fixed sentences; which pages are meant travels in the measurement.
|
|
35
|
+
const problems = new Map();
|
|
36
|
+
const add = item => problems.set(item.text, item);
|
|
37
|
+
const noReturn = [], listedWithoutReturn = [];
|
|
38
|
+
if (mine.length && !mine.some(alternate => alternate.hreflang === X_DEFAULT)) add(NO_X_DEFAULT);
|
|
39
|
+
let unchecked = 0;
|
|
40
|
+
for (const alternate of mine) {
|
|
41
|
+
if (points(alternate.href, self)) continue;
|
|
42
|
+
const target = alternates.get(canonicalUrl(alternate.href));
|
|
43
|
+
if (!target) unchecked += 1;
|
|
44
|
+
else if (!target.alternates.some(back => points(back.href, self))) { add(NO_RETURN); noReturn.push(target.urls[0]); }
|
|
45
|
+
}
|
|
46
|
+
for (const other of listedBy) if (!mine.some(alternate => points(alternate.href, other))) { add(NOT_LISTED_BACK); listedWithoutReturn.push(other.urls[0]); }
|
|
47
|
+
const measurement = { alternates_without_return_link: noReturn, pages_listing_this_page_without_return: listedWithoutReturn, unchecked_alternates: unchecked };
|
|
48
|
+
if (problems.size) out.push(makeObservation({ family_id: FAMILY, unit_key: unitKey(FAMILY, url), state: 'fail', evidence_ref: artifact.evidence_ref, problems: [...problems.values()],
|
|
49
|
+
extra: { measurement: { ...measurement, problems: [...problems.keys()] } } }));
|
|
50
|
+
else if (unchecked) observe('unknown', 'Some alternate pages were not collected, so their return links are unchecked', { measurement });
|
|
51
|
+
else observe('pass', 'An x-default alternate is declared and every alternate links back', { measurement });
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { makeObservation, problem, unitKey } from './observation.mjs';
|
|
2
|
+
|
|
3
|
+
const FAMILY = 'naver-markup';
|
|
4
|
+
const OG_PREFIX = 'no-og-';
|
|
5
|
+
/** The Korean message each markup finding code of the Naver module has; the five Open Graph codes share one. */
|
|
6
|
+
const CODES = ['no-og', 'no-description', 'no-canonical', 'canonical-relative', 'title-keyword-repeat', 'no-viewport', 'resource-blocked'];
|
|
7
|
+
const keyOf = code => `naver-markup.${code.startsWith(OG_PREFIX) ? 'no-og' : code}`;
|
|
8
|
+
|
|
9
|
+
/** Open Graph, description, canonical, viewport and title markup that Naver reads, judged only when Naver is a target engine. */
|
|
10
|
+
export const naverMarkup = {
|
|
11
|
+
family_id: FAMILY,
|
|
12
|
+
message_keys: CODES.map(code => keyOf(code)),
|
|
13
|
+
derive({ context, pageRows }) {
|
|
14
|
+
if (!context.protocol.engines.includes('naver')) return [];
|
|
15
|
+
// Each distinct problem is its own finding, as in the other checks that state several: the joined reason is only the verdict's summary.
|
|
16
|
+
return pageRows.map(({ url, artifact, info }) => makeObservation({ family_id: FAMILY, unit_key: unitKey(FAMILY, url, 'naver'),
|
|
17
|
+
state: info.markup_findings.length ? 'fail' : 'pass', evidence_ref: artifact.evidence_ref,
|
|
18
|
+
...(info.markup_findings.length ? { problems: info.markup_findings.map(x => problem(x.message, keyOf(x.code), x.values)) }
|
|
19
|
+
: { reason: 'Open Graph, description, canonical, viewport and title checks pass' }) }));
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { validateEntity } from '../contracts.mjs';
|
|
3
|
+
import { versions } from '../config.mjs';
|
|
4
|
+
import { messageOf } from '../messages.mjs';
|
|
5
|
+
|
|
6
|
+
const FIXED_Q = { pass: 1, fail: 0 };
|
|
7
|
+
|
|
8
|
+
/** Identity of one judged unit: the family, the engine when the family is judged per engine, and the url. */
|
|
9
|
+
export const unitKey = (family_id, url, engine = null) => `${family_id}|${engine ? `${engine}|` : ''}${url}`;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One problem a failed check states: its English sentence (the grouping key, never translated), the catalog key of its Korean
|
|
13
|
+
* sentence, and the page-specific values that sentence fills.
|
|
14
|
+
*/
|
|
15
|
+
export const problem = (text, message_key, message_values = {}) => ({ text, ...messageOf(message_key, message_values) });
|
|
16
|
+
|
|
17
|
+
/** What a failed verdict records for the problems it states: the joined reason, the list when there are several, and each problem's Korean key. */
|
|
18
|
+
function statedProblems(problems, extra) {
|
|
19
|
+
const texts = problems.map(item => item.text);
|
|
20
|
+
const measurement = texts.length > 1 && !extra.measurement?.problems ? { ...extra.measurement, problems: texts } : extra.measurement;
|
|
21
|
+
return { reason: texts.join('; '), extra: { ...extra, ...(measurement ? { measurement } : {}),
|
|
22
|
+
problem_messages: problems.map(({ message_key, message_values }) => messageOf(message_key, message_values)) } };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One code-judged CheckObservation. `observation_id` is the sha256 of `unit_key`, as in 0.1.4. `q` follows the state
|
|
27
|
+
* (pass 1, fail 0, anything else null) unless a block passes its own; `check_id` defaults to the family. A failed verdict
|
|
28
|
+
* passes the `problems` it states (see `problem`) instead of a reason: the reason is their texts joined, and
|
|
29
|
+
* `problem_messages` lines up with them for the report's Korean text.
|
|
30
|
+
*/
|
|
31
|
+
export function makeObservation({ family_id, check_id = family_id, unit_key, state, q = FIXED_Q[state] ?? null, reason, evidence_ref,
|
|
32
|
+
partial_basis = null, problems = null, extra = {} }) {
|
|
33
|
+
const stated = problems ? statedProblems(problems, extra) : { reason, extra };
|
|
34
|
+
return validateEntity('CheckObservation', { schema_version: versions().contract_schema,
|
|
35
|
+
observation_id: createHash('sha256').update(unit_key).digest('hex'), check_id, family_id, unit_key,
|
|
36
|
+
state, q, partial_basis, reason: stated.reason, evaluator_type: 'code', source_ref: 'optbrew:collector', evidence_ref, ...stated.extra });
|
|
37
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { auditCollected } from '../../../modules/naver-searchadvisor/scripts/audit.mjs';
|
|
2
|
+
import { alternateLinks, decodeEntities } from '../html.mjs';
|
|
3
|
+
import { declaredRobotsIndex } from '../collector.mjs';
|
|
4
|
+
import { compareFacts, pageFacts } from '../facts.mjs';
|
|
5
|
+
import { canonicalUrl, findOrphans, linkGraph } from '../link-graph.mjs';
|
|
6
|
+
import { sameSite } from '../page-select.mjs';
|
|
7
|
+
import { selectRenderPages } from '../render.mjs';
|
|
8
|
+
|
|
9
|
+
const SKIP_REASONS = ['depth_limit', 'url_limit', 'request_limit'];
|
|
10
|
+
const NOT_COLLECTED = 'not_collected';
|
|
11
|
+
const normalizeTitle = title => title.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
12
|
+
const BASE_HREF = /<base\b[^>]*\bhref\s*=\s*["']([^"']*)["']/i;
|
|
13
|
+
const pathOf = url => url.pathname.length > 1 ? url.pathname.replace(/\/+$/, '') || '/' : url.pathname;
|
|
14
|
+
/** A page's address as its site and path, and its query when asked: the spelling documents are compared by. */
|
|
15
|
+
const documentAddress = (url, withQuery) => `${url.origin}${pathOf(url)}${withQuery ? url.search : ''}`;
|
|
16
|
+
/**
|
|
17
|
+
* The document a page stands for (R95, R98). A page whose canonical link names the same site (`www.` and http/https folded) and the same
|
|
18
|
+
* path is one document with every page that names the same canonical, query included: `/bookings?service=x` -> `/bookings` groups with
|
|
19
|
+
* `/bookings`, while `/products?cat=shoes` and `/products?cat=bags` that each name themselves stay two documents.
|
|
20
|
+
* Any other canonical (the home page, another path, another site), an unusable one, or none leaves the page its own document.
|
|
21
|
+
* The canonical is resolved against `<base href>` when the head has one, else against where the page was served.
|
|
22
|
+
*/
|
|
23
|
+
function documentKeyOf(artifact, info, html) {
|
|
24
|
+
const own = new URL(artifact.final_url);
|
|
25
|
+
if (info.canonical) {
|
|
26
|
+
try {
|
|
27
|
+
const live = String(html ?? '').replace(/<!--[\s\S]*?(?:-->|$)/g, '').replace(/<(script|style|template|noscript)\b[\s\S]*?(?:<\/\1\s*>|$)/gi, '');
|
|
28
|
+
const baseHref = BASE_HREF.exec(/<head\b[\s\S]*?<\/head\s*>/i.exec(live)?.[0] ?? live)?.[1];
|
|
29
|
+
const target = new URL(info.canonical, baseHref ? new URL(baseHref, own).href : own.href);
|
|
30
|
+
if (sameSite(target, own) && pathOf(target) === pathOf(own)) return `${documentAddress(own, false)}${target.search}`;
|
|
31
|
+
} catch { /* an unusable canonical declares nothing */ }
|
|
32
|
+
}
|
|
33
|
+
return documentAddress(own, true);
|
|
34
|
+
}
|
|
35
|
+
/** The pages the run renders: the ones its protocol fixed (none when rendering is off), else, before render plans existed, the default sample. */
|
|
36
|
+
const renderPlan = protocol => protocol.render ? (protocol.render.enabled ? protocol.render.pages : []) : selectRenderPages(protocol.page_types);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The declared sitemaps among the collected artifacts: every listed url, whether one is an index, the unusable ones, and the ones the
|
|
40
|
+
* run never fetched (`unread`, each with the reason the collector skipped it, else `not_collected`).
|
|
41
|
+
*/
|
|
42
|
+
function readSitemaps(artifacts, declared, textOf, skipped) {
|
|
43
|
+
const sitemaps = { urls: new Set(), index: false, broken: [], unread: [] };
|
|
44
|
+
const fetched = new Set(artifacts.map(artifact => artifact.normalized_url));
|
|
45
|
+
for (const url of declared) if (!fetched.has(url)) sitemaps.unread.push({ url, reason: skipped.find(x => x.url === url)?.reason ?? NOT_COLLECTED });
|
|
46
|
+
for (const artifact of artifacts) {
|
|
47
|
+
if (!declared.has(artifact.normalized_url)) continue;
|
|
48
|
+
if (artifact.status >= 200 && artifact.status < 300) {
|
|
49
|
+
const body = String(textOf(artifact) ?? '');
|
|
50
|
+
// Child sitemaps of an index are not fetched, so pages missing from the index prove nothing.
|
|
51
|
+
if (/<sitemapindex\b/i.test(body)) sitemaps.index = true;
|
|
52
|
+
for (const match of body.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)) sitemaps.urls.add(canonicalUrl(decodeEntities(match[1])));
|
|
53
|
+
} else sitemaps.broken.push(`${artifact.normalized_url} (${artifact.status ?? artifact.error ?? 'no response'})`);
|
|
54
|
+
}
|
|
55
|
+
return sitemaps;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The values several check blocks share, computed once from the collected evidence.
|
|
60
|
+
* `pageRows` are the selected pages with a 200 raw HTML body, each with its module audit (`info`), its declared robots
|
|
61
|
+
* artifact and its normalized title key. `titleCounts` maps a title key to the rows that carry it. `facts` and `comparison`
|
|
62
|
+
* are the cross-page entity facts. `linkGraph` and `orphans` come from every collected HTML page, `sitemaps` from the
|
|
63
|
+
* declared sitemaps; `crawlComplete` is false when the crawl was cut short and `htmlPages` counts the collected HTML pages. `alternates` maps a page url (as requested and as finally served) to `{ urls, alternates }`, the hreflang
|
|
64
|
+
* links of every collected HTML page. `renderSample` holds the canonical urls of the pages the run renders: the protocol's fixed render pages, or for a run from before the render plan the first URL of each page type. `renders` are the stored render records, `renderTextOf(record)` the final DOM of one, and
|
|
65
|
+
* `chrome` is `{ found, reason }` for the run's Chrome, or null when the run recorded none. `cwv` holds the stored
|
|
66
|
+
* field-data answers, each `{ source, origin, status, body | error, evidence_ref }`, empty when the run collected none.
|
|
67
|
+
*/
|
|
68
|
+
export function prepare({ context, artifacts, collection = {}, textOf, renders = [], renderTextOf = () => '', chrome = null, cwv = [] }) {
|
|
69
|
+
const { protocol } = context;
|
|
70
|
+
const planned = new Set(protocol.families.filter(x => x.applicable).map(x => x.family_id));
|
|
71
|
+
const selected = new Map(protocol.page_types.flatMap(type => type.urls).map(url => [url.normalized, url]));
|
|
72
|
+
const declared = kind => new Set((protocol.auxiliary_resources ?? []).filter(x => x.kind === kind).map(x => x.normalized));
|
|
73
|
+
const declaredRobots = declared('robots');
|
|
74
|
+
const pageRows = [];
|
|
75
|
+
for (const artifact of artifacts) {
|
|
76
|
+
const url = artifact.normalized_url;
|
|
77
|
+
if (!selected.has(url) || artifact.capability !== 'raw_html' || artifact.status !== 200) continue;
|
|
78
|
+
const html = textOf(artifact);
|
|
79
|
+
const robotsIndex = declaredRobotsIndex(artifacts, declaredRobots, artifact.final_url);
|
|
80
|
+
const robots = robotsIndex < 0 ? null : artifacts[robotsIndex];
|
|
81
|
+
const info = auditCollected({ artifact, html, engines: protocol.engines,
|
|
82
|
+
robotsArtifact: robots?.status === 200 ? { ...robots, body: textOf(robots) } : null });
|
|
83
|
+
pageRows.push({ url, artifact, html, info, robots, publicIndexing: selected.get(url).public_indexing !== false,
|
|
84
|
+
titleKey: info.title ? normalizeTitle(info.title) : null, documentKey: documentKeyOf(artifact, info, html) });
|
|
85
|
+
}
|
|
86
|
+
const titleCounts = new Map();
|
|
87
|
+
// A title shared with a page that is not meant to be indexed is no duplicate: only indexed pages compete for a title.
|
|
88
|
+
for (const row of pageRows) if (row.titleKey !== null && row.publicIndexing) titleCounts.set(row.titleKey, [...titleCounts.get(row.titleKey) ?? [], row]);
|
|
89
|
+
const facts = pageRows.map(row => ({ url: row.url, row, facts: pageFacts(row.html, [row.url, row.artifact.final_url]) }));
|
|
90
|
+
const htmlRows = artifacts.filter(x => x.capability === 'raw_html' && x.status === 200)
|
|
91
|
+
.map(artifact => ({ url: artifact.normalized_url, artifact, html: textOf(artifact) }));
|
|
92
|
+
const graph = linkGraph(htmlRows, artifacts);
|
|
93
|
+
const alternates = new Map();
|
|
94
|
+
for (const { artifact, html } of htmlRows) {
|
|
95
|
+
const entry = { urls: [...new Set([artifact.normalized_url, artifact.final_url].map(canonicalUrl))], alternates: alternateLinks(html, artifact.final_url) };
|
|
96
|
+
for (const key of entry.urls) alternates.set(key, entry);
|
|
97
|
+
}
|
|
98
|
+
const sitemaps = readSitemaps(artifacts, declared('sitemap'), textOf, collection.skipped ?? []);
|
|
99
|
+
const complete = !collection.limit_reached && !(collection.skipped ?? []).some(x => SKIP_REASONS.includes(x.reason));
|
|
100
|
+
return { context, artifacts, textOf, planned, selected, pageRows, titleCounts, facts, comparison: compareFacts(facts), sitemaps,
|
|
101
|
+
linkGraph: graph, orphans: findOrphans({ pageRows, rows: htmlRows, graph, sitemaps, complete }), crawlComplete: complete, htmlPages: htmlRows.length, alternates,
|
|
102
|
+
renderSample: new Set(renderPlan(protocol).map(page => canonicalUrl(page.normalized))), renders, renderTextOf, chrome, cwv };
|
|
103
|
+
}
|