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.
@@ -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
- await writeFile(image, enc.image)
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 hreflang_pairs for the current page: { [criterion id]: { ok, evidence } }. The requests go through an
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: run?.meta?.environment?.name || '', locale, cookies }))
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
- out[c.id] = c.check.assert === 'links_ok'
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}` } }
@@ -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 (&eacute; &ccaron; &Scaron; &uring;…) 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: []