kaloko 0.20.0 → 0.20.1
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/skills/kaloko/reference/stacks.md +12 -3
- package/src/cli.mjs +5 -4
- package/src/lib/scenario.mjs +0 -1
- package/src/lib/stack.mjs +96 -59
- package/src/lib/variants-core.mjs +88 -5
package/package.json
CHANGED
|
@@ -80,6 +80,12 @@ Item sources:
|
|
|
80
80
|
`sitemap-de.xml`; `en_US` finds `en-us`, `en` does not), only that
|
|
81
81
|
locale's sitemaps are read; otherwise all of them. Use it when a site
|
|
82
82
|
lists one sitemap per language, so the scenario does not hard-code paths.
|
|
83
|
+
- `kaloko walk` reads robots.txt and sitemaps with the environment's gate
|
|
84
|
+
(headers, HTTP Basic, client certificates) and the walk's cookies, on the
|
|
85
|
+
environment's own origins only, so a staging behind Basic auth or mTLS
|
|
86
|
+
needs no public sitemap. A sitemap on another host (a CDN, an index
|
|
87
|
+
pointing elsewhere) or a redirect to another host gets none of them.
|
|
88
|
+
Redirects are followed by hand (at most 5); 60 s and 60 MB per file.
|
|
83
89
|
- `crawl: { from: /clanky, links: "a.card", next: "a.next", pages: 20 }`
|
|
84
90
|
collects links from a listing and follows its pagination.
|
|
85
91
|
- `script: qa/steps/items.mjs` uses the default export
|
|
@@ -229,9 +235,12 @@ stack, set:
|
|
|
229
235
|
locale).
|
|
230
236
|
- A script that lists pages per locale instead: give each page the same
|
|
231
237
|
`key` in every locale (for example the source path), so they are one item.
|
|
232
|
-
- Hosted runs (`kaloko schedule add`)
|
|
233
|
-
|
|
234
|
-
|
|
238
|
+
- Hosted runs (`kaloko schedule add`) group variants the same way: same
|
|
239
|
+
ids, window, `missing` and HC1005. They read each source page's head for
|
|
240
|
+
its hreflang on the environment's own addresses (with the schedule's
|
|
241
|
+
password or cookies), and each read counts as one hosted page of the
|
|
242
|
+
monthly budget. Budget a stack of N source pages in L locales as
|
|
243
|
+
N reads + N × L pages at most.
|
|
235
244
|
|
|
236
245
|
## Cost and size: choose deliberately
|
|
237
246
|
|
package/src/cli.mjs
CHANGED
|
@@ -33,7 +33,7 @@ import { detectProject, initScenario, skeletonScenario, scenarioHeader, formatDe
|
|
|
33
33
|
import { checkSlug, checkName, setConfigKeys, createProject, renameProject, syncProject, shareProjectNote } from './lib/projects-cli.mjs'
|
|
34
34
|
import { recheckCaptures } from './lib/recheck.mjs'
|
|
35
35
|
import { runFindings, findingsText } from './lib/findings.mjs'
|
|
36
|
-
import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft, rotationState, rotationCoverage, sourceLocaleOf,
|
|
36
|
+
import { resolveItems, fieldsOf, DEFAULT_LIMIT, timeLeft, rotationState, rotationCoverage, sourceLocaleOf, stackReader, readsOutside } from './lib/stack.mjs'
|
|
37
37
|
import { walkSiteChecks, siteChecksOn } from './lib/site.mjs'
|
|
38
38
|
import { DEFAULTS as JEV_DEFAULTS } from './lib/jev.mjs'
|
|
39
39
|
import { expandPacks } from './lib/packs.mjs'
|
|
@@ -818,8 +818,9 @@ async function walkStack ({ node, locale, page, env, run, scenario, scenarioDir,
|
|
|
818
818
|
const sourceLocale = node.items?.variants ? sourceLocaleOf(node, scenario, locale) : locale
|
|
819
819
|
const counter = state && node.items.variants && node.items.rotate === 'run' ? await rotationState(statePath('variants'), run.meta.id) : null
|
|
820
820
|
const round = counter ? counter.run : state?.run || 0
|
|
821
|
-
//
|
|
822
|
-
|
|
821
|
+
// sitemaps, robots.txt and the hreflang of source pages are read with the environment's access (stackReader): its
|
|
822
|
+
// gate and the walk's cookies on its own origins only
|
|
823
|
+
const reader = readsOutside(node.items) ? await stackReader({ page, gate, origins: envOrigins(env, scenario, locale) }) : null
|
|
823
824
|
let resolved
|
|
824
825
|
try { resolved = await resolveItems({ node, base: baseUrl(env, locale), sourceBase: baseUrl(env, sourceLocale), page, scenarioDir, env, locale, scenario, limit: Number(args.limit) || undefined, rotation: { run: round }, checked: state?.checked || null, only: list(args.items), cache, readPage: reader?.read || null }) } finally { await reader?.dispose() }
|
|
825
826
|
const { items, total, truncated, all, rotation, partial, variants, resolve_failed: unresolved, resolve_error: unresolvedWhy } = resolved
|
|
@@ -1610,7 +1611,7 @@ const commands = {
|
|
|
1610
1611
|
const pick = list(args.steps)
|
|
1611
1612
|
// stacks with locale variants read their source list and each page's hreflang once per walk, for every locale
|
|
1612
1613
|
const stackCache = new Map()
|
|
1613
|
-
const stackGate = await gateForRequests(env, { root }) // the gate for requests outside the browser: link checks,
|
|
1614
|
+
const stackGate = await gateForRequests(env, { root }) // the gate for requests outside the browser: link checks, stack sitemaps and hreflang
|
|
1614
1615
|
const locales = list(args.locales) || run.meta.scope.locales
|
|
1615
1616
|
const scope = run.meta.scope
|
|
1616
1617
|
// browser engines and device profiles (batch E): one pass each, capturing only its own keys; a run without
|
package/src/lib/scenario.mjs
CHANGED
|
@@ -120,7 +120,6 @@ export function scenarioWarnings (data, { projectRequired = null } = {}) {
|
|
|
120
120
|
const out = []
|
|
121
121
|
if (!data || !Array.isArray(data.nodes)) return out
|
|
122
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
123
|
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:`)
|
|
125
124
|
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`)
|
|
126
125
|
}
|
package/src/lib/stack.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { pathToFileURL } from 'node:url'
|
|
|
8
8
|
import { gunzipSync } from 'node:zlib'
|
|
9
9
|
import { UserError, localized, readJson, writeJson } from './util.mjs'
|
|
10
10
|
import { STACK_DEFAULT_FIELDS, STACK_SEMANTIC_SAMPLE, stackFieldsOf } from './manifest-core.mjs'
|
|
11
|
-
import { variantItems,
|
|
11
|
+
import { variantItems, sitemapAlternates, pageAlternates, readHeadFrom } from './variants-core.mjs'
|
|
12
12
|
import { DEFAULT_LIMIT, SITEMAP_MAX_BYTES, SITEMAP_MAX_CHILDREN, ROTATE_MODES, itemId, sitemapLocs, robotsSitemaps, sitemapsForLocale, handoffOf, isoWeek, rotateWindow, patternFor as corePatternFor, withLocale, sitemapPages, selectItems } from './stack-core.mjs'
|
|
13
13
|
|
|
14
14
|
/** How long items.resolve may take for one page (ms) before the page is walked without its edit link. */
|
|
@@ -94,7 +94,8 @@ export function rotationCoverage (all, checked) {
|
|
|
94
94
|
* source page; `checked` and `only` use it). `variants` in the result: { mode, source_locale, locale, pages,
|
|
95
95
|
* missing: [{ id, path, url, reason }], shared: [{ url, ids, paths }] } (`source: true` in the source locale). `cache`
|
|
96
96
|
* (a Map the walk keeps for all its locales) reads the source list and each page's alternates once; `readPage` (a
|
|
97
|
-
*
|
|
97
|
+
* stackReader's read: the environment's gate and the walk's cookies) reads the sitemaps, robots.txt and the pages,
|
|
98
|
+
* else a plain pageReader over `fetchImpl`.
|
|
98
99
|
*/
|
|
99
100
|
export async function resolveItems ({ node, base, page, scenarioDir, env, locale, limit, fetchImpl = fetch, rotation = {}, checked = null, scenario = null, only = null, resolveTimeout = RESOLVE_TIMEOUT, sourceBase = null, cache = null, readPage = null }) {
|
|
100
101
|
const memo = cache || new Map()
|
|
@@ -111,7 +112,7 @@ export async function resolveItems ({ node, base, page, scenarioDir, env, locale
|
|
|
111
112
|
const listLocale = mapped ? sourceLocale : locale
|
|
112
113
|
const listBase = mapped ? (sourceBase || base) : base
|
|
113
114
|
const listSrc = mapped ? withLocale(node.items || {}, listLocale) : src
|
|
114
|
-
const read = () => sourceEntries({ src: listSrc, locale: listLocale, base: listBase, page, scenarioDir, env, info: infoFor(listLocale), fetchImpl, withAlternates: variants })
|
|
115
|
+
const read = () => sourceEntries({ src: listSrc, locale: listLocale, base: listBase, page, scenarioDir, env, info: infoFor(listLocale), fetchImpl, readSource: readPage, withAlternates: variants })
|
|
115
116
|
const { raw, alternates } = variants ? await cached(memo, `list|${scenario?.id || ''}|${node.id}|${listLocale}|${listBase}`, read) : await read()
|
|
116
117
|
// 1. and 2.: match, exclude, deduplication, the window (stack-core.mjs, shared with hosted runs); with variants in
|
|
117
118
|
// the source locale, so every locale walks the same window with the same ids
|
|
@@ -146,7 +147,7 @@ const urlKey = (u) => { try { return new URL(u).href } catch { return String(u)
|
|
|
146
147
|
* { raw: [{ path, title?, key?, purpose?, edit_url?, content_ref? }], alternates: Map(url → the sitemap's xhtml:link
|
|
147
148
|
* alternates of that page, read when `withAlternates`) }.
|
|
148
149
|
*/
|
|
149
|
-
async function sourceEntries ({ src, locale, base, page, scenarioDir, env, info, fetchImpl, withAlternates = false }) {
|
|
150
|
+
async function sourceEntries ({ src, locale, base, page, scenarioDir, env, info, fetchImpl, readSource = null, withAlternates = false }) {
|
|
150
151
|
const origin = new URL(base).origin
|
|
151
152
|
const abs = (p) => new URL(p, base.endsWith('/') ? base : base + '/').href.replace(/^(https?:\/\/[^/]+)\/\//, '$1/')
|
|
152
153
|
const toUrl = (p) => (/^https?:\/\//.test(p) ? p : new URL(p, origin).href)
|
|
@@ -154,11 +155,15 @@ async function sourceEntries ({ src, locale, base, page, scenarioDir, env, info,
|
|
|
154
155
|
if (src.urls) raw = src.urls.map((p) => ({ path: p }))
|
|
155
156
|
else if (src.file) raw = await listFile(resolve(scenarioDir, src.file))
|
|
156
157
|
else if (src.sitemap || src.robots) {
|
|
158
|
+
// through the environment's gate: its headers, Basic, client certificates and the walk's cookies on its own
|
|
159
|
+
// origins only; a sitemap on another host, or a redirect there, gets none of them
|
|
160
|
+
const reader = readSource || pageReader({ fetchImpl })
|
|
157
161
|
const get = async (u, what = 'items.sitemap') => {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
+
let r
|
|
163
|
+
try { r = await reader(u, { bytes: true }) } catch (e) { throw new UserError(`${what} ${u}: ${String(e?.message || e).split('\n')[0]}`) }
|
|
164
|
+
if (r.status < 200 || r.status >= 300) throw new UserError(`${what} ${u}: HTTP ${r.status}${r.url && r.url !== u ? ` (after a redirect to ${r.url})` : ''}`)
|
|
165
|
+
if (what !== 'items.sitemap') return r.body.toString('utf8')
|
|
166
|
+
const text = sitemapText(r.body, u)
|
|
162
167
|
// with locale variants the xhtml:link alternates of each <url> are kept for pages that name none themselves
|
|
163
168
|
if (withAlternates) for (const [k, v] of sitemapAlternates(text)) alternates.set(urlKey(k), v)
|
|
164
169
|
return text
|
|
@@ -206,57 +211,76 @@ export const ALTERNATES_MAX = 1024 * 1024
|
|
|
206
211
|
|
|
207
212
|
/**
|
|
208
213
|
* A pageReader over a Playwright request client (APIRequestContext), for gates with client certificates, which fetch
|
|
209
|
-
* cannot present: the client brings the certificates
|
|
210
|
-
* `origins`, and redirects are followed by hand (at most 5).
|
|
214
|
+
* cannot present: the client brings the certificates (Playwright presents each only to its origin); the gate's headers,
|
|
215
|
+
* HTTP Basic and the walk's cookies (`cookiesFor`) go only to `origins`, and redirects are followed by hand (at most 5).
|
|
216
|
+
* `want` as in pageReader (a sitemap: `{ bytes: true }`).
|
|
211
217
|
*/
|
|
212
|
-
export function contextReader (request, { gate = null, origins = [], timeout = ALTERNATES_TIMEOUT, max = ALTERNATES_MAX } = {}) {
|
|
213
|
-
return async (url) => {
|
|
218
|
+
export function contextReader (request, { gate = null, origins = [], cookiesFor = null, timeout = ALTERNATES_TIMEOUT, max = ALTERNATES_MAX } = {}) {
|
|
219
|
+
return async (url, want = {}) => {
|
|
220
|
+
const limit = want.max || (want.bytes ? SITEMAP_MAX_BYTES : max)
|
|
221
|
+
// one time limit for a sitemap with all its redirects, as with fetch; a page head has it per request
|
|
222
|
+
const deadline = want.bytes ? Date.now() + (want.timeout || SITEMAP_TIMEOUT) : null
|
|
214
223
|
let at = url
|
|
215
224
|
for (let hop = 0; hop <= 5; hop++) {
|
|
216
|
-
const
|
|
217
|
-
|
|
225
|
+
const left = deadline ? deadline - Date.now() : (want.timeout || timeout)
|
|
226
|
+
if (left <= 0) throw new Error(`no answer in ${Math.round((want.timeout || SITEMAP_TIMEOUT) / 1000)} s (timeout)`)
|
|
227
|
+
const headers = { ...readHeaders(want), ...gateHeadersFor(gate, at, origins) }
|
|
228
|
+
if (cookiesFor && origins.includes(new URL(at).origin)) { const c = await cookiesFor(at); if (c) headers.Cookie = c }
|
|
229
|
+
const r = await request.get(at, { headers, timeout: Math.max(1, left), maxRedirects: 0 })
|
|
218
230
|
const h = r.headers()
|
|
219
|
-
if (r.status() >= 300 && r.status() < 400 && h.location) { await r.dispose().catch(() => {}); at =
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
231
|
+
if (r.status() >= 300 && r.status() < 400 && h.location) { await r.dispose().catch(() => {}); at = nextHop(h.location, at); continue }
|
|
232
|
+
try {
|
|
233
|
+
if (!want.bytes) return { status: r.status(), url: at, link: h.link || null, html: r.ok() ? (await r.body()).subarray(0, limit).toString('utf8') : '' }
|
|
234
|
+
// the client reads the whole body before it answers: the limit is checked on what came
|
|
235
|
+
const body = r.ok() ? await r.body() : Buffer.alloc(0)
|
|
236
|
+
if (body.length > limit) throw new Error(`larger than ${Math.round(limit / 1048576)} MB`)
|
|
237
|
+
return { status: r.status(), url: at, link: h.link || null, body }
|
|
238
|
+
} finally { await r.dispose().catch(() => {}) }
|
|
223
239
|
}
|
|
224
240
|
throw new Error('more than 5 redirects')
|
|
225
241
|
}
|
|
226
242
|
}
|
|
227
243
|
|
|
228
|
-
/**
|
|
229
|
-
|
|
244
|
+
/**
|
|
245
|
+
* Options of the request client stackReader makes for client certificates: the certificates only. The walk's cookies
|
|
246
|
+
* go as a header on the environment's own origins (contextReader), not into the client's jar, which would send a
|
|
247
|
+
* domain cookie to a sibling host after a redirect.
|
|
248
|
+
*/
|
|
249
|
+
export const variantRequestOptions = (gate) => ({ clientCertificates: gate.clientCertificates })
|
|
250
|
+
|
|
251
|
+
/** The next address of a redirect: http(s) only. */
|
|
252
|
+
const nextHop = (location, from) => {
|
|
253
|
+
const u = new URL(location, from)
|
|
254
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error(`a redirect to ${u.protocol} is not followed`)
|
|
255
|
+
return u.href
|
|
256
|
+
}
|
|
230
257
|
|
|
231
258
|
/**
|
|
232
|
-
* How a stack with locale variants
|
|
233
|
-
* headers and HTTP Basic, and the walk's cookies, only on `origins`. A gate with
|
|
234
|
-
* client of its own (`factory`: Playwright's `request`), made with the certificates
|
|
235
|
-
* the browser context of a shared browser (connected over CDP) has
|
|
259
|
+
* How a stack reads its sitemaps, robots.txt and (with locale variants) its source pages for their hreflang:
|
|
260
|
+
* { read, dispose }. With the gate's headers and HTTP Basic, and the walk's cookies, only on `origins`. A gate with
|
|
261
|
+
* client certificates gets a request client of its own (`factory`: Playwright's `request`), made with the certificates:
|
|
262
|
+
* the browser context of a shared browser (connected over CDP) has none, they live where the browser was started.
|
|
236
263
|
* `dispose` closes it after the reads.
|
|
237
264
|
*/
|
|
238
|
-
export async function
|
|
265
|
+
export async function stackReader ({ page = null, gate = null, origins = [], factory = null, fetchImpl = fetch }) {
|
|
266
|
+
const cookiesFor = page ? async (url) => (await page.context().cookies(url).catch(() => [])).map((c) => `${c.name}=${c.value}`).join('; ') : null
|
|
239
267
|
if (gate?.clientCertificates?.length) {
|
|
240
|
-
const cookies = page ? await page.context().cookies().catch(() => []) : []
|
|
241
268
|
const pw = factory || (await import('playwright')).request
|
|
242
|
-
const request = await pw.newContext(variantRequestOptions(gate
|
|
243
|
-
return { kind: 'certificates', read: contextReader(request, { gate, origins }), dispose: () => request.dispose().catch(() => {}) }
|
|
269
|
+
const request = await pw.newContext(variantRequestOptions(gate))
|
|
270
|
+
return { kind: 'certificates', read: contextReader(request, { gate, origins, cookiesFor }), dispose: () => request.dispose().catch(() => {}) }
|
|
244
271
|
}
|
|
245
|
-
const cookiesFor = page ? async (url) => (await page.context().cookies(url).catch(() => [])).map((c) => `${c.name}=${c.value}`).join('; ') : null
|
|
246
272
|
return { kind: 'fetch', read: pageReader({ fetchImpl, gate, origins, cookiesFor }), dispose: async () => {} }
|
|
247
273
|
}
|
|
274
|
+
/** The name of before, when only locale variants read through the gate. */
|
|
275
|
+
export const variantReader = stackReader
|
|
276
|
+
|
|
277
|
+
/** Whether a stack reads anything outside the browser: a sitemap, robots.txt or the hreflang of its pages. */
|
|
278
|
+
export const readsOutside = (items = {}) => !!(items.sitemap || items.robots || items.variants)
|
|
248
279
|
|
|
249
280
|
/** The head of a page as text: read until </head> (outside comments and scripts) or `max` bytes, then stopped. */
|
|
250
281
|
export async function readHead (r, max = ALTERNATES_MAX) {
|
|
251
282
|
if (!r.body?.getReader) return String(await r.text()).slice(0, max)
|
|
252
|
-
|
|
253
|
-
for (;;) {
|
|
254
|
-
const { done, value } = await reader.read()
|
|
255
|
-
if (done) break
|
|
256
|
-
bytes += value.length; text += dec.decode(value, { stream: true })
|
|
257
|
-
if (bytes >= max || /<\/head\s*>|<body[\s>]/i.test(stripInert(text))) { try { await reader.cancel() } catch {} break }
|
|
258
|
-
}
|
|
259
|
-
return text.slice(0, max)
|
|
283
|
+
return readHeadFrom(r.body.getReader(), max)
|
|
260
284
|
}
|
|
261
285
|
|
|
262
286
|
/**
|
|
@@ -272,21 +296,49 @@ export function gateHeadersFor (gate, url, origins = []) {
|
|
|
272
296
|
return h
|
|
273
297
|
}
|
|
274
298
|
|
|
299
|
+
/** How long reading one sitemap or robots.txt may take (ms); its size is capped by SITEMAP_MAX_BYTES. */
|
|
300
|
+
export const SITEMAP_TIMEOUT = 60000
|
|
301
|
+
|
|
302
|
+
const readHeaders = (want) => want.bytes
|
|
303
|
+
? { 'User-Agent': 'Kaloko', Accept: 'application/xml,text/xml,text/plain;q=0.9,*/*;q=0.8', 'Accept-Encoding': 'gzip' }
|
|
304
|
+
: { 'User-Agent': 'Kaloko', Accept: 'text/html,application/xhtml+xml' }
|
|
305
|
+
|
|
306
|
+
/** The whole body as bytes, at most `max` (else an error, the rest is not read). */
|
|
307
|
+
export async function readBytes (r, max = SITEMAP_MAX_BYTES) {
|
|
308
|
+
const big = () => new Error(`larger than ${Math.round(max / 1048576)} MB`)
|
|
309
|
+
if (!r.body?.getReader) { const b = Buffer.from(await r.arrayBuffer()); if (b.length > max) throw big(); return b }
|
|
310
|
+
const reader = r.body.getReader(); const parts = []; let bytes = 0
|
|
311
|
+
for (;;) {
|
|
312
|
+
const { done, value } = await reader.read()
|
|
313
|
+
if (done) break
|
|
314
|
+
bytes += value.length
|
|
315
|
+
if (bytes > max) { try { await reader.cancel() } catch {} throw big() }
|
|
316
|
+
parts.push(value)
|
|
317
|
+
}
|
|
318
|
+
return Buffer.concat(parts)
|
|
319
|
+
}
|
|
320
|
+
|
|
275
321
|
/**
|
|
276
322
|
* Reads a page for its hreflang: { status, url (after redirects), link (the Link header), html (the head) }. The
|
|
277
323
|
* gate (headers, Basic) and the walk's cookies (`cookiesFor(url)` → Cookie header) go only to `origins`; redirects
|
|
278
|
-
* are followed by hand (at most 5) so they never carry them elsewhere; each request has `timeout` ms.
|
|
324
|
+
* are followed by hand (at most 5) so they never carry them elsewhere; each request has `timeout` ms. `want.bytes`
|
|
325
|
+
* reads a sitemap or robots.txt instead: the whole body as `body` (bytes, at most SITEMAP_MAX_BYTES, all hops within
|
|
326
|
+
* SITEMAP_TIMEOUT ms; `want.max` and `want.timeout` change them).
|
|
279
327
|
*/
|
|
280
328
|
export function pageReader ({ fetchImpl = fetch, gate = null, origins = [], cookiesFor = null, timeout = ALTERNATES_TIMEOUT, max = ALTERNATES_MAX } = {}) {
|
|
281
|
-
return async (url) => {
|
|
329
|
+
return async (url, want = {}) => {
|
|
282
330
|
let at = url
|
|
331
|
+
const whole = want.bytes ? AbortSignal.timeout(want.timeout || SITEMAP_TIMEOUT) : null
|
|
283
332
|
for (let hop = 0; hop <= 5; hop++) {
|
|
284
|
-
const headers = {
|
|
333
|
+
const headers = { ...readHeaders(want), ...gateHeadersFor(gate, at, origins) }
|
|
285
334
|
if (cookiesFor && origins.includes(new URL(at).origin)) { const c = await cookiesFor(at); if (c) headers.Cookie = c }
|
|
286
|
-
const r = await fetchImpl(at, { redirect: 'manual', headers, signal: AbortSignal.timeout(timeout) })
|
|
335
|
+
const r = await fetchImpl(at, { redirect: 'manual', headers, signal: whole || AbortSignal.timeout(timeout) })
|
|
287
336
|
const loc = r.headers?.get?.('location')
|
|
288
|
-
if (r.status >= 300 && r.status < 400 && loc) { try { await r.body?.cancel() } catch {} at =
|
|
289
|
-
|
|
337
|
+
if (r.status >= 300 && r.status < 400 && loc) { try { await r.body?.cancel() } catch {} at = nextHop(loc, at); continue }
|
|
338
|
+
const link = r.headers?.get?.('link') || null
|
|
339
|
+
if (!want.bytes) return { status: r.status, url: at, link, html: r.ok ? await readHead(r, max) : '' }
|
|
340
|
+
if (!r.ok) { try { await r.body?.cancel() } catch {} return { status: r.status, url: at, link, body: Buffer.alloc(0) } }
|
|
341
|
+
return { status: r.status, url: at, link, body: await readBytes(r, want.max || SITEMAP_MAX_BYTES) }
|
|
290
342
|
}
|
|
291
343
|
throw new Error('more than 5 redirects')
|
|
292
344
|
}
|
|
@@ -298,22 +350,7 @@ export function pageReader ({ fetchImpl = fetch, gate = null, origins = [], cook
|
|
|
298
350
|
* a timeout or a 5xx is tried once more; a result with an error is not kept, so another locale asks again.
|
|
299
351
|
*/
|
|
300
352
|
export async function readAlternates (read, url, fallback = null, cache = new Map()) {
|
|
301
|
-
const got = await cached(cache, `alt|${url}`,
|
|
302
|
-
let alternates = []; let error = null; let at = url
|
|
303
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
304
|
-
try {
|
|
305
|
-
const r = await read(url)
|
|
306
|
-
at = r.url || url
|
|
307
|
-
if (r.status < 200 || r.status >= 300) { error = `HTTP ${r.status}`; if (r.status >= 500) continue; break }
|
|
308
|
-
error = null
|
|
309
|
-
alternates = htmlAlternates(r.html, at)
|
|
310
|
-
if (!alternates.length) alternates = headerAlternates(r.link, at)
|
|
311
|
-
break
|
|
312
|
-
} catch (e) { error = String(e?.message || e).split('\n')[0] }
|
|
313
|
-
}
|
|
314
|
-
if (!alternates.length && fallback?.length) return { alternates: fallback, error: null, url: at, from: 'sitemap' }
|
|
315
|
-
return { alternates, error, url: at }
|
|
316
|
-
})
|
|
353
|
+
const got = await cached(cache, `alt|${url}`, () => pageAlternates(read, url, fallback))
|
|
317
354
|
if (got.error) cache.delete(`alt|${url}`)
|
|
318
355
|
return got
|
|
319
356
|
}
|
|
@@ -24,6 +24,53 @@ const unique = (list) => { const seen = new Set(); return list.filter((a) => a &
|
|
|
24
24
|
*/
|
|
25
25
|
export const stripInert = (html) => String(html || '').replace(/<!--[\s\S]*?(?:-->|$)/g, '').replace(/<script\b[\s\S]*?(?:<\/script\s*>|$)/gi, '').replace(/<style\b[\s\S]*?(?:<\/style\s*>|$)/gi, '')
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Where the head of a page ends, found as the text arrives: `feed(chunk)` is true once `</head>` or `<body>` shows up
|
|
29
|
+
* outside comments, scripts and styles (the same rule as stripInert, then the search in htmlAlternates). Each call
|
|
30
|
+
* scans only the new text and a few characters before it, so a long head read in small chunks stays linear.
|
|
31
|
+
*/
|
|
32
|
+
export function headEnd () {
|
|
33
|
+
let text = ''; let pos = 0; let mode = null
|
|
34
|
+
const OPEN = /<!--|<script\b|<style\b|<\/head\s*>|<body[\s>]/gi
|
|
35
|
+
const CLOSE = { comment: /-->/g, script: /<\/script\s*>/gi, style: /<\/style\s*>/gi }
|
|
36
|
+
return (chunk) => {
|
|
37
|
+
text += chunk
|
|
38
|
+
for (;;) {
|
|
39
|
+
const re = mode ? CLOSE[mode] : OPEN
|
|
40
|
+
re.lastIndex = pos
|
|
41
|
+
const m = re.exec(text)
|
|
42
|
+
// nothing yet: keep the last characters, a token may be split between two chunks
|
|
43
|
+
if (!m) { pos = Math.max(pos, text.length - 16); return false }
|
|
44
|
+
// `<script` at the very end may still turn into `<scripts`: wait for the next character
|
|
45
|
+
if (!mode && m.index + m[0].length >= text.length && /^<(script|style)/i.test(m[0])) { pos = m.index; return false }
|
|
46
|
+
pos = m.index + m[0].length
|
|
47
|
+
if (mode) { mode = null; continue }
|
|
48
|
+
const t = m[0].toLowerCase()
|
|
49
|
+
if (t === '<!--') mode = 'comment'
|
|
50
|
+
else if (t.startsWith('<script')) mode = 'script'
|
|
51
|
+
else if (t.startsWith('<style')) mode = 'style'
|
|
52
|
+
else return true
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The head of a page from a stream reader (`body.getReader()`): read until the head ends (headEnd) or `max` bytes,
|
|
59
|
+
* then the stream is cancelled. Shared by the CLI (stack.mjs readHead) and hosted runs (hosted-stacks.js).
|
|
60
|
+
*/
|
|
61
|
+
export async function readHeadFrom (reader, max) {
|
|
62
|
+
const dec = new TextDecoder(); const ended = headEnd(); let text = ''; let bytes = 0
|
|
63
|
+
for (;;) {
|
|
64
|
+
const { done, value } = await reader.read()
|
|
65
|
+
if (done) break
|
|
66
|
+
bytes += value.byteLength
|
|
67
|
+
const chunk = dec.decode(value, { stream: true })
|
|
68
|
+
text += chunk
|
|
69
|
+
if (bytes >= max || ended(chunk)) { try { await reader.cancel() } catch {} break }
|
|
70
|
+
}
|
|
71
|
+
return text.slice(0, max)
|
|
72
|
+
}
|
|
73
|
+
|
|
27
74
|
/** hreflang alternates in the head of an HTML page: [{ lang (lowercase, en-us), href (absolute) }]. */
|
|
28
75
|
export function htmlAlternates (html, url) {
|
|
29
76
|
const text = stripInert(html)
|
|
@@ -49,18 +96,30 @@ export function headerAlternates (link, url) {
|
|
|
49
96
|
return unique(out)
|
|
50
97
|
}
|
|
51
98
|
|
|
52
|
-
/**
|
|
53
|
-
|
|
99
|
+
/**
|
|
100
|
+
* The `<url>` entries of a sitemap that name alternates, unparsed: Map(loc → the entry's markup). The service keeps
|
|
101
|
+
* these for one sitemap at a time and parses only the pages it takes (urlAlternates).
|
|
102
|
+
*/
|
|
103
|
+
export function sitemapUrlEntries (xml) {
|
|
54
104
|
const out = new Map()
|
|
55
105
|
for (const m of String(xml || '').matchAll(/<url(?:\s[^>]*)?>([\s\S]*?)<\/url>/gi)) {
|
|
106
|
+
if (!/<(?:xhtml:)?link\b/i.test(m[1])) continue
|
|
56
107
|
const loc = m[1].match(LOC)?.[1]
|
|
57
|
-
if (
|
|
58
|
-
const alts = unique([...m[1].matchAll(/<(?:xhtml:)?link\b[^>]*>/gi)].map((l) => { const a = attrsOf(l[0]); return /(^|\s)alternate(\s|$)/i.test(a.rel || '') ? alternate(a.hreflang, a.href) : null }))
|
|
59
|
-
if (alts.length) out.set(decode(loc), alts)
|
|
108
|
+
if (loc) out.set(decode(loc), m[1])
|
|
60
109
|
}
|
|
61
110
|
return out
|
|
62
111
|
}
|
|
63
112
|
|
|
113
|
+
/** The `xhtml:link` alternates of one `<url>` entry of a sitemap: [{ lang, href }]. */
|
|
114
|
+
export const urlAlternates = (entry) => unique([...String(entry || '').matchAll(/<(?:xhtml:)?link\b[^>]*>/gi)].map((l) => { const a = attrsOf(l[0]); return /(^|\s)alternate(\s|$)/i.test(a.rel || '') ? alternate(a.hreflang, a.href) : null }))
|
|
115
|
+
|
|
116
|
+
/** The `<url>` entries of a sitemap with their `xhtml:link` alternates: Map(loc → [{ lang, href }]). */
|
|
117
|
+
export function sitemapAlternates (xml) {
|
|
118
|
+
const out = new Map()
|
|
119
|
+
for (const [loc, entry] of sitemapUrlEntries(xml)) { const alts = urlAlternates(entry); if (alts.length) out.set(loc, alts) }
|
|
120
|
+
return out
|
|
121
|
+
}
|
|
122
|
+
|
|
64
123
|
/**
|
|
65
124
|
* The alternate that stands for `locale`: the same tag (en-us for en_US), else the bare language (en) for a locale
|
|
66
125
|
* with a region, else the first regional tag of the language (sk-sk) for a locale without one. A locale with a script
|
|
@@ -84,6 +143,30 @@ export function alternateFor (alts, locale) {
|
|
|
84
143
|
return parts.find(([, q]) => q?.lang === p.lang && !q.script)?.[0] || parts.find(([, q]) => q?.lang === p.lang)?.[0] || null
|
|
85
144
|
}
|
|
86
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The hreflang alternates of one page as `read` (url → { status, url (after redirects), link, html }) gives it: from
|
|
148
|
+
* its HTML head, else its Link header, else `fallback` (the sitemap's xhtml:link entries for it):
|
|
149
|
+
* { alternates: [{ lang, href }], error, url, from? }. A network error, a timeout or a 5xx is tried once more, except
|
|
150
|
+
* an error `noRetry(e)` names (a refused address); an error `rethrow(e)` names (the service's spent round) is not
|
|
151
|
+
* caught. Shared by the CLI (stack.mjs readAlternates) and hosted runs (worker/src/hosted-stacks.js).
|
|
152
|
+
*/
|
|
153
|
+
export async function pageAlternates (read, url, fallback = null, { rethrow = () => false, noRetry = () => false } = {}) {
|
|
154
|
+
let alternates = []; let error = null; let at = url
|
|
155
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
156
|
+
try {
|
|
157
|
+
const r = await read(url)
|
|
158
|
+
at = r.url || url
|
|
159
|
+
if (r.status < 200 || r.status >= 300) { error = `HTTP ${r.status}`; if (r.status >= 500) continue; break }
|
|
160
|
+
error = null
|
|
161
|
+
alternates = htmlAlternates(r.html, at)
|
|
162
|
+
if (!alternates.length) alternates = headerAlternates(r.link, at)
|
|
163
|
+
break
|
|
164
|
+
} catch (e) { if (rethrow(e)) throw e; error = String(e?.message || e).split('\n')[0]; if (noRetry(e)) break }
|
|
165
|
+
}
|
|
166
|
+
if (!alternates.length && fallback?.length) return { alternates: fallback, error: null, url: at, from: 'sitemap' }
|
|
167
|
+
return { alternates, error, url: at }
|
|
168
|
+
}
|
|
169
|
+
|
|
87
170
|
const hostOf = (u) => { try { return new URL(u).host } catch { return null } }
|
|
88
171
|
const sameLanguage = (a, b) => { const x = localeParts(a); const y = localeParts(b); return !!x && !!y && x.lang === y.lang }
|
|
89
172
|
|