kaloko 0.18.0 → 0.19.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 +36 -1
- package/schemas/run.v1.json +47 -1
- package/schemas/scenario.v1.json +81 -14
- package/skills/kaloko/reference/acceptance.md +18 -4
- package/skills/kaloko/reference/evaluation.md +44 -1
- package/skills/kaloko/reference/integrations.md +6 -0
- package/skills/kaloko/reference/mcp.md +2 -0
- package/skills/kaloko/reference/stacks.md +104 -14
- package/src/cli.mjs +157 -33
- package/src/lib/build.mjs +18 -7
- package/src/lib/capture.mjs +31 -9
- package/src/lib/config.mjs +15 -1
- package/src/lib/crawler.mjs +242 -0
- package/src/lib/draft.mjs +2 -0
- package/src/lib/evaluate.mjs +124 -38
- package/src/lib/findings.mjs +73 -0
- package/src/lib/health-core.mjs +28 -5
- package/src/lib/health-index.mjs +187 -0
- package/src/lib/health.mjs +4 -0
- package/src/lib/jev.mjs +34 -14
- package/src/lib/linkcheck.mjs +59 -22
- package/src/lib/local-evaluator.mjs +270 -0
- package/src/lib/manifest-core.mjs +38 -12
- package/src/lib/pack-help.mjs +24 -0
- package/src/lib/packs.mjs +41 -3
- package/src/lib/pii.mjs +2 -0
- package/src/lib/pr.mjs +5 -1
- package/src/lib/probe.mjs +62 -1
- package/src/lib/recheck.mjs +5 -1
- package/src/lib/scenario.mjs +11 -3
- package/src/lib/seo-core.mjs +372 -0
- package/src/lib/seo-init.mjs +272 -0
- package/src/lib/service.mjs +31 -2
- package/src/lib/site.mjs +362 -0
- package/src/lib/stack.mjs +230 -23
- package/src/lib/upload.mjs +3 -2
- package/viewer/index.html +31 -17
package/src/lib/capture.mjs
CHANGED
|
@@ -89,7 +89,10 @@ export function captureOptions (scenario, node) {
|
|
|
89
89
|
maskAuto: masks.some((m) => m.auto),
|
|
90
90
|
annotate: o.annotate === true,
|
|
91
91
|
// stack pages default to the first screen: hundreds of full-page shots would swamp storage
|
|
92
|
-
fullPage: o.full_page != null ? o.full_page !== false : node?.kind !== 'stack'
|
|
92
|
+
fullPage: o.full_page != null ? o.full_page !== false : node?.kind !== 'stack',
|
|
93
|
+
// screenshot: thumb (#150): only the card thumbnail is stored, as the step's image too (SEO crawls of thousands of
|
|
94
|
+
// pages need the page facts, not a sharp picture of each)
|
|
95
|
+
thumbOnly: o.screenshot === 'thumb'
|
|
93
96
|
}
|
|
94
97
|
}
|
|
95
98
|
|
|
@@ -97,12 +100,14 @@ import { criteriaHash } from './evaluate.mjs'
|
|
|
97
100
|
import { consoleEntriesFor } from './console.mjs'
|
|
98
101
|
import { redact, redactDeep } from './secrets.mjs'
|
|
99
102
|
import { readFields } from './stack.mjs'
|
|
100
|
-
import { checkLinks, checkHreflang, requester, walkCache, NETWORK_ASSERTS, isolatedRequest, cacheScope } from './linkcheck.mjs'
|
|
103
|
+
import { checkLinks, checkHreflang, checkLlmsTxt, requester, walkCache, NETWORK_ASSERTS, isolatedRequest, anonymousRequest, cacheScope } from './linkcheck.mjs'
|
|
104
|
+
import { checkNojsParity, botAgent, renderedSnapshot } from './crawler.mjs'
|
|
101
105
|
import { keyboardChecks, needsKeyboard, KEYBOARD_ASSERTS } from './keyboard.mjs'
|
|
102
106
|
import { repeatRegions, resolveIgnore } from './stability.mjs'
|
|
103
107
|
import { applyVariant, engineOf, markEngineGaps, variantFields } from './dimensions.mjs'
|
|
104
108
|
import { detailsFor, detailFacts, detailBox, detailName, DETAIL_PADDING } from './detail.mjs'
|
|
105
109
|
import { liveValueChecks } from './assertions.mjs'
|
|
110
|
+
import { localeChecks } from './seo-core.mjs'
|
|
106
111
|
|
|
107
112
|
/** Probe arguments derived from the node's criteria. */
|
|
108
113
|
export function probeArgs (node, maxChars, design = false) {
|
|
@@ -184,6 +189,8 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
184
189
|
const ignore = await resolveIgnore(page, node, locale, viewport)
|
|
185
190
|
// URL, computed styles and read-only expressions (src/lib/assertions.mjs), before the mask and the keyboard walk
|
|
186
191
|
await liveValueChecks({ page, node, result, url: page.url(), tokens })
|
|
192
|
+
// the locale pack (#159): html lang, the own hreflang, the language of the text and prices against the locale
|
|
193
|
+
Object.assign(result.checks, localeChecks(node.criteria, { locale, meta: result.meta, url: page.url(), state: result.state }))
|
|
187
194
|
// the console is read before the mask: the page's own reaction to masked text never counts as its error
|
|
188
195
|
const url = page.url()
|
|
189
196
|
const collected = consoleFile ? await readJsonl(consoleFile) : consoleEntriesFor(page)
|
|
@@ -252,7 +259,8 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
252
259
|
// the extension follows the bytes (WebKit encodes PNG), so the service accepts the file
|
|
253
260
|
if (enc.imageType === 'image/png') image = paths.step(stepId, locale, viewport, 'png')
|
|
254
261
|
if (enc.thumbType === 'image/png') thumb = paths.step(stepId, locale, viewport, 'thumb.png')
|
|
255
|
-
|
|
262
|
+
if (opts.thumbOnly) image = thumb
|
|
263
|
+
else await writeFile(image, enc.image)
|
|
256
264
|
await writeFile(thumb, enc.thumb)
|
|
257
265
|
} catch (error) {
|
|
258
266
|
// never store an unmasked image when masks were asked for
|
|
@@ -262,7 +270,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
262
270
|
}
|
|
263
271
|
// named details for guides (details:): from the same masked page as the screenshot, with the same mask boxes
|
|
264
272
|
const details = item ? [] : await captureDetails({ page, node, locale, viewport, run, stepId, maskRects, scale: shotScale, engine, dpr })
|
|
265
|
-
// network checks (internal links, hreflang pairs) run once per page, outside the page, with a walk-wide cache
|
|
273
|
+
// network checks (internal links, hreflang pairs, llms.txt, the raw HTML) run once per page, outside the page, with a walk-wide cache
|
|
266
274
|
const net = (node.criteria || []).filter((c) => c.check.type === 'deterministic' && NETWORK_ASSERTS.has(c.check.assert))
|
|
267
275
|
if (net.length) {
|
|
268
276
|
netResults ||= await networkChecks(page, net, url, { run, locale })
|
|
@@ -321,14 +329,14 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
321
329
|
...result,
|
|
322
330
|
...extra,
|
|
323
331
|
http_status: extra.http_status ?? result.http_status ?? null,
|
|
324
|
-
capture: { format: String(image).split('.').pop(), scale: shotScale, ...(dpr !== 1 ? { dpr } : {}), masked: opts.mask.length, annotations, ...(opts.fullPage ? {} : { full_page: false }) },
|
|
332
|
+
capture: { format: String(image).split('.').pop(), scale: shotScale, ...(dpr !== 1 ? { dpr } : {}), masked: opts.mask.length, annotations, ...(opts.fullPage ? {} : { full_page: false }), ...(opts.thumbOnly && image === thumb ? { screenshot: 'thumb' } : {}) },
|
|
325
333
|
// what was hidden and where (#64): the canvas marks these places, so a blank is not read as a bug
|
|
326
334
|
...(mask.active ? { pii: { mode: mask.mode, auto: mask.auto, selectors: mask.selectors.length, kinds: maskedPage?.counts || {}, rects: (maskedPage?.rects || []).slice(0, 200).map(({ x, y, w, h, kind }) => ({ x, y, w, h, kind })) } } : {}),
|
|
327
335
|
...(ignore.length ? { ignore } : {}),
|
|
328
336
|
...(unstable ? { unstable } : {}),
|
|
329
337
|
...(keyboard ? { keyboard } : {}),
|
|
330
338
|
...(details.length ? { details } : {}),
|
|
331
|
-
...(item ? { item: { id: item.id, path: item.path, ...(item.title ? { title: item.title } : {}), ...(item.purpose ? { purpose: item.purpose } : {}) } } : {}),
|
|
339
|
+
...(item ? { item: { id: item.id, path: item.path, ...(item.title ? { title: item.title } : {}), ...(item.purpose ? { purpose: item.purpose } : {}), ...(item.edit_url ? { edit_url: item.edit_url } : {}), ...(item.content_ref ? { content_ref: item.content_ref } : {}), ...(item.last_checked !== undefined ? { last_checked: item.last_checked } : {}) } } : {}),
|
|
332
340
|
...(fields ? { fields: { ...(await page.evaluate(readFields, fields).catch(() => ({}))), ...(fields.some((f) => (f.from || f.id) === 'http') ? Object.fromEntries(fields.filter((f) => (f.from || f.id) === 'http').map((f) => [f.id, extra.http_status ?? result.http_status ?? null])) : {}) } } : {}),
|
|
333
341
|
files: { png: relative(run.dir, image), image: relative(run.dir, image), thumb: thumbWritten ? relative(run.dir, thumb) : null, html: relative(run.dir, html) }
|
|
334
342
|
}
|
|
@@ -433,9 +441,11 @@ export async function captureCrop ({ page, node, locale, selector, title, run, p
|
|
|
433
441
|
}
|
|
434
442
|
|
|
435
443
|
/**
|
|
436
|
-
* links_ok and
|
|
444
|
+
* links_ok, hreflang_pairs, llms_txt and nojs_parity for the current page: { [criterion id]: { ok, evidence } }. The requests go through an
|
|
437
445
|
* isolated request context (linkcheck.mjs), so the walker's cookies, language and session stay as they were.
|
|
438
446
|
* links_ok follows links only on a readonly environment or scenario, or when the check says `follow: true`.
|
|
447
|
+
* nojs_parity with a bot user agent requests without cookies, as a crawler does, from one anonymous request context
|
|
448
|
+
* for the walk (crawler.mjs).
|
|
439
449
|
*/
|
|
440
450
|
export async function networkChecks (page, criteria, url, { run = null, locale = '', factory = null } = {}) {
|
|
441
451
|
const out = {}
|
|
@@ -449,10 +459,11 @@ export async function networkChecks (page, criteria, url, { run = null, locale =
|
|
|
449
459
|
if (!todo.length) return out
|
|
450
460
|
let request
|
|
451
461
|
let get
|
|
462
|
+
const env = run?.meta?.environment?.name || ''
|
|
452
463
|
try {
|
|
453
464
|
request = await isolatedRequest(page.context(), { factory })
|
|
454
465
|
const { cookies = [] } = await page.context().storageState().catch(() => ({}))
|
|
455
|
-
get = requester(request, walkCache, cacheScope({ environment:
|
|
466
|
+
get = requester(request, walkCache, cacheScope({ environment: env, locale, cookies }))
|
|
456
467
|
} catch (error) {
|
|
457
468
|
for (const c of todo) out[c.id] = { ok: null, evidence: `network checks unavailable: ${error.message}` }
|
|
458
469
|
await request?.dispose().catch(() => {})
|
|
@@ -465,10 +476,21 @@ export async function networkChecks (page, criteria, url, { run = null, locale =
|
|
|
465
476
|
canonical: document.querySelector("link[rel='canonical'][href]")?.href || null,
|
|
466
477
|
links: Object.fromEntries(sels.map((sel) => [sel, (() => { try { return [...document.querySelectorAll(sel)].map((a) => a.href).filter(Boolean) } catch { return [] } })()]))
|
|
467
478
|
}), [...new Set(todo.filter((c) => c.check.assert === 'links_ok').map((c) => c.check.selector || 'a[href]'))]).catch(() => null)
|
|
479
|
+
if (doc && todo.some((c) => c.check.assert === 'nojs_parity')) doc.rendered = await page.evaluate(renderedSnapshot).catch(() => null)
|
|
468
480
|
for (const c of todo) {
|
|
469
481
|
if (!doc) { out[c.id] = { ok: null, evidence: 'page could not be read for network checks' }; continue }
|
|
470
482
|
try {
|
|
471
|
-
|
|
483
|
+
if (c.check.assert === 'nojs_parity') {
|
|
484
|
+
if (!doc.rendered) { out[c.id] = { ok: null, evidence: 'the rendered page could not be read for nojs_parity' }; continue }
|
|
485
|
+
const agent = botAgent(c.check.user_agent)
|
|
486
|
+
// a bot comes without cookies; robots.txt once per origin, bot and walk
|
|
487
|
+
const anon = agent ? await anonymousRequest({ factory }) : null
|
|
488
|
+
out[c.id] = await checkNojsParity({ pageUrl: url, rendered: doc.rendered, agent, ratio: c.check.ratio ?? undefined, get: agent ? requester(anon, walkCache, cacheScope({ environment: env })) : get, session: get, robots: agent ? requester(anon, walkCache, 'site-root') : get })
|
|
489
|
+
continue
|
|
490
|
+
}
|
|
491
|
+
out[c.id] = c.check.assert === 'llms_txt'
|
|
492
|
+
? await checkLlmsTxt({ pageUrl: url, get: requester(request, walkCache, 'site-root') }) // once per origin and walk, whatever the locale or session
|
|
493
|
+
: c.check.assert === 'links_ok'
|
|
472
494
|
? await checkLinks({ links: doc.links[c.check.selector || 'a[href]'] || [], pageUrl: url, get, max: c.check.max ?? undefined, exclude: c.check.pattern || null })
|
|
473
495
|
: await checkHreflang({ alternates: doc.alternates, pageUrl: url, canonical: doc.canonical, lang: doc.lang, get, min: c.check.min ?? 2 })
|
|
474
496
|
} catch (error) { out[c.id] = { ok: false, evidence: `check error: ${error.message}` } }
|
package/src/lib/config.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import yaml from 'js-yaml'
|
|
|
5
5
|
import { schemaErrors } from './schema.mjs'
|
|
6
6
|
import { UserError, localized, globToRegExp } from './util.mjs'
|
|
7
7
|
import { noteLegacy, legacyVariables } from './legacy.mjs'
|
|
8
|
+
import { withEvaluatorProblems } from './local-evaluator.mjs'
|
|
8
9
|
|
|
9
10
|
export const CONFIG_FILE = 'kaloko.config.yml'
|
|
10
11
|
/** The file name before the rename to Kaloko; still found, so existing projects keep working. */
|
|
@@ -51,7 +52,7 @@ export async function loadConfig (start = process.cwd(), explicit) {
|
|
|
51
52
|
await loadEnvFiles([join(dirname(file), '.env'), ...(userEnv ? [join(homedir(), '.config', 'kaloko', '.env'), join(homedir(), '.config', 'qawalk', '.env')] : [])])
|
|
52
53
|
aliasLegacyEnv()
|
|
53
54
|
const config = yaml.load(await readFile(file, 'utf8'))
|
|
54
|
-
const problems = schemaErrors('config.v1', config)
|
|
55
|
+
const problems = withEvaluatorProblems(config, schemaErrors('config.v1', config))
|
|
55
56
|
if (problems.length) throw new UserError(`${file} is invalid:\n - ${problems.join('\n - ')}`)
|
|
56
57
|
aliasConfigKeys(config, file)
|
|
57
58
|
// projects started before the rename keep their run folder (it is in their .gitignore)
|
|
@@ -76,6 +77,19 @@ export function aliasConfigKeys (config, file = CONFIG_FILE) {
|
|
|
76
77
|
return config
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Whether the config asks every scenario to name its own `project:`: a product lists `projects:` (several projects work
|
|
82
|
+
* on it, so the product's project is no safe default), or a comment says so ("every scenario must name a project",
|
|
83
|
+
* "Každý scénář musí určit vlastní projekt"). Returns { reason, projects } or null.
|
|
84
|
+
*/
|
|
85
|
+
export function projectRequirement (config, text = '') {
|
|
86
|
+
const projects = [...new Set(Object.values(config?.products || {}).flatMap((p) => (Array.isArray(p?.projects) ? p.projects : [])))]
|
|
87
|
+
const comment = String(text).split(/\r?\n/).map((l) => l.match(/^\s*#(.*)$/)?.[1] || '').find((l) => /(?<!\p{L})(scenari|scénář|flow)/iu.test(l) && /(?<!\p{L})(project|projekt)/iu.test(l) && /(?<!\p{L})(every|each|must|required|každ|mus|povinn)/iu.test(l))
|
|
88
|
+
if (comment) return { reason: `the config says "${comment.trim()}"`, projects }
|
|
89
|
+
if (projects.length) return { reason: `the config lists several projects (products: projects: ${projects.join(', ')})`, projects }
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
79
93
|
export function environment (config, name, vars = process.env) {
|
|
80
94
|
const env = config.environments[name]
|
|
81
95
|
if (!env) throw new UserError(`Unknown environment "${name}". Known: ${Object.keys(config.environments).join(', ')}`)
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// nojs_parity: the page as a crawler that does not run JavaScript gets it. One GET of the page's raw HTML (optionally
|
|
2
|
+
// with a bot user agent, anonymous like a crawler, with the walker's Accept-Language), read with a light parse in Node:
|
|
3
|
+
// title, canonical, meta description, H1, JSON-LD types and the length of the body text. It is compared with the
|
|
4
|
+
// rendered page the walk captured; key content that exists only after JavaScript fails the check. A bot user agent
|
|
5
|
+
// respects robots.txt: a page the bot may not fetch is reported as blocked, not fetched. A bot the site, a WAF or a
|
|
6
|
+
// gate turns away (401/403/429/503, a challenge page, a redirect to another origin) is not applicable: that is not a
|
|
7
|
+
// rendering problem. Requests go through linkcheck.mjs (isolated; the walk-wide cache keeps the parsed snapshot only).
|
|
8
|
+
|
|
9
|
+
/** Bots by name (`user_agent: gptbot`): the robots.txt product token and the user agent string they send. */
|
|
10
|
+
export const BOTS = {
|
|
11
|
+
gptbot: { token: 'GPTBot', ua: 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.2; +https://openai.com/gptbot)' },
|
|
12
|
+
'oai-searchbot': { token: 'OAI-SearchBot', ua: 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot)' },
|
|
13
|
+
claudebot: { token: 'ClaudeBot', ua: 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)' },
|
|
14
|
+
perplexitybot: { token: 'PerplexityBot', ua: 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)' },
|
|
15
|
+
googlebot: { token: 'Googlebot', ua: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' },
|
|
16
|
+
bingbot: { token: 'bingbot', ua: 'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)' }
|
|
17
|
+
}
|
|
18
|
+
/** Body text of the raw HTML below this share of the rendered one fails (`ratio:` on the check). */
|
|
19
|
+
export const NOJS_RATIO = 0.3
|
|
20
|
+
// below this much rendered text the share says little (a login screen, a gallery): only the other signals count
|
|
21
|
+
const MIN_TEXT = 200
|
|
22
|
+
// answers that mean "not you" rather than "this is the page": the site, a WAF or a gate turned the request away
|
|
23
|
+
const BLOCKED = new Set([401, 403, 407, 429, 503])
|
|
24
|
+
const CHALLENGE = /cf-chl-|cf_chl_opt|\/cdn-cgi\/challenge-platform|<title>\s*(?:just a moment|attention required|access denied|checking your browser|ddos-guard)|_incapsula_resource|px-captcha|captcha-delivery\.com|awswaf|sucuri_cloudproxy/i
|
|
25
|
+
|
|
26
|
+
/** A named bot or a user agent string → { label, token, ua }; nothing → null (the walker's own browser). */
|
|
27
|
+
export function botAgent (spec) {
|
|
28
|
+
const s = String(spec || '').trim()
|
|
29
|
+
if (!s) return null
|
|
30
|
+
const known = BOTS[s.toLowerCase()]
|
|
31
|
+
if (known) return { label: known.token, ...known }
|
|
32
|
+
const token = s.match(/compatible;\s*([A-Za-z][\w.-]*?)(?:\/|;|\)|\s|$)/)?.[1] || s.match(/^([A-Za-z][\w.-]*?)(?:\/|\s|$)/)?.[1] || s.slice(0, 40)
|
|
33
|
+
return { label: token, token, ua: s }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// robots.txt paths compare percent-encoded (RFC 9309): raw UTF-8 in a rule matches the encoded path, %xx in any case
|
|
37
|
+
const encodePath = (p) => String(p).replace(/%[0-9a-f]{2}/gi, (m) => m.toUpperCase()).replace(/[^\x21-\x7e]/gu, (c) => encodeURIComponent(c))
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* robots.txt for one product token (RFC 9309): the groups naming the token, else `*`; the longest matching rule wins,
|
|
41
|
+
* Allow on a tie; `*` and a closing `$` in rules. → { allowed, rule } (rule: the deciding line, or null).
|
|
42
|
+
*/
|
|
43
|
+
export function robotsAllows (text, token, path) {
|
|
44
|
+
const groups = []; let cur = null; let agents = false
|
|
45
|
+
for (const raw of String(text || '').split(/\r?\n/)) {
|
|
46
|
+
const m = raw.replace(/#.*/, '').trim().match(/^([a-z-]+)\s*:\s*(.*)$/i)
|
|
47
|
+
if (!m) continue
|
|
48
|
+
const key = m[1].toLowerCase(); const val = m[2].trim()
|
|
49
|
+
if (key === 'user-agent') { if (!agents) groups.push(cur = { agents: [], rules: [] }); cur.agents.push(val.toLowerCase()); agents = true; continue }
|
|
50
|
+
agents = false
|
|
51
|
+
if (cur && (key === 'allow' || key === 'disallow') && val) cur.rules.push({ allow: key === 'allow', path: encodePath(val), line: `${key === 'allow' ? 'Allow' : 'Disallow'}: ${val}` })
|
|
52
|
+
}
|
|
53
|
+
const t = String(token || '').toLowerCase()
|
|
54
|
+
const own = groups.filter((g) => g.agents.includes(t))
|
|
55
|
+
const rules = (own.length ? own : groups.filter((g) => g.agents.includes('*'))).flatMap((g) => g.rules)
|
|
56
|
+
const target = encodePath(path)
|
|
57
|
+
let best = null
|
|
58
|
+
for (const r of rules) {
|
|
59
|
+
const re = new RegExp('^' + r.path.replace(/\$$/, '').replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + (r.path.endsWith('$') ? '$' : ''))
|
|
60
|
+
if (!re.test(target)) continue
|
|
61
|
+
if (!best || r.path.length > best.path.length || (r.path.length === best.path.length && r.allow)) best = r
|
|
62
|
+
}
|
|
63
|
+
return { allowed: !best || best.allow, rule: best?.line ?? null }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** schema.org @type values of JSON-LD texts (graphs, lists, nested values), in order; blocks that do not parse are skipped. */
|
|
67
|
+
export function jsonldTypes (texts = []) {
|
|
68
|
+
const types = []
|
|
69
|
+
const walk = (x, depth) => {
|
|
70
|
+
if (!x || typeof x !== 'object' || depth > 6) return
|
|
71
|
+
if (Array.isArray(x)) { for (const y of x) walk(y, depth + 1); return }
|
|
72
|
+
for (const t of [].concat(x['@type'] || [])) { const n = typeof t === 'string' ? t.replace(/^https?:\/\/schema\.org\//, '') : null; if (n && !types.includes(n)) types.push(n) }
|
|
73
|
+
for (const [k, v] of Object.entries(x)) if (k !== '@context' && v && typeof v === 'object') walk(v, depth + 1)
|
|
74
|
+
}
|
|
75
|
+
for (const t of texts) { try { walk(JSON.parse(t), 0) } catch {} }
|
|
76
|
+
return types
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// HTML named entities common in text (titles with Yoast separators, typography, currency, Latin letters); letters
|
|
80
|
+
// with diacritics (é č Š ů…) are composed from the name below
|
|
81
|
+
const NAMED = {
|
|
82
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ensp: ' ', emsp: ' ', thinsp: ' ', shy: '', zwnj: '', zwj: '', lrm: '', rlm: '',
|
|
83
|
+
ndash: '–', mdash: '—', minus: '−', hellip: '…', middot: '·', bull: '•', sdot: '⋅', lsquo: '‘', rsquo: '’', sbquo: '‚', ldquo: '“', rdquo: '”', bdquo: '„',
|
|
84
|
+
laquo: '«', raquo: '»', lsaquo: '‹', rsaquo: '›', prime: '′', Prime: '″', dagger: '†', Dagger: '‡', permil: '‰', sect: '§', para: '¶', verbar: '|', vert: '|',
|
|
85
|
+
copy: '©', reg: '®', trade: '™', deg: '°', plusmn: '±', times: '×', divide: '÷', frasl: '⁄', micro: 'µ', iexcl: '¡', iquest: '¿', ordf: 'ª', ordm: 'º',
|
|
86
|
+
sup1: '¹', sup2: '²', sup3: '³', frac12: '½', frac14: '¼', frac34: '¾', euro: '€', pound: '£', yen: '¥', cent: '¢', curren: '¤', dollar: '$', percnt: '%',
|
|
87
|
+
larr: '←', rarr: '→', uarr: '↑', darr: '↓', harr: '↔', rArr: '⇒', lArr: '⇐', check: '✓', star: '☆', starf: '★', hearts: '♥', loz: '◊',
|
|
88
|
+
szlig: 'ß', aelig: 'æ', AElig: 'Æ', oslash: 'ø', Oslash: 'Ø', eth: 'ð', ETH: 'Ð', thorn: 'þ', THORN: 'Þ', oelig: 'œ', OElig: 'Œ', dstrok: 'đ', Dstrok: 'Đ',
|
|
89
|
+
lstrok: 'ł', Lstrok: 'Ł', imath: 'ı', excl: '!', num: '#', lpar: '(', rpar: ')', ast: '*', plus: '+', comma: ',', period: '.', sol: '/', colon: ':', semi: ';',
|
|
90
|
+
equals: '=', quest: '?', commat: '@', lsqb: '[', rsqb: ']', bsol: '\\', lowbar: '_', grave: '`', lcub: '{', rcub: '}', tilde: '˜', circ: 'ˆ', NewLine: '\n', Tab: '\t'
|
|
91
|
+
}
|
|
92
|
+
const MARKS = { acute: '́', grave: '̀', circ: '̂', uml: '̈', tilde: '̃', ring: '̊', cedil: '̧', caron: '̌', macr: '̄', ogon: '̨', dot: '̇', breve: '̆', dblac: '̋' }
|
|
93
|
+
// entities a browser also reads without the closing semicolon
|
|
94
|
+
const LEGACY = new Set(['amp', 'lt', 'gt', 'quot', 'nbsp', 'copy', 'reg', 'AMP', 'LT', 'GT', 'QUOT'])
|
|
95
|
+
const named = (name) => {
|
|
96
|
+
if (NAMED[name] != null) return NAMED[name]
|
|
97
|
+
if (/^(AMP|LT|GT|QUOT)$/.test(name)) return NAMED[name.toLowerCase()]
|
|
98
|
+
const m = name.match(/^([a-zA-Z])(acute|grave|circ|uml|tilde|ring|cedil|caron|macr|ogon|dot|breve|dblac)$/)
|
|
99
|
+
const c = m && (m[1] + MARKS[m[2]]).normalize('NFC')
|
|
100
|
+
return c && c.length === 1 ? c : null
|
|
101
|
+
}
|
|
102
|
+
/** Decodes HTML character references: named ones common in text, decimal and hexadecimal. */
|
|
103
|
+
export function decodeEntities (s) {
|
|
104
|
+
return String(s ?? '').replace(/&(#[xX][0-9a-fA-F]+|#\d+|[a-zA-Z][a-zA-Z0-9]*)(;?)/g, (m, e, semi) => {
|
|
105
|
+
if (e[0] === '#') { const n = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : +e.slice(1); try { return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n === 160 ? 32 : n) : m } catch { return m } }
|
|
106
|
+
if (!semi && !LEGACY.has(e)) return m
|
|
107
|
+
return named(e) ?? m
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
/** Text as people read it: no invisible characters, every run of whitespace (no-break spaces too) one space. */
|
|
111
|
+
export const normText = (s) => String(s ?? '').replace(/[-]/g, '').replace(/\s+/g, ' ').trim()
|
|
112
|
+
const textOf = (html) => normText(decodeEntities(String(html).replace(/<[^>]*>/g, ' ')))
|
|
113
|
+
const attrOf = (tag, name) => tag.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'))?.slice(1).find((v) => v != null)
|
|
114
|
+
const SKIP = /<(script|style|noscript|template|svg)\b[\s\S]*?<\/\1\s*>/gi
|
|
115
|
+
// an element cut off by the size limit (or never closed) swallows the rest of the document
|
|
116
|
+
const UNCLOSED = /<(script|style|noscript|template|svg)\b[\s\S]*$/i
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What a crawler without JavaScript reads in raw HTML: { title, canonical, description, h1, lang, types, text,
|
|
120
|
+
* challenge, truncated }. text is the length of the body text without scripts, styles, noscript, templates and SVG
|
|
121
|
+
* (hidden elements count, as they do in the rendered page: streamed parts such as Next.js `<div hidden id="S:0">`).
|
|
122
|
+
*/
|
|
123
|
+
export function rawSnapshot (html, base, truncated = false) {
|
|
124
|
+
const doc = String(html || '').replace(/<!--[\s\S]*?(?:-->|$)/g, '')
|
|
125
|
+
const jsonld = [...doc.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi)].filter((m) => /application\/ld\+json/i.test(attrOf(m[1], 'type') || '')).map((m) => m[2])
|
|
126
|
+
const bare = doc.replace(SKIP, ' ').replace(UNCLOSED, ' ')
|
|
127
|
+
const href = (bare.match(/<base\b[^>]*>/i) || [])[0]
|
|
128
|
+
try { if (href && attrOf(href, 'href')) base = new URL(decodeEntities(attrOf(href, 'href')), base).href } catch {}
|
|
129
|
+
const title = textOf(bare.match(/<title\b[^>]*>([\s\S]*?)<\/title\s*>/i)?.[1] || '') || null
|
|
130
|
+
let canonical = null; let description = null
|
|
131
|
+
for (const tag of bare.match(/<link\b[^>]*>/gi) || []) if (!canonical && /(^|\s)canonical(\s|$)/i.test(attrOf(tag, 'rel') || '') && attrOf(tag, 'href')) { try { canonical = new URL(decodeEntities(attrOf(tag, 'href')), base).href } catch {} }
|
|
132
|
+
for (const tag of bare.match(/<meta\b[^>]*>/gi) || []) if (description == null && /^description$/i.test(attrOf(tag, 'name') || '')) description = textOf(attrOf(tag, 'content') || '') || null
|
|
133
|
+
const h1 = textOf(bare.match(/<h1\b[^>]*>([\s\S]*?)<\/h1\s*>/i)?.[1] || '') || null
|
|
134
|
+
const lang = attrOf(bare.match(/<html\b[^>]*>/i)?.[0] || '', 'lang') || null
|
|
135
|
+
const noHead = bare.replace(/<head\b[\s\S]*?<\/head\s*>/i, ' ')
|
|
136
|
+
const body = noHead.match(/<body\b[^>]*>([\s\S]*)/i)?.[1] ?? noHead.replace(/<title\b[\s\S]*?<\/title\s*>/gi, ' ')
|
|
137
|
+
return { title, canonical, description, h1, lang, types: jsonldTypes(jsonld), text: textOf(body).length, challenge: CHALLENGE.test(doc.slice(0, 30000)), truncated: !!truncated }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The same snapshot of the rendered page, plus the browser's user agent and languages; runs in the page (self-contained). */
|
|
141
|
+
export function renderedSnapshot () {
|
|
142
|
+
const norm = (s) => String(s ?? '').replace(/[-]/g, '').replace(/\s+/g, ' ').trim()
|
|
143
|
+
const body = document.body?.cloneNode(true)
|
|
144
|
+
for (const el of body ? body.querySelectorAll('script, style, noscript, template, svg') : []) el.remove()
|
|
145
|
+
return {
|
|
146
|
+
title: norm(document.title) || null,
|
|
147
|
+
canonical: document.querySelector("link[rel~='canonical'][href]")?.href || null,
|
|
148
|
+
description: norm(document.querySelector("meta[name='description' i]")?.getAttribute('content')) || null,
|
|
149
|
+
h1: norm(document.querySelector('h1')?.textContent) || null,
|
|
150
|
+
lang: document.documentElement.getAttribute('lang') || null,
|
|
151
|
+
jsonld: [...document.querySelectorAll('script[type="application/ld+json"]')].map((s) => s.textContent),
|
|
152
|
+
text: body ? norm(body.textContent).length : 0,
|
|
153
|
+
ua: navigator.userAgent,
|
|
154
|
+
languages: [...(navigator.languages || [])]
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Accept-Language for the browser's languages: cs-CZ,cs;q=0.9,en;q=0.8. */
|
|
159
|
+
export function acceptLanguage (languages = []) {
|
|
160
|
+
return languages.slice(0, 6).map((l, i) => (i ? `${l};q=${(1 - i / 10).toFixed(1)}` : l)).join(',') || null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const cut = (s, n = 60) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
|
|
164
|
+
const sameUrl = (a, b) => { try { const x = new URL(a); const y = new URL(b); x.hash = y.hash = ''; return x.href.replace(/\/$/, '') === y.href.replace(/\/$/, '') } catch { return a === b } }
|
|
165
|
+
const primary = (l) => String(l || '').toLowerCase().split(/[-_]/)[0]
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Raw against rendered: { ok, problems, found }. Fails when the rendered page has a title, canonical, meta description,
|
|
169
|
+
* H1 or JSON-LD type the raw HTML lacks, when title or canonical differ, or when the raw body text is below `ratio`
|
|
170
|
+
* of the rendered one (rendered text of 200 characters or more). A raw body cut at the size limit does not judge JSON-LD.
|
|
171
|
+
*/
|
|
172
|
+
export function compareSnapshots (raw, rendered, { ratio = NOJS_RATIO } = {}) {
|
|
173
|
+
const problems = []; const found = []
|
|
174
|
+
const rt = normText(rendered.title); const wt = normText(raw.title)
|
|
175
|
+
if (rt) {
|
|
176
|
+
const other = raw.lang && rendered.lang && primary(raw.lang) !== primary(rendered.lang) ? ` (the raw HTML is lang="${raw.lang}", the walked page lang="${rendered.lang}")` : ''
|
|
177
|
+
if (!wt) problems.push(`no title (rendered: "${cut(rt)}")`)
|
|
178
|
+
else if (wt !== rt) problems.push(`title "${cut(wt)}", rendered "${cut(rt)}"${other}`)
|
|
179
|
+
else found.push('title')
|
|
180
|
+
}
|
|
181
|
+
if (rendered.canonical) {
|
|
182
|
+
if (!raw.canonical) problems.push(`no canonical (rendered: ${rendered.canonical})`)
|
|
183
|
+
else if (!sameUrl(raw.canonical, rendered.canonical)) problems.push(`canonical ${raw.canonical}, rendered ${rendered.canonical}`)
|
|
184
|
+
else found.push('canonical')
|
|
185
|
+
}
|
|
186
|
+
if (normText(rendered.description)) { if (normText(raw.description)) found.push('meta description'); else problems.push('no meta description') }
|
|
187
|
+
if (normText(rendered.h1)) { if (normText(raw.h1)) found.push('H1'); else problems.push(`no H1 (rendered: "${cut(normText(rendered.h1))}")`) }
|
|
188
|
+
const types = rendered.types || []
|
|
189
|
+
const late = types.filter((t) => !raw.types.includes(t))
|
|
190
|
+
if (late.length && raw.truncated) found.push(`JSON-LD not compared: the raw HTML was cut at 500,000 characters (${late.slice(0, 6).join(', ')} not before the cut)`)
|
|
191
|
+
else if (late.length) problems.push(`JSON-LD ${late.slice(0, 6).join(', ')} only after JavaScript`)
|
|
192
|
+
else if (types.length) found.push(`JSON-LD (${types.slice(0, 6).join(', ')})`)
|
|
193
|
+
const share = rendered.text ? raw.text / rendered.text : 1
|
|
194
|
+
const text = `body text ${raw.text} of ${rendered.text} characters (${Math.round(Math.min(share, 9.99) * 100)} %)`
|
|
195
|
+
if (rendered.text >= MIN_TEXT && share < ratio) problems.push(`${text}, at least ${Math.round(ratio * 100)} % expected`)
|
|
196
|
+
else found.push(text)
|
|
197
|
+
return { ok: !problems.length, problems, found }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const turnedAway = (r, pageUrl) => {
|
|
201
|
+
if (BLOCKED.has(r.status)) return `HTTP ${r.status}`
|
|
202
|
+
try { if (r.url && new URL(r.url).origin !== new URL(pageUrl).origin) return `a redirect to ${new URL(r.url).origin} (a sign-in or a gate)` } catch {}
|
|
203
|
+
if (r.status === 200 && r.parsed?.challenge) return 'a challenge page instead of the page'
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* nojs_parity: { pageUrl, rendered (renderedSnapshot), agent (botAgent or null), get(url, opts) (anonymous for a bot),
|
|
209
|
+
* session(url, opts) (the walker's cookies, to tell a gate from a bot block), robots(url, opts), ratio } → { ok, evidence, na? }.
|
|
210
|
+
* One request for the page (the parsed snapshot is cached for the walk); with a bot, robots.txt once per origin first.
|
|
211
|
+
*/
|
|
212
|
+
export async function checkNojsParity ({ pageUrl, rendered, agent = null, get, session = null, robots = get, ratio = NOJS_RATIO }) {
|
|
213
|
+
const who = agent ? agent.label : 'the browser'
|
|
214
|
+
const ua = agent?.ua || rendered?.ua || null
|
|
215
|
+
const lang = acceptLanguage(rendered?.languages)
|
|
216
|
+
const u = new URL(pageUrl)
|
|
217
|
+
if (agent) {
|
|
218
|
+
const r = await robots(new URL('/robots.txt', pageUrl).href, { html: true, ua })
|
|
219
|
+
if (!r || r.error || r.status >= 500) return { ok: null, na: true, evidence: `robots.txt ${r?.error ? `did not answer (${r.error})` : `answered HTTP ${r?.status}`}; crawlers then fetch nothing, so ${agent.token} was not sent` }
|
|
220
|
+
if (r.status === 200) {
|
|
221
|
+
const verdict = robotsAllows(r.html, agent.token, u.pathname + u.search)
|
|
222
|
+
if (!verdict.allowed) return { ok: null, na: true, evidence: `blocked by robots.txt for ${agent.token} (${verdict.rule}); not fetched` }
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const r = await get(pageUrl, { ua, lang, parse: rawSnapshot })
|
|
226
|
+
if (!r || r.error) return { ok: null, na: true, evidence: `${who} without JavaScript got no answer (${r?.error || 'no response'}); not a rendering problem` }
|
|
227
|
+
const away = turnedAway(r, pageUrl)
|
|
228
|
+
if (away) {
|
|
229
|
+
// who turned it away: the bot's user agent, the missing cookies (a sign-in or a cookie gate), or everyone
|
|
230
|
+
const ok = (x) => x && !x.error && x.status === 200 && !turnedAway(x, pageUrl)
|
|
231
|
+
let why = 'blocked by the site, a WAF or a gate'
|
|
232
|
+
if (agent && ok(await get(pageUrl, { ua: rendered?.ua, lang, parse: rawSnapshot }))) why = `the ${agent.token} user agent is blocked by the site or a WAF (the browser user agent gets the page)`
|
|
233
|
+
else if (agent && session && ok(await session(pageUrl, { ua: rendered?.ua, lang, parse: rawSnapshot }))) why = 'the page needs the walk\'s cookies (a sign-in or a cookie gate), which a crawler does not have'
|
|
234
|
+
return { ok: null, na: true, evidence: `${who} without JavaScript gets ${away}: ${why}; not a rendering problem` }
|
|
235
|
+
}
|
|
236
|
+
if (r.status !== 200) return { ok: false, evidence: `${who} without JavaScript gets HTTP ${r.status}${r.url && !sameUrl(r.url, pageUrl) ? ` (from ${r.url})` : ''}` }
|
|
237
|
+
const { ok, problems, found } = compareSnapshots(r.parsed, { ...rendered, types: jsonldTypes(rendered?.jsonld || []) }, { ratio })
|
|
238
|
+
const moved = r.url && !sameUrl(r.url, pageUrl) ? ` (redirected to ${r.url})` : ''
|
|
239
|
+
return ok
|
|
240
|
+
? { ok: true, evidence: `${who} without JavaScript${moved}: ${found.join(', ')}` }
|
|
241
|
+
: { ok: false, evidence: `${who} without JavaScript${moved}: ${problems.join('; ')}${found.length ? `; present: ${found.join(', ')}` : ''}` }
|
|
242
|
+
}
|
package/src/lib/draft.mjs
CHANGED
|
@@ -47,6 +47,8 @@ export function draftScenario (pages, { id, title } = {}) {
|
|
|
47
47
|
nodes.push({
|
|
48
48
|
id: nf, kind: 'screen', title: 'Page not found', path: '/no-such-page-{random}',
|
|
49
49
|
purpose: 'A visitor who follows a broken link gets a real 404 with a way back to the site.',
|
|
50
|
+
// expect_status marks it as an error page: a soft 404 (HTTP 200) fails HC601 instead of being not applicable
|
|
51
|
+
expect_status: 404,
|
|
50
52
|
packs: ['errors'],
|
|
51
53
|
execution: { strategy: 'playwright', script: 'steps/open.mjs', instructions: 'Open an address that does not exist ({random} makes it unique).' },
|
|
52
54
|
criteria: []
|