kaloko 0.19.0 → 0.20.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/package.json +1 -1
- package/schemas/config.v1.json +11 -0
- package/schemas/run.v1.json +12 -2
- package/schemas/scenario.v1.json +19 -0
- package/skills/kaloko/reference/integrations.md +24 -1
- package/skills/kaloko/reference/mcp.md +1 -0
- package/skills/kaloko/reference/stacks.md +78 -3
- package/src/cli.mjs +43 -13
- package/src/lib/build.mjs +24 -5
- package/src/lib/capture.mjs +1 -1
- package/src/lib/findings.mjs +30 -5
- package/src/lib/health-findings.mjs +116 -0
- package/src/lib/health-index.mjs +20 -5
- package/src/lib/integrations-cli.mjs +6 -4
- package/src/lib/manifest-core.mjs +83 -8
- package/src/lib/pack-help.mjs +2 -0
- package/src/lib/packs.mjs +1 -1
- package/src/lib/scenario.mjs +5 -0
- package/src/lib/seo-core.mjs +33 -5
- package/src/lib/stack-core.mjs +244 -0
- package/src/lib/stack.mjs +201 -168
- package/src/lib/traffic-core.mjs +289 -0
- package/src/lib/traffic.mjs +149 -0
- package/src/lib/variants-core.mjs +198 -0
- package/viewer/index.html +22 -11
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Findings (#156): thousands of open health results folded into a handful a team can act on. A finding is one check
|
|
2
|
+
// on one site in one locale on one step (a stack step is a template): fingerprint check|origin|locale|step. Its
|
|
3
|
+
// severity is the worst of its results, its count the pages it affects; first seen is the earliest first seen of its
|
|
4
|
+
// results. What a person decided (acknowledged, ignored with a reason) and the issue a tracker holds for it are kept
|
|
5
|
+
// per fingerprint by the service (worker/src/health-findings.js); everything else is derived from what the flow keeps
|
|
6
|
+
// open (health_open), so a finding is fixed once none of its results is open any more.
|
|
7
|
+
//
|
|
8
|
+
// Pure: no Node APIs. The shapes are documented in help/reference/health-api.md.
|
|
9
|
+
import { parseHealthKey } from './health-index.mjs'
|
|
10
|
+
import { severityRank, SEVERITIES } from './health-core.mjs'
|
|
11
|
+
|
|
12
|
+
export const HEALTH_FINDINGS_SCHEMA = 'kaloko.health-findings.v1'
|
|
13
|
+
/** What a person may set; fixed is never set, it is derived. */
|
|
14
|
+
export const FINDING_STATUSES = ['open', 'acknowledged', 'ignored']
|
|
15
|
+
export const FINDING_FILTERS = ['open', 'acknowledged', 'ignored', 'fixed']
|
|
16
|
+
/** Sample pages a finding shows. */
|
|
17
|
+
export const SAMPLE_MAX = 10
|
|
18
|
+
|
|
19
|
+
/** The site of a result: the origin of its URL ('' when it has none or it is not a URL). */
|
|
20
|
+
export function siteOf (url) {
|
|
21
|
+
if (!url) return ''
|
|
22
|
+
try { const u = new URL(String(url)); return /^https?:$/.test(u.protocol) ? u.origin : '' } catch { return '' }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** check|origin|locale|step: stable while the check, the site, the locale and the step keep their ids. */
|
|
26
|
+
export const findingFingerprint = ({ check, url = null, site = null, locale = '', step }) => [check, site ?? siteOf(url), locale || '', step].join('|')
|
|
27
|
+
|
|
28
|
+
/** check|origin|locale|step → { check, site, locale, step }; the origin holds no "|". */
|
|
29
|
+
export function parseFingerprint (fp) {
|
|
30
|
+
const [check, site, locale, ...rest] = String(fp).split('|')
|
|
31
|
+
return { check, site, locale, step: rest.join('|') }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A short id of a fingerprint for URLs (16 hex digits, FNV-1a 64-bit). The same everywhere: the API, the page, MCP
|
|
36
|
+
* and an issue's title.
|
|
37
|
+
*/
|
|
38
|
+
export function findingId (fp) {
|
|
39
|
+
let h = 0xcbf29ce484222325n
|
|
40
|
+
const prime = 0x100000001b3n
|
|
41
|
+
for (const b of new TextEncoder().encode(String(fp))) { h ^= BigInt(b); h = (h * prime) & 0xffffffffffffffffn }
|
|
42
|
+
return h.toString(16).padStart(16, '0')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Findings of a flow from its open results. rows: health_open rows ({ key, severity, url, first_run, first_at, misses
|
|
47
|
+
* }); checks: { HC…: { text, pack } } of the newest run's index; edit: "step|page" → edit_url; seen: { run, at } of
|
|
48
|
+
* the newest run (last seen of what it checked). Returns findings, worst first: { id, fingerprint, check, text, pack,
|
|
49
|
+
* site, locale, step, severity, count, unchecked, samples: [{ page, url, edit_url }], first_seen, last_seen }.
|
|
50
|
+
*/
|
|
51
|
+
export function groupFindings (rows, { checks = {}, edit = {}, seen = null } = {}) {
|
|
52
|
+
const out = new Map()
|
|
53
|
+
for (const r of rows || []) {
|
|
54
|
+
const k = parseHealthKey(r.key)
|
|
55
|
+
const fp = findingFingerprint({ check: k.check, url: r.url, locale: k.locale, step: k.step })
|
|
56
|
+
let f = out.get(fp)
|
|
57
|
+
if (!f) {
|
|
58
|
+
const c = Object.hasOwn(checks || {}, k.check) ? checks[k.check] : null
|
|
59
|
+
f = { id: findingId(fp), fingerprint: fp, check: k.check, text: c?.text || null, pack: c?.pack || null, site: siteOf(r.url) || null, locale: k.locale || null, step: k.step, severity: null, count: 0, unchecked: 0, samples: [], first_seen: null, last_seen: null }
|
|
60
|
+
out.set(fp, f)
|
|
61
|
+
}
|
|
62
|
+
f.count++
|
|
63
|
+
if (severityRank(r.severity) > severityRank(f.severity)) f.severity = r.severity
|
|
64
|
+
if (r.misses > 0) f.unchecked++
|
|
65
|
+
else if (seen) f.last_seen = seen
|
|
66
|
+
if (r.first_at && (!f.first_seen || r.first_at < f.first_seen.at)) f.first_seen = { run: r.first_run, at: r.first_at }
|
|
67
|
+
if (f.samples.length < SAMPLE_MAX) {
|
|
68
|
+
const ek = `${k.step}|${k.page}`
|
|
69
|
+
f.samples.push({ page: k.page || null, url: r.url || null, edit_url: (edit && Object.hasOwn(edit, ek) ? edit[ek] : null) || null })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return [...out.values()].sort(worstFirst)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const FINDINGS_SUMMARY_SCHEMA = 'kaloko.health-findings-summary.v1'
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The findings of a flow after a recorded run, made once and kept with the run's health files: groupFindings of what
|
|
79
|
+
* the flow keeps open, plus the findings whose every page the run fixed (`fixed`: the diff's fixed results as rows;
|
|
80
|
+
* count 0, last seen `previous`). An open finding counts the pages the run fixed in `fixed` too.
|
|
81
|
+
*/
|
|
82
|
+
export function summarizeFindings (rows, { checks = {}, edit = {}, seen = null, fixed = [], previous = null } = {}) {
|
|
83
|
+
const list = groupFindings(rows, { checks, edit, seen })
|
|
84
|
+
const at = new Map(list.map((f) => [f.id, f]))
|
|
85
|
+
for (const g of groupFindings(fixed, { checks, edit })) {
|
|
86
|
+
const f = at.get(g.id)
|
|
87
|
+
if (f) { f.fixed = g.count; continue }
|
|
88
|
+
list.push({ ...g, count: 0, fixed: g.count, unchecked: 0, last_seen: previous })
|
|
89
|
+
}
|
|
90
|
+
for (const f of list) f.fixed ||= 0
|
|
91
|
+
return { schema: FINDINGS_SUMMARY_SCHEMA, run: seen?.run || null, started_at: seen?.at || null, findings: list }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** An http(s) URL as given, or null: what may become a link in a page or an issue. */
|
|
95
|
+
export function httpUrl (u) {
|
|
96
|
+
if (!u) return null
|
|
97
|
+
try { const x = new URL(String(u)); return /^https?:$/.test(x.protocol) ? x.href : null } catch { return null }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Errors before warnings before unsure, then the most pages, then by fingerprint (stable). */
|
|
101
|
+
export const worstFirst = (a, b) => severityRank(b.severity) - severityRank(a.severity) || b.count - a.count || (a.fingerprint < b.fingerprint ? -1 : 1)
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The status of a finding: fixed when none of its results is open (count 0), else what a person set (acknowledged,
|
|
105
|
+
* ignored), else open.
|
|
106
|
+
*/
|
|
107
|
+
export const statusOf = (count, stored) => count > 0 ? (FINDING_STATUSES.includes(stored) ? stored : 'open') : 'fixed'
|
|
108
|
+
|
|
109
|
+
/** At least this severity: error ≥ warning ≥ info. */
|
|
110
|
+
export const atLeastSeverity = (severity, min) => SEVERITIES.includes(min) && severityRank(severity) >= severityRank(min)
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A change of the number of affected pages worth telling the tracker about: by at least 3 pages and a quarter of what
|
|
114
|
+
* the issue last said.
|
|
115
|
+
*/
|
|
116
|
+
export const countChanged = (before, now) => before != null && Math.abs(now - before) >= Math.max(3, Math.ceil(before * 0.25))
|
package/src/lib/health-index.mjs
CHANGED
|
@@ -20,6 +20,8 @@ export const VERDICTS = ['pass', 'fail', 'needs_review', 'not_evaluated']
|
|
|
20
20
|
export const TOP_KEEP = 20
|
|
21
21
|
/** Open results a run keeps (worst first); a site with more is cut and says so (`truncated`). */
|
|
22
22
|
export const OPEN_MAX = 50000
|
|
23
|
+
/** Edit links an index keeps (pages of the open list, worst first): a bounded index for any catalogue. */
|
|
24
|
+
export const EDIT_MAX = 5000
|
|
23
25
|
|
|
24
26
|
const blank = () => ({ pass: 0, fail: 0, needs_review: 0, not_evaluated: 0 })
|
|
25
27
|
const cut = (s, n = 200) => s == null ? null : String(s).slice(0, n)
|
|
@@ -38,13 +40,16 @@ const firstUrl = (vps) => { for (const c of Object.values(vps || {})) if (c?.url
|
|
|
38
40
|
* The index of a run manifest. `stacks` maps a stack step's id to its items index (steps/<node>/items.json); a stack
|
|
39
41
|
* step without one counts as one page. Returns { schema, checks: { HC…: { text, pack, level } }, totals, groups:
|
|
40
42
|
* [{ locale, step, check, counts, top: [{ page, path, url, verdict, evidence }] }], open: [[key, severity, url]],
|
|
41
|
-
* open_total, truncated, passed }. passed says which checks passed on which page: { "step|locale": { sigs:
|
|
42
|
-
* …], pages: { <page>: <index into sigs> } } }; only a result that passed counts as fixed later
|
|
43
|
+
* open_total, truncated, passed, edit }. passed says which checks passed on which page: { "step|locale": { sigs:
|
|
44
|
+
* ["HC103,HC112", …], pages: { <page>: <index into sigs> } } }; only a result that passed counts as fixed later
|
|
45
|
+
* (passedIn). edit maps "step|page" to the edit_url of a stack page with open results (findings link it).
|
|
43
46
|
*/
|
|
44
47
|
export function buildHealthIndex (manifest, stacks = {}) {
|
|
45
48
|
const checks = Object.create(null); const groups = new Map(); const open = []; const pages = new Set(); const ok = new Map()
|
|
49
|
+
// where a page with open results is edited (a stack item's edit_url): "step|page" → URL, kept below for the open list only
|
|
50
|
+
const edits = new Map()
|
|
46
51
|
const totals = { results: 0, ...blank() }
|
|
47
|
-
const add = (step, locale, page, path, url, check, verdict, evidence) => {
|
|
52
|
+
const add = (step, locale, page, path, url, check, verdict, evidence, editUrl = null) => {
|
|
48
53
|
if (!VERDICTS.includes(verdict)) return
|
|
49
54
|
pages.add(`${step}|${page}`)
|
|
50
55
|
const gk = `${locale}\u0000${step}\u0000${check}`
|
|
@@ -57,6 +62,7 @@ export function buildHealthIndex (manifest, stacks = {}) {
|
|
|
57
62
|
const list = verdict === 'fail' ? g.fails : g.unsure
|
|
58
63
|
if (list.length < TOP_KEEP) list.push({ page, ...(path ? { path } : {}), url: url || null, verdict, ...(evidence ? { evidence: cut(evidence) } : {}) })
|
|
59
64
|
open.push([healthKey(step, locale, check, page), severity, url || null])
|
|
65
|
+
if (editUrl && typeof editUrl === 'string' && /^https?:\/\//i.test(editUrl)) edits.set(`${step}|${page}`, cut(editUrl, 500))
|
|
60
66
|
}
|
|
61
67
|
for (const n of manifest?.nodes || []) {
|
|
62
68
|
if (n.kind === 'decision') continue
|
|
@@ -79,7 +85,7 @@ export function buildHealthIndex (manifest, stacks = {}) {
|
|
|
79
85
|
if (!was || worst([was.v, x[0]]) !== was.v) per.set(k, { locale, id, v: x[0], e: x[1] })
|
|
80
86
|
}
|
|
81
87
|
}
|
|
82
|
-
for (const { locale, id, v, e } of per.values()) add(n.id, locale, String(it.id), it.path || null, it.url || firstUrl(it.captures?.[locale]), id, v, e)
|
|
88
|
+
for (const { locale, id, v, e } of per.values()) add(n.id, locale, String(it.id), it.path || null, it.url || firstUrl(it.captures?.[locale]), id, v, e, it.edit_url)
|
|
83
89
|
}
|
|
84
90
|
continue
|
|
85
91
|
}
|
|
@@ -106,10 +112,19 @@ export function buildHealthIndex (manifest, stacks = {}) {
|
|
|
106
112
|
if (i < 0) { i = w.sigs.length; w.sigs.push(sig) }
|
|
107
113
|
w.pages[page] = i
|
|
108
114
|
}
|
|
115
|
+
// edit links of the pages in the kept open list only, at most EDIT_MAX of them (worst first, like the list)
|
|
116
|
+
const kept = open.slice(0, OPEN_MAX)
|
|
117
|
+
const edit = Object.create(null); let links = 0
|
|
118
|
+
for (const [key] of kept) {
|
|
119
|
+
if (links >= EDIT_MAX) break
|
|
120
|
+
const { step, page } = parseHealthKey(key)
|
|
121
|
+
const k = `${step}|${page}`
|
|
122
|
+
if (edits.has(k) && !Object.hasOwn(edit, k)) { edit[k] = edits.get(k); links++ }
|
|
123
|
+
}
|
|
109
124
|
return {
|
|
110
125
|
schema: HEALTH_INDEX_SCHEMA, checks, totals: { ...totals, pages: pages.size },
|
|
111
126
|
groups: [...groups.values()].map(({ fails, unsure, ...g }) => ({ ...g, top: [...fails, ...unsure].slice(0, TOP_KEEP) })),
|
|
112
|
-
open:
|
|
127
|
+
open: kept, open_total: open.length, truncated: open.length > OPEN_MAX, passed, edit
|
|
113
128
|
}
|
|
114
129
|
}
|
|
115
130
|
|
|
@@ -80,7 +80,10 @@ export async function hostedAuth ({ env, account = null, root, config, vars = pr
|
|
|
80
80
|
const atHour = (v, d = 5) => { const n = Number(String(v ?? '').split(':')[0]); return Number.isInteger(n) && n >= 0 && n <= 23 ? n : d }
|
|
81
81
|
const weekdayOf = (v, d = 1) => WEEKDAYS[String(v || '').slice(0, 3).toLowerCase()] || (Number(v) >= 1 && Number(v) <= 7 ? Number(v) : d)
|
|
82
82
|
const when = (s) => s.every === 'manual' ? 'on demand' : `${s.every === 'week' ? `weekly on ${Object.keys(WEEKDAYS)[s.weekday - 1]}` : 'daily'} at ${String(s.at_hour).padStart(2, '0')}:00 UTC`
|
|
83
|
-
|
|
83
|
+
/** The environment's base URL as the service takes it: one address, or one per locale (the same rule as walk: default is the fallback). */
|
|
84
|
+
export const hostedBase = (env, baseUrl) => typeof env.base_url === 'string' ? baseUrl(env) : Object.fromEntries(Object.keys(env.base_url).map((k) => [k, baseUrl(env, k)]))
|
|
85
|
+
const baseLine = (b) => typeof b === 'string' ? b : Object.entries(b || {}).map(([k, v]) => `${k} ${v}`).join(', ')
|
|
86
|
+
const scheduleLine = (s) => `${s.id} ${s.scenario_id} on ${s.environment} (${baseLine(s.base_url)}) · ${when(s)}${s.enabled ? '' : ' · paused'}${s.pages_per_run ? ` · ${s.pages_per_run} pages` : ''}${s.stack_steps?.length ? ` + stacks ${s.stack_steps.join(', ')}` : ''}${s.walking ? ` · reading stack pages ${s.walking.done}/${s.walking.total}` : ''}${s.last_health ? ` · health ${s.last_health.new} new, ${s.last_health.fixed} fixed` : ''}\n next ${s.enabled && s.next_run_at ? s.next_run_at.slice(0, 16).replace('T', ' ') : '—'} · last ${s.last_run_at ? `${s.last_run_at.slice(0, 16).replace('T', ' ')} ${s.last_status}${s.last_run_id ? ` ${s.last_run_id}` : ''}` : '—'}${s.last_error ? `\n ! ${s.last_error}` : ''}`
|
|
84
87
|
|
|
85
88
|
/** The GitHub Actions workflow that walks a scenario on a schedule with the full CLI and shares the run. */
|
|
86
89
|
export function githubScheduleWorkflow ({ scenario, scenarioId, env, every = 'day', at_hour = 5, weekday = 1, pm = 'npm', dependency = true, version = 'latest', node = 22, browsers = ['chromium'], secrets = [] }) {
|
|
@@ -188,19 +191,18 @@ export async function scheduleCmd ({ args, config, root, say, deps }) {
|
|
|
188
191
|
const env = deps.environment(config, envName)
|
|
189
192
|
if (scenario.environments && !scenario.environments.includes(envName)) throw new UserError(`${relative(root, file)} allows environments [${scenario.environments.join(', ')}], not ${envName}.`)
|
|
190
193
|
if (env.kind === 'design' || env.base_url == null) throw new UserError(`${envName} has no web address; hosted runs open web pages.`)
|
|
191
|
-
if (typeof env.base_url !== 'string') throw new UserError(`${envName} has a base_url per locale; hosted runs take one address. Walk it in CI: kaloko schedule export github.`)
|
|
192
194
|
const every = ['day', 'week', 'manual'].includes(args.every) ? args.every : 'day'
|
|
193
195
|
const { project } = await deps.projectAndIssue({ cwd: root, config, scenario, branch: null })
|
|
194
196
|
// health alerts (theme K): off, error (the service's default) or warning; unset keeps what the schedule has
|
|
195
197
|
if (args['health-alert'] != null && !['off', 'error', 'warning'].includes(args['health-alert'])) throw new UsageError(`--health-alert takes off, error or warning, not ${args['health-alert']}.`)
|
|
196
|
-
const body = { scenario: await hostedScenario(scenario, file), environment: envName, base_url: deps.baseUrl
|
|
198
|
+
const body = { scenario: await hostedScenario(scenario, file), environment: envName, base_url: hostedBase(env, deps.baseUrl), every, at_hour: atHour(args.at), weekday: weekdayOf(args.weekday), project: project?.slug || null, auth: await hostedAuth({ env, account: typeof args.account === 'string' ? args.account : null, root, config }), ...(typeof args['health-alert'] === 'string' ? { health_alert: args['health-alert'] } : {}) }
|
|
197
199
|
let s
|
|
198
200
|
try { s = await client().request('POST', `/orgs/${org}/schedules`, body) } catch (e) {
|
|
199
201
|
if (/HTTP 404/.test(e.message)) throw new UserError(`${e.message}\nWalk it on a schedule in CI instead: kaloko schedule export github --scenario ${relative(root, file)} --env ${envName}`)
|
|
200
202
|
throw e
|
|
201
203
|
}
|
|
202
204
|
if (args.json) { say(JSON.stringify(s, null, 2)); return s }
|
|
203
|
-
say(`✓ ${s.created ? 'Scheduled' : 'Updated'} ${s.scenario_id} on ${envName} ${when(s)}: ${s.pages_per_run} pages per run${s.auth !== 'none' ? ` (${s.auth === 'basic' ? 'HTTP Basic' : 'saved session'})` : ''}. Runs are shared as you and land in the inbox.`)
|
|
205
|
+
say(`✓ ${s.created ? 'Scheduled' : 'Updated'} ${s.scenario_id} on ${envName} ${when(s)}: ${s.pages_per_run} pages per run${s.stack_steps?.length ? ` and the pages of ${s.stack_steps.join(', ')} (read without screenshots, each page counts)` : ''}${s.auth !== 'none' ? ` (${s.auth === 'basic' ? 'HTTP Basic' : 'saved session'})` : ''}. Runs are shared as you and land in the inbox.`)
|
|
204
206
|
for (const k of s.skipped || []) say(` - ${k.id}: not walked (${k.reason})`)
|
|
205
207
|
if ((s.skipped || []).length) say(' Steps that click or type need the full walk: kaloko schedule export github')
|
|
206
208
|
return s
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import { packHelp } from './pack-help.mjs'
|
|
6
6
|
import { isHealthCriterion, levelOf, healthSummary } from './health-core.mjs'
|
|
7
7
|
import { stackChecks } from './seo-core.mjs'
|
|
8
|
+
import { variantCheck, VARIANT_LIST_MAX } from './variants-core.mjs'
|
|
9
|
+
import { trafficKey, mergeTraffic, trafficView } from './traffic-core.mjs'
|
|
8
10
|
|
|
9
11
|
const RANK = { fail: 4, needs_review: 3, not_evaluated: 2, pass: 1, not_reached: 0 }
|
|
10
12
|
export const worst = (list) => list.reduce((a, b) => (RANK[b] > RANK[a] ? b : a), 'not_reached')
|
|
@@ -247,7 +249,7 @@ export const stackFieldsOf = (node) => (node.fields?.length ? node.fields.map((f
|
|
|
247
249
|
* captures: [{ item, locale, viewport, cap (capture record), ev (valid evaluation or null), files: { png, thumb?, html?, eval } }]
|
|
248
250
|
* in walk order. Returns { node, index, file } where `file` is the index's path in the run.
|
|
249
251
|
*/
|
|
250
|
-
export function assembleStack ({ node, base, environment = null, captures, attachments = [], rotation = null }) {
|
|
252
|
+
export function assembleStack ({ node, base, environment = null, captures, attachments = [], rotation = null, traffic = null, variants = null }) {
|
|
251
253
|
const fields = stackFieldsOf(node).map((f) => ({ id: f.id, label: f.label || f.id }))
|
|
252
254
|
const crit = (node.criteria || []).map((c) => ({ id: c.id, text: c.text, type: c.check.type, pack: c.pack || null, ...(c.refs?.length ? { refs: c.refs } : {}), ...(c.from ? { from: c.from } : {}), ...(c.pack && c.acceptance ? { acceptance: true } : {}), ...(isHealthCriterion(c) ? { health: true, level: levelOf(c.id), check: c.check } : {}), ...(appliesOnEnv(c, environment) ? {} : { not_applicable: 'environment' }) }))
|
|
253
255
|
// criteria limited to other environments do not decide the status of a page, and neither do health checks
|
|
@@ -256,17 +258,23 @@ export function assembleStack ({ node, base, environment = null, captures, attac
|
|
|
256
258
|
for (const c of captures) {
|
|
257
259
|
const { cap, ev } = c
|
|
258
260
|
let it = byItem.get(c.item)
|
|
259
|
-
|
|
261
|
+
// a locale variant (#176) carries the source page's path: the item is the source page, whichever locale came first
|
|
262
|
+
const variant = !!cap.item?.source_path
|
|
263
|
+
if (!it) { it = { id: c.item, path: cap.item?.source_path || cap.item?.path || null, url: cap.url, title: cap.item?.title || cap.fields?.title || cap.metadata?.title || null, fields: cap.fields || {}, captures: {}, results: {}, order: byItem.size, variant, ...(cap.item?.edit_url ? { edit_url: cap.item.edit_url } : {}), ...(cap.item?.content_ref ? { content_ref: cap.item.content_ref } : {}), ...(cap.item && 'last_checked' in cap.item ? { last_checked: cap.item.last_checked } : {}) }; byItem.set(c.item, it) }
|
|
264
|
+
else if (it.variant && !variant && cap.item) {
|
|
265
|
+
// the source page's own capture names the item: its address, title and index fields
|
|
266
|
+
Object.assign(it, { url: cap.url, title: cap.item.title || cap.fields?.title || cap.metadata?.title || it.title, fields: cap.fields || it.fields, variant: false, ...(cap.item.edit_url ? { edit_url: cap.item.edit_url } : {}), ...(cap.item.content_ref ? { content_ref: cap.item.content_ref } : {}) })
|
|
267
|
+
}
|
|
260
268
|
// the structured data types of the page (JSON-LD and microdata), from its first capture that read them
|
|
261
269
|
if (cap.structured_data && !it.structured_data) it.structured_data = { types: cap.structured_data.types || [], invalid: cap.structured_data.invalid || 0 }
|
|
262
|
-
;(it.captures[c.locale] ||= {})[c.viewport] = { png: c.files.png, thumb: c.files.thumb || null, html: c.files.html || null, url: cap.url, size: cap.size ? (Array.isArray(cap.size) ? cap.size : [cap.size.width, cap.size.height]) : null, boxes: cap.boxes, captured_at: cap.captured_at, console_errors: (cap.console_errors || []).length, console: (cap.console_errors || []).slice(0, 10), overflow: (cap.overflow || []).length, meta: cap.meta, ...(cap.http_status ? { http_status: cap.http_status } : {}), ...(cap.capture?.full_page === false ? { first_screen: true } : {}), ...(c.live ? { live: c.live } : {}), ...Object.fromEntries(DIM_FIELDS.filter((k) => cap[k]).map((k) => [k, cap[k]])), eval: ev ? c.files.eval : null }
|
|
270
|
+
;(it.captures[c.locale] ||= {})[c.viewport] = { png: c.files.png, thumb: c.files.thumb || null, html: c.files.html || null, url: cap.url, ...(cap.item?.path && cap.item.path !== it.path ? { path: cap.item.path } : {}), size: cap.size ? (Array.isArray(cap.size) ? cap.size : [cap.size.width, cap.size.height]) : null, boxes: cap.boxes, captured_at: cap.captured_at, console_errors: (cap.console_errors || []).length, console: (cap.console_errors || []).slice(0, 10), overflow: (cap.overflow || []).length, meta: cap.meta, ...(cap.http_status ? { http_status: cap.http_status } : {}), ...(cap.capture?.full_page === false ? { first_screen: true } : {}), ...(c.live ? { live: c.live } : {}), ...Object.fromEntries(DIM_FIELDS.filter((k) => cap[k]).map((k) => [k, cap[k]])), eval: ev ? c.files.eval : null }
|
|
263
271
|
// a criterion limited to other step states (#46) does not decide this capture
|
|
264
272
|
// nor does an error-page check on a page that is no error page (errors pack HC601, HC605)
|
|
265
273
|
for (const r of ev?.criteria || []) if (r.not_applicable === 'error_page') naPage.add(r.id)
|
|
266
274
|
it.results[`${c.locale}|${c.viewport}`] = ev ? Object.fromEntries((ev.criteria || []).filter((r) => r.not_applicable !== 'state' && r.not_applicable !== 'error_page').map((r) => [r.id, [r.verdict, r.evidence ? String(r.evidence).slice(0, 200) : null, r.score ?? null]])) : Object.fromEntries(crit.filter((x) => x.type !== 'semantic').map((x) => [x.id, ['not_evaluated', null, null]]))
|
|
267
275
|
if (!ev) it.stale = true
|
|
268
276
|
}
|
|
269
|
-
const items = [...byItem.values()].sort((a, b) => a.order - b.order).map(({ order, ...it }) => {
|
|
277
|
+
const items = [...byItem.values()].sort((a, b) => a.order - b.order).map(({ order, variant: _v, ...it }) => {
|
|
270
278
|
const per = Object.values(it.results)
|
|
271
279
|
const verdictOf = (id) => worst(per.map((r) => r[id]?.[0]).filter(Boolean))
|
|
272
280
|
const verdicts = crit.filter((c) => live.has(c.id)).map((c) => verdictOf(c.id)).filter((v) => v !== 'not_reached')
|
|
@@ -291,15 +299,20 @@ export function assembleStack ({ node, base, environment = null, captures, attac
|
|
|
291
299
|
const criteria = judged.filter((c) => !c.health)
|
|
292
300
|
const src = node.items || {}
|
|
293
301
|
const source = ['urls', 'file', 'sitemap', 'robots', 'crawl', 'script'].find((k) => src[k]) || null
|
|
302
|
+
// traffic data (#157): Search Console and GA4 numbers per page, joined by URL (traffic-core.mjs)
|
|
303
|
+
const joined = traffic?.table ? joinTraffic(items, traffic.table) : null
|
|
294
304
|
// cross-page checks (#151): a stack with the seo pack compares its pages with each other; advisory like the pack
|
|
295
|
-
|
|
305
|
+
// locale variants (#176): pages without their own version in a walked locale, marked on the page and as HC1005
|
|
306
|
+
const vars = variantsOf(variants, items)
|
|
307
|
+
const varCheck = vars ? variantCheck(vars.locales) : null
|
|
308
|
+
const cross = [...(hasSeoPack(node) ? stackChecks({ items, source, traffic: joined?.matched ? traffic : null }) : []), ...(varCheck ? [varCheck] : [])]
|
|
296
309
|
for (const x of cross) { const hit = new Set(x.items); for (const it of items) if (hit.has(it.id)) (it.cross_fails ||= []).push(x.id) }
|
|
297
310
|
const health = [...judged.filter((c) => c.health).map(({ health: _h, ...c }) => c), ...cross.map(crossHealth)]
|
|
298
311
|
const semanticSample = node.items?.semantic_sample ?? STACK_SEMANTIC_SAMPLE
|
|
299
312
|
const file = `steps/${node.id}/items.json`
|
|
300
313
|
// a rotating sample (items.rotate): per locale the window of this run and how much of the list runs have covered
|
|
301
314
|
const rot = rotation && Object.keys(rotation).length ? rotation : null
|
|
302
|
-
const index = { schema: 'qawalk.stack.v1', node: node.id, fields, criteria: crit, semantic_sample: semanticSample, ...(structured ? { structured_data: structured } : {}), ...(rot ? { rotation: rot } : {}), ...(cross.length ? { cross_page: cross.map(({ items: _i, ...x }) => x) } : {}), items }
|
|
315
|
+
const index = { schema: 'qawalk.stack.v1', node: node.id, fields, criteria: crit, semantic_sample: semanticSample, ...(structured ? { structured_data: structured } : {}), ...(rot ? { rotation: rot } : {}), ...(vars ? { variants: vars.index } : {}), ...(cross.length ? { cross_page: cross.map(({ items: _i, ...x }) => x) } : {}), items }
|
|
303
316
|
const thumbOf = (it) => { const c = Object.values(it.captures)[0]; const v = c && Object.values(c)[0]; return v ? v.thumb || v.png : null }
|
|
304
317
|
const sample = [...items.filter((it) => it.status === 'fail'), ...items.filter((it) => it.status !== 'fail')].slice(0, 4).map((it) => ({ id: it.id, title: it.title, path: it.path, status: it.status, thumb: thumbOf(it) }))
|
|
305
318
|
const status = !items.length ? 'skip' : summary.fail ? 'fail' : summary.partial || summary.skip ? 'partial' : 'pass'
|
|
@@ -309,14 +322,76 @@ export function assembleStack ({ node, base, environment = null, captures, attac
|
|
|
309
322
|
...base, status, by_locale, captures: {}, criteria, ...(health.length ? { health } : {}), semantic: {}, metadata: {}, session: node.session || null,
|
|
310
323
|
execution: node.execution ? { strategy: node.execution.strategy, instructions: node.execution.instructions } : { strategy: 'playwright', instructions: 'Open every page of the stack and capture it.' },
|
|
311
324
|
attachments: stepAttachments(attachments, { node: node.id }),
|
|
312
|
-
stack: { count: items.length, items: file, summary, fields, sample, semantic_sample: semanticSample, source, stale: items.filter((it) => it.stale).length, ...(structured ? { structured_data: structured } : {}), ...(rot ? { rotation: rot } : {}), ...(cross.length ? { cross_page: cross.map((x) => ({ id: x.id, verdict: x.verdict, pages: x.pages, affected: x.affected })) } : {}) }
|
|
325
|
+
stack: { count: items.length, items: file, summary, fields, sample, semantic_sample: semanticSample, source, stale: items.filter((it) => it.stale).length, ...(structured ? { structured_data: structured } : {}), ...(rot ? { rotation: rot } : {}), ...(vars ? { variants: vars.summary } : {}), ...(cross.length ? { cross_page: cross.map((x) => ({ id: x.id, verdict: x.verdict, pages: x.pages, affected: x.affected })) } : {}), ...(joined ? { traffic: trafficSummary(items, joined) } : {}) }
|
|
313
326
|
}
|
|
314
327
|
return { node: out, index, file }
|
|
315
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* Locale variants of a stack (#176) from the walk's `variants.<locale>.json` ({ [locale]: { mode, source_locale,
|
|
331
|
+
* pages, missing, shared, source? } }): marks each page with the locales it has no version in (`missing`, only where
|
|
332
|
+
* no capture of that locale exists, so a later walk that found one wins, or where the capture answered 4xx/5xx) and
|
|
333
|
+
* returns { locales (for HC1005), index
|
|
334
|
+
* (items.json), summary (the node) }, or null without variants.
|
|
335
|
+
*/
|
|
336
|
+
function variantsOf (variants, items) {
|
|
337
|
+
const all = Object.entries(variants || {}).filter(([, v]) => v?.mode === 'hreflang')
|
|
338
|
+
if (!all.length) return null
|
|
339
|
+
const byId = new Map(items.map((it) => [it.id, it]))
|
|
340
|
+
const sourceLocale = all.find(([, v]) => v.source)?.[1].source_locale || all[0][1].source_locale
|
|
341
|
+
const locales = {}
|
|
342
|
+
for (const [l, v] of all) {
|
|
343
|
+
if (v.source || l === v.source_locale) { locales[l] = { ...v, source: true, missing: [], shared: [] }; continue }
|
|
344
|
+
const missing = (v.missing || []).filter((m) => !byId.get(m.id)?.captures?.[l])
|
|
345
|
+
// a translation the hreflang names but that answers 4xx/5xx is walked, and missing all the same
|
|
346
|
+
for (const it of items) {
|
|
347
|
+
const c = Object.values(it.captures?.[l] || {}).find((x) => x?.http_status >= 400)
|
|
348
|
+
if (c) missing.push({ id: it.id, path: it.path, url: c.url, variant: c.path || null, reason: `error ${c.http_status}` })
|
|
349
|
+
}
|
|
350
|
+
for (const m of missing) { const it = byId.get(m.id); if (it && !(it.missing ||= []).includes(l)) it.missing.push(l) }
|
|
351
|
+
locales[l] = { ...v, missing, shared: v.shared || [] }
|
|
352
|
+
}
|
|
353
|
+
const cap = (list) => list.slice(0, VARIANT_LIST_MAX)
|
|
354
|
+
return {
|
|
355
|
+
locales,
|
|
356
|
+
index: { mode: 'hreflang', source_locale: sourceLocale, locales: Object.fromEntries(Object.entries(locales).map(([l, v]) => [l, v.source ? { source: true, pages: v.pages || 0 } : { pages: v.pages || 0, missing: cap(v.missing), shared: cap(v.shared), ...(v.missing.length > VARIANT_LIST_MAX ? { more_missing: v.missing.length - VARIANT_LIST_MAX } : {}) }])) },
|
|
357
|
+
summary: { mode: 'hreflang', source_locale: sourceLocale, missing: Object.fromEntries(Object.entries(locales).filter(([, v]) => !v.source).map(([l, v]) => [l, v.missing.length])), shared: Object.fromEntries(Object.entries(locales).filter(([, v]) => !v.source && v.shared.length).map(([l, v]) => [l, v.shared.length])) }
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Puts the traffic of each page on it (it.traffic: clicks, impressions, ctr, position, sessions, conversions), looked
|
|
362
|
+
* up by every address its captures ended on, one per address, so the language versions of a page add up; the
|
|
363
|
+
* address asked for only when none of those is found. Rows that are paths without a host (GA4) join only when the
|
|
364
|
+
* stack walks one host. Returns { matched, note }.
|
|
365
|
+
*/
|
|
366
|
+
export function joinTraffic (items, table) {
|
|
367
|
+
const hosts = new Set()
|
|
368
|
+
for (const it of items) for (const vps of Object.values(it.captures || {})) for (const c of Object.values(vps || {})) { try { hosts.add(new URL(c?.url || it.url).host) } catch {} }
|
|
369
|
+
const paths = hosts.size <= 1
|
|
370
|
+
let matched = 0
|
|
371
|
+
for (const it of items) {
|
|
372
|
+
const hits = new Map()
|
|
373
|
+
for (const vps of Object.values(it.captures || {})) {
|
|
374
|
+
for (const c of Object.values(vps || {})) {
|
|
375
|
+
const k = c?.url ? trafficKey(c.url) : null
|
|
376
|
+
if (!k || hits.has(k)) continue
|
|
377
|
+
const t = table.of(c.url, { paths })
|
|
378
|
+
if (t) hits.set(k, t)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (!hits.size && it.url) { const t = table.of(it.url, { paths }); if (t) hits.set(trafficKey(it.url), t) }
|
|
382
|
+
if (hits.size) { it.traffic = trafficView(mergeTraffic([...hits.values()])); matched++ }
|
|
383
|
+
}
|
|
384
|
+
return { matched, note: !paths && table.bare ? `${table.bare} GA4 path(s) without a host not joined: the stack walks ${hosts.size} hosts` : null }
|
|
385
|
+
}
|
|
386
|
+
/** The traffic of a stack in its node: pages with data and their clicks, impressions and sessions added up. */
|
|
387
|
+
const trafficSummary = (items, { matched, note }) => {
|
|
388
|
+
const sum = (k) => items.reduce((s, it) => s + (it.traffic?.[k] || 0), 0)
|
|
389
|
+
return { pages: matched, of: items.length, clicks: sum('clicks'), impressions: sum('impressions'), sessions: sum('sessions'), ...(note ? { note } : {}) }
|
|
390
|
+
}
|
|
316
391
|
const hasSeoPack = (node) => (node.packs || []).includes('seo') || (node.criteria || []).some((c) => c.pack === 'seo')
|
|
317
392
|
/** A cross-page check as a health entry of the stack node: counts of pages, evidence and the first addresses. */
|
|
318
393
|
const crossHealth = (x) => ({
|
|
319
|
-
id: x.id, text: x.text, type: 'deterministic', pack: 'seo', level: x.level, verdict: x.verdict, by_locale: {}, cross_page: true,
|
|
394
|
+
id: x.id, text: x.text, type: 'deterministic', pack: x.pack || 'seo', level: x.level, verdict: x.verdict, by_locale: {}, cross_page: true,
|
|
320
395
|
counts: x.verdict === 'fail' ? { pass: Math.max(0, x.pages - x.affected), fail: x.affected } : { pass: x.pages }, evidence: x.evidence,
|
|
321
396
|
...(x.groups.length ? { urls: x.groups.flatMap((g) => g.urls).slice(0, 20) } : {}),
|
|
322
397
|
...(packHelp(x.id) ? { help: { en: packHelp(x.id, 'en'), cs: packHelp(x.id, 'cs') } } : {})
|
package/src/lib/pack-help.mjs
CHANGED
|
@@ -24,6 +24,7 @@ export const PACK_HELP = {
|
|
|
24
24
|
HC124: { cs: 'Každá stránka je svou kanonickou adresou', why: ['A canonical that points elsewhere asks search engines to drop the page; pointing at an error or noindex page drops both.', 'Kanonická adresa jinam žádá vyhledávač, aby stránku vynechal; když míří na chybovou stránku nebo noindex, vypadnou obě.'], fix: ['Point the canonical at the page itself, or take the copies out of the sitemap and the stack. The evidence shows where the clusters point.', 'Nastavte kanonickou adresu na stránku samotnou, nebo kopie vyřaďte ze sitemapy i ze stacku. Kam shluky míří, ukazuje důkaz.'] },
|
|
25
25
|
HC125: { cs: 'Jazykové verze na sebe přes hreflang odkazují navzájem', why: ['Search engines ignore hreflang that is not confirmed from the other side, so the wrong language shows up in results.', 'Vyhledávač hreflang bez potvrzení z druhé strany ignoruje a ve výsledcích se ukáže špatný jazyk.'], fix: ['Give every language version the same complete set of alternates, including itself and ideally x-default, all pointing to addresses that answer 200.', 'Dejte všem jazykovým verzím stejnou úplnou sadu alternativ včetně sebe sama a ideálně x-default; všechny ať vedou na adresy, které odpovídají 200.'] },
|
|
26
26
|
HC126: { cs: 'Stránky ze sitemapy odpovídají 200, nepřesměrují a jdou indexovat', why: ['A sitemap is the list of pages you want found; redirects, errors and noindex in it waste the crawl and erode trust in the whole list.', 'Sitemapa je seznam stránek, které chcete nechat najít; přesměrování, chyby a noindex v ní plýtvají procházením a podkopávají důvěru v celý seznam.'], fix: ['List the final addresses only: drop deleted pages, replace redirected ones with their target, and leave out pages with noindex.', 'Uvádějte jen cílové adresy: smazané stránky vyřaďte, přesměrované nahraďte cílem a stránky s noindex vynechte.'] },
|
|
27
|
+
HC127: { cs: 'Často zobrazované stránky nemají výrazně nižší míru prokliku', why: ['Searchers see the page but pick another result: the title and description in the results do not promise what they are looking for.', 'Lidé stránku ve výsledcích vidí, ale kliknou jinam: titulek a popis ve výsledcích neslibují to, co hledají.'], fix: ['Rewrite the title and meta description of the listed pages so they answer the query people see them for (Search Console shows the queries). Compare the CTR again after a few weeks.', 'Přepište titulek a meta popis uvedených stránek tak, aby odpovídaly dotazům, u kterých se zobrazují (dotazy ukáže Search Console). Míru prokliku porovnejte znovu za pár týdnů.'] },
|
|
27
28
|
HC201: { cs: 'Účel stránky je jasný hned na první obrazovce', why: ['Visitors decide within seconds whether they are in the right place.', 'Návštěvník se během pár vteřin rozhodne, jestli je na správném místě.'], fix: ['Make the heading and first sentence say what the page is for and for whom.', 'Ať nadpis a první věta řeknou, k čemu stránka je a pro koho.'] },
|
|
28
29
|
HC202: { cs: 'Jedna jasná hlavní akce', why: ['Several equally loud buttons make people hesitate; none leaves them without a next step.', 'Několik stejně výrazných tlačítek lidi zdrží; žádné je nechá bez dalšího kroku.'], fix: ['Give one action the primary style; make the others secondary or links.', 'Jedné akci dejte hlavní styl, ostatní udělejte vedlejší nebo jako odkazy.'] },
|
|
29
30
|
HC203: { cs: 'Formulářová pole mají viditelný popisek', why: ['A placeholder disappears as people type; they forget what the field was.', 'Placeholder při psaní zmizí a lidé zapomenou, co do pole patří.'], fix: ['Add a <label for="…"> (or aria-label) to every visible input.', 'Ke každému viditelnému poli doplňte <label for="…"> (nebo aria-label).'] },
|
|
@@ -74,6 +75,7 @@ export const PACK_HELP = {
|
|
|
74
75
|
HC1002: { cs: 'Vlastní hreflang stránky odpovídá jazyku, kódy jsou platné a sedí k adresám', why: ['A wrong own hreflang or a code such as en_UK makes search engines skip the whole set of language versions.', 'Špatný vlastní hreflang nebo kód typu en_UK způsobí, že vyhledávač celou sadu jazykových verzí přeskočí.'], fix: ['Use language-REGION codes with a hyphen (en-GB, pt-BR), name the page itself with its own language, and keep each code on the address of that language.', 'Používejte kódy jazyk-REGION se spojovníkem (en-GB, pt-BR), stránku samotnou označte jejím jazykem a každý kód dejte k adrese téhož jazyka.'] },
|
|
75
76
|
HC1003: { cs: 'Text je v jazyce procházené verze', why: ['An untranslated page in a language version loses its visitors and looks like duplicate content of the source language.', 'Nepřeložená stránka v jazykové verzi ztratí návštěvníky a pro vyhledávač vypadá jako duplicita zdrojového jazyka.'], fix: ['Translate the page, or keep it out of this language version and its sitemap until it is translated. Kaloko counts common words, so short pages and close languages (Danish, Norwegian, Swedish) are not judged.', 'Stránku přeložte, nebo ji do překladu z této jazykové verze i sitemapy vyřaďte. Kaloko počítá běžná slova, takže krátké stránky a blízké jazyky (dánština, norština, švédština) nehodnotí.'] },
|
|
76
77
|
HC1004: { cs: 'Ceny používají desetinný oddělovač daného jazyka', why: ['12.50 € reads oddly to a German or Czech customer and 12,50 $ to an American; it looks like the shop was not made for them.', 'Zápis 12.50 € působí na německého nebo českého zákazníka divně a 12,50 $ na amerického; vypadá to, že obchod nebyl dělaný pro ně.'], fix: ['Format prices with the locale (Intl.NumberFormat or your framework\'s number helper), not with a fixed pattern.', 'Ceny formátujte podle jazyka (Intl.NumberFormat nebo pomocník vašeho frameworku), ne pevným vzorem.'] },
|
|
78
|
+
HC1005: { cs: 'Každá stránka má v každém jazyce vlastní verzi (hreflang)', why: ['A page without its translation sends visitors of that language to the wrong one, and search shows them whatever is left; several pages pointing to one version usually mean a fallback to a listing or the home page.', 'Stránka bez překladu pošle návštěvníky toho jazyka jinam a vyhledávač jim ukáže, co zbude; když víc stránek míří na jednu verzi, jde obvykle o náhradu výpisem nebo úvodní stránkou.'], fix: ['Translate the page, or add <link rel="alternate" hreflang="…"> to its existing translation; a translation that answers an error needs fixing or a new address in the hreflang. The evidence names the locales and the reason; kaloko export findings lists every page.', 'Stránku přeložte, nebo jí doplňte <link rel="alternate" hreflang="…"> na překlad, který už existuje; překlad, který odpovídá chybou, opravte nebo v hreflang uveďte novou adresu. Jazyky a důvod jsou v důkazu, všechny stránky vypíše kaloko export findings.'] },
|
|
77
79
|
HC1101: { cs: 'robots.txt odpovídá 200 a každý řádek dává smysl', why: ['Crawlers skip lines they do not understand, so a typo such as "Disalow" silently opens or closes part of the site; a server error stops crawling altogether.', 'Roboti přeskočí řádky, kterým nerozumí, takže překlep jako „Disalow“ potichu otevře nebo zavře část webu; chyba serveru procházení úplně zastaví.'], fix: ['Serve /robots.txt as text/plain with User-agent, Allow, Disallow and Sitemap lines only, and fix the lines the evidence names.', 'Servírujte /robots.txt jako text/plain jen s řádky User-agent, Allow, Disallow a Sitemap a opravte řádky z důkazu.'] },
|
|
78
80
|
HC1102: { cs: 'robots.txt neblokuje procházené úvodní stránky', why: ['A Disallow that covers the home page or a language root removes the site from search, often after a staging setting reached production.', 'Disallow, který zakryje úvodní stránku nebo kořen jazykové verze, vyřadí web z vyhledávání; často jde o nastavení ze stagingu, které se dostalo na produkci.'], fix: ['Remove or narrow the rule the evidence names; list paths that must stay open under health.site.paths.', 'Odstraňte nebo zužte pravidlo z důkazu; cesty, které musí zůstat otevřené, uveďte v health.site.paths.'] },
|
|
79
81
|
HC1103: { cs: 'robots.txt neblokuje AI roboty', why: ['GPTBot, ClaudeBot, PerplexityBot and others read robots.txt; blocked, the site does not appear in AI answers and assistants cannot open its pages for users.', 'GPTBot, ClaudeBot, PerplexityBot a další se řídí robots.txt; když jsou zablokovaní, web se v odpovědích AI neobjeví a asistenti jeho stránky uživatelům neotevřou.'], fix: ['If blocking was not decided on purpose, remove the Disallow for those user agents. If it was, set health.site.ai_crawlers: false.', 'Pokud blokování nebylo záměr, odstraňte Disallow pro tyto roboty. Pokud bylo, nastavte health.site.ai_crawlers: false.'] },
|
package/src/lib/packs.mjs
CHANGED
|
@@ -12,7 +12,7 @@ const det = (assert, extra = {}) => ({ type: 'deterministic', assert, ...extra }
|
|
|
12
12
|
export const PACKS = {
|
|
13
13
|
seo: {
|
|
14
14
|
base: 100,
|
|
15
|
-
title: 'SEO basics: title, description, canonical, hreflang, Open Graph, robots, heading, viewport, structured data (JSON-LD); on a stack step also across its pages (duplicates, canonicals, hreflang pairs, sitemap pages)',
|
|
15
|
+
title: 'SEO basics: title, description, canonical, hreflang, Open Graph, robots, heading, viewport, structured data (JSON-LD); on a stack step also across its pages (duplicates, canonicals, hreflang pairs, sitemap pages; with traffic data, high impressions and low CTR)',
|
|
16
16
|
criteria: ({ locales }) => [
|
|
17
17
|
{ id: 'HC101', text: 'Exactly one H1', check: det('count', { selector: 'h1', min: 1, max: 1 }) },
|
|
18
18
|
{ id: 'HC102', text: 'Title between 10 and 70 characters', check: det('title_matches', { pattern: '^.{10,70}$' }) },
|
package/src/lib/scenario.mjs
CHANGED
|
@@ -57,6 +57,9 @@ export function validateScenario (data) {
|
|
|
57
57
|
if (node.kind === 'stack' && !node.items) problems.push(`node "${node.id}": stack steps need items (urls, file, sitemap, crawl or script)`)
|
|
58
58
|
if (node.kind === 'stack' && node.items && ['urls', 'file', 'sitemap', 'robots', 'crawl', 'script'].filter((k) => node.items[k]).length !== 1) problems.push(`node "${node.id}": items needs exactly one source of urls, file, sitemap, robots, crawl or script`)
|
|
59
59
|
if (node.kind !== 'stack' && (node.items || node.fields)) problems.push(`node "${node.id}": items and fields belong to stack steps`)
|
|
60
|
+
// locale variants (#176): the list comes from the source locale, so it must be one the scenario walks
|
|
61
|
+
if (node.kind === 'stack' && node.items?.source_locale && !node.items.variants) problems.push(`node "${node.id}": items.source_locale goes with items.variants: hreflang`)
|
|
62
|
+
if (node.kind === 'stack' && node.items?.variants && node.items.source_locale && Array.isArray(data.locales) && !data.locales.includes(node.items.source_locale)) problems.push(`node "${node.id}": items.source_locale ${node.items.source_locale} is not one of the scenario's locales (${data.locales.join(', ')})`)
|
|
60
63
|
// app screens are reached by a script or a person as often as by a deep link
|
|
61
64
|
// a manual screen may have no path: a person (or an imported walkthrough) shows how to get there
|
|
62
65
|
if (node.kind === 'screen' && !node.path && (!data.platform || data.platform === 'web') && node.execution?.strategy !== 'manual') problems.push(`node "${node.id}": screen steps need a path`)
|
|
@@ -117,6 +120,8 @@ export function scenarioWarnings (data, { projectRequired = null } = {}) {
|
|
|
117
120
|
const out = []
|
|
118
121
|
if (!data || !Array.isArray(data.nodes)) return out
|
|
119
122
|
for (const node of data.nodes) {
|
|
123
|
+
if (node.kind === 'stack' && node.items?.variants) out.push(`node "${node.id}": hosted runs (kaloko schedule add) do not group locale variants yet: there each language reads its own list and missing translations (HC1005) are not reported; kaloko walk and the scheduled GitHub workflow do`)
|
|
124
|
+
if (node.kind === 'stack' && node.items?.variants && (data.locales || []).length < 2) out.push(`node "${node.id}": items.variants: hreflang groups the language versions of a page, but the scenario walks ${(data.locales || []).length ? `one locale (${data.locales[0]})` : 'no locales'}; list the other locales under locales:`)
|
|
120
125
|
if (node.kind === 'stack' && (node.packs || []).includes('errors')) out.push(`node "${node.id}": the errors pack is meant for error pages (404, 500, maintenance); on a stack of normal pages HC601 and HC605 stay not applicable. Remove it, or give the step expect_status when its pages are error pages`)
|
|
121
126
|
}
|
|
122
127
|
if (projectRequired && !data.project) out.push(`no project: ${projectRequired.reason}; add project: <slug>${projectRequired.projects.length ? ` (one of ${projectRequired.projects.join(', ')})` : ''}, or kaloko share may answer HTTP 404`)
|
package/src/lib/seo-core.mjs
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
// service (worker/src/design.js, through manifest-core.mjs) assembles stacks with the same results.
|
|
3
3
|
// - cross-page checks of a stack step (#151): duplicate titles, descriptions and H1 within a language, canonicals
|
|
4
4
|
// that point elsewhere, hreflang that does not answer back, sitemap pages that are not indexable;
|
|
5
|
+
// - with traffic data (#157), pages seen often in search but clicked far less than the site's pages at similar
|
|
6
|
+
// positions (HC127);
|
|
5
7
|
// - locale checks of one capture (#159, the `locale` pack): html lang, the page's own hreflang, the language of the
|
|
6
8
|
// text and the decimal separator of prices against the walked locale.
|
|
9
|
+
import { lowCtr, trafficLine } from './traffic-core.mjs'
|
|
7
10
|
|
|
8
11
|
/** Comparable form of a URL: origin, path without a trailing slash (except the root), sorted query, no hash. */
|
|
9
12
|
export function normUrl (href, base) {
|
|
@@ -44,7 +47,8 @@ export const STACK_CHECKS = [
|
|
|
44
47
|
{ id: 'HC123', text: 'H1 headings are unique within a language', field: 'h1' },
|
|
45
48
|
{ id: 'HC124', text: 'Every page is its own canonical, and no canonical points to an error or noindex page' },
|
|
46
49
|
{ id: 'HC125', text: 'hreflang alternates answer back: each language version links to the others and to itself' },
|
|
47
|
-
{ id: 'HC126', text: 'Pages listed in the sitemap answer 200 without a redirect and are indexable' }
|
|
50
|
+
{ id: 'HC126', text: 'Pages listed in the sitemap answer 200 without a redirect and are indexable' },
|
|
51
|
+
{ id: 'HC127', text: 'Pages seen often in search are not clicked far less than the site\'s other pages at similar positions' }
|
|
48
52
|
]
|
|
49
53
|
/** Most groups and addresses a check keeps (items.json, the node's health entry). */
|
|
50
54
|
export const STACK_CHECK_GROUPS = 20
|
|
@@ -62,7 +66,9 @@ export function stackPages (items) {
|
|
|
62
66
|
if (!c) continue
|
|
63
67
|
const meta = c.meta || {}
|
|
64
68
|
const url = c.url || it.url || null
|
|
65
|
-
|
|
69
|
+
// a locale variant (#176) was opened at its own path, the item keeps the source page's
|
|
70
|
+
const path = c.path || it.path
|
|
71
|
+
const requested = path && /^https?:\/\//.test(path) ? path : url && path ? (() => { try { return new URL(path, url).href } catch { return url } })() : url
|
|
66
72
|
out.push({ item: it.id, locale, url, requested, status: c.http_status ?? it.fields?.http ?? null, title: meta.title ?? it.fields?.title ?? null, description: meta.description ?? null, h1: Array.isArray(meta.h1) ? meta.h1[0] ?? null : meta.h1 ?? it.fields?.h1 ?? null, canonical: meta.canonical || null, robots: meta.robots || null, hreflang: (meta.hreflang || []).map((a) => ({ lang: String(a.lang || a.hreflang || '').toLowerCase(), href: a.href })).filter((a) => a.lang && a.href) })
|
|
67
73
|
}
|
|
68
74
|
}
|
|
@@ -178,19 +184,41 @@ function sitemapPages (def, pages) {
|
|
|
178
184
|
return result(def, { verdict: affected.length ? 'fail' : 'pass', pages: known.length, affected, groups: list, evidence: affected.length ? `${new Set(affected).size} of ${known.length} sitemap pages are not indexable as listed: ${list.map((g) => `${g.value} ×${g.urls.length} (${g.urls.slice(0, 2).join(', ')})`).join('; ')}` : `${known.length} sitemap pages answer 200, without a redirect or noindex` })
|
|
179
185
|
}
|
|
180
186
|
|
|
187
|
+
/**
|
|
188
|
+
* HC127 (#157): pages with traffic data (items[].traffic) whose CTR is below `ratio` × the site's median CTR in their
|
|
189
|
+
* position band (traffic-core.mjs ctrMedians). Only with traffic data; pages it cannot judge are not counted.
|
|
190
|
+
*/
|
|
191
|
+
function lowCtrPages (def, items, traffic) {
|
|
192
|
+
const judged = []; const low = []
|
|
193
|
+
for (const it of items) {
|
|
194
|
+
const r = lowCtr(it.traffic, traffic.medians, traffic.low_ctr)
|
|
195
|
+
if (!r) continue
|
|
196
|
+
judged.push(it)
|
|
197
|
+
if (r.low) low.push({ it, ...r })
|
|
198
|
+
}
|
|
199
|
+
if (!judged.length) return null
|
|
200
|
+
low.sort((a, b) => b.it.traffic.impressions - a.it.traffic.impressions)
|
|
201
|
+
const groups = low.map(({ it, median }) => ({ value: trafficLine(it.traffic, median), urls: [shortUrl(it.url || it.path)] }))
|
|
202
|
+
return result(def, {
|
|
203
|
+
verdict: low.length ? 'fail' : 'pass', pages: judged.length, affected: low.map((x) => x.it.id), groups,
|
|
204
|
+
evidence: low.length ? `${low.length} of ${judged.length} pages with enough impressions are clicked less than half as often as the site's pages at similar positions: ${groups.slice(0, 3).map((g) => `${g.urls[0]}: ${g.value}`).join('; ')}` : `${judged.length} pages with enough impressions, each clicked about as often as the site's pages at similar positions`
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
181
208
|
/**
|
|
182
209
|
* The cross-page checks of a stack step from its assembled items: [{ id, text, level, verdict, pages, affected,
|
|
183
210
|
* evidence, groups: [{ locale?, value, urls, more? }], items: [affected item ids] }]. Checks without data (one page,
|
|
184
|
-
* no canonicals, no hreflang, a source other than a sitemap for HC126) are left out.
|
|
211
|
+
* no canonicals, no hreflang, a source other than a sitemap for HC126, no traffic data for HC127) are left out.
|
|
185
212
|
*/
|
|
186
|
-
export function stackChecks ({ items, source = null }) {
|
|
213
|
+
export function stackChecks ({ items, source = null, traffic = null }) {
|
|
187
214
|
const pages = stackPages(items)
|
|
188
215
|
if (!pages.length) return []
|
|
189
216
|
const def = (id) => STACK_CHECKS.find((c) => c.id === id)
|
|
190
217
|
return [
|
|
191
218
|
duplicates(def('HC121'), pages), duplicates(def('HC122'), pages), duplicates(def('HC123'), pages),
|
|
192
219
|
canonicals(def('HC124'), pages), hreflangPairs(def('HC125'), pages),
|
|
193
|
-
source === 'sitemap' || source === 'robots' ? sitemapPages(def('HC126'), pages) : null
|
|
220
|
+
source === 'sitemap' || source === 'robots' ? sitemapPages(def('HC126'), pages) : null,
|
|
221
|
+
traffic?.medians ? lowCtrPages(def('HC127'), items, traffic) : null
|
|
194
222
|
].filter(Boolean)
|
|
195
223
|
}
|
|
196
224
|
|