meodp 0.1.0 → 0.2.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.
Files changed (48) hide show
  1. package/README.md +28 -1
  2. package/dist/check/cli.cjs +130 -39
  3. package/dist/check/cli.mjs +130 -39
  4. package/dist/check/index.cjs +12 -8
  5. package/dist/check/index.d.cts +5 -68
  6. package/dist/check/index.d.mts +5 -68
  7. package/dist/check/index.d.ts +5 -68
  8. package/dist/check/index.mjs +4 -2
  9. package/dist/cli/main.cjs +59 -6
  10. package/dist/cli/main.mjs +59 -6
  11. package/dist/config/index.cjs +7 -0
  12. package/dist/config/index.d.cts +47 -0
  13. package/dist/config/index.d.mts +47 -0
  14. package/dist/config/index.d.ts +47 -0
  15. package/dist/config/index.mjs +5 -0
  16. package/dist/notify/cli.cjs +107 -0
  17. package/dist/notify/cli.d.cts +3 -0
  18. package/dist/notify/cli.d.mts +3 -0
  19. package/dist/notify/cli.d.ts +3 -0
  20. package/dist/notify/cli.mjs +101 -0
  21. package/dist/notify/email.cjs +38 -0
  22. package/dist/notify/email.d.cts +29 -0
  23. package/dist/notify/email.d.mts +29 -0
  24. package/dist/notify/email.d.ts +29 -0
  25. package/dist/notify/email.mjs +35 -0
  26. package/dist/notify/feishu.cjs +162 -0
  27. package/dist/notify/feishu.d.cts +74 -0
  28. package/dist/notify/feishu.d.mts +74 -0
  29. package/dist/notify/feishu.d.ts +74 -0
  30. package/dist/notify/feishu.mjs +157 -0
  31. package/dist/notify/index.cjs +64 -0
  32. package/dist/notify/index.d.cts +27 -0
  33. package/dist/notify/index.d.mts +27 -0
  34. package/dist/notify/index.d.ts +27 -0
  35. package/dist/notify/index.mjs +62 -0
  36. package/dist/shared/{meodp.a0bcc3e7.cjs → meodp.2b00b0ae.cjs} +13 -365
  37. package/dist/shared/meodp.4f1da070.d.cts +41 -0
  38. package/dist/shared/meodp.63483e20.cjs +30 -0
  39. package/dist/shared/meodp.80ff918d.mjs +465 -0
  40. package/dist/shared/meodp.8369c4c1.d.ts +41 -0
  41. package/dist/shared/meodp.93e06e00.d.cts +64 -0
  42. package/dist/shared/meodp.93e06e00.d.mts +64 -0
  43. package/dist/shared/meodp.93e06e00.d.ts +64 -0
  44. package/dist/shared/{meodp.d0916bc5.mjs → meodp.9c706389.mjs} +1 -350
  45. package/dist/shared/meodp.a704f8db.d.mts +41 -0
  46. package/dist/shared/meodp.c4c70565.cjs +477 -0
  47. package/dist/shared/meodp.f96d0510.mjs +24 -0
  48. package/package.json +50 -3
@@ -0,0 +1,465 @@
1
+ import { setTimeout } from 'node:timers/promises';
2
+ import { LinkChecker } from 'linkinator';
3
+ import { hostname } from 'node:os';
4
+ import { p as parseReport, c as createReportHtml, f as formatReport } from './meodp.9c706389.mjs';
5
+ import { mkdir, writeFile } from 'node:fs/promises';
6
+ import { dirname, resolve, join } from 'node:path';
7
+ import process from 'node:process';
8
+ import { Buffer } from 'node:buffer';
9
+ import { promisify } from 'node:util';
10
+ import { gunzip } from 'node:zlib';
11
+ import { XMLValidator, XMLParser } from 'fast-xml-parser';
12
+
13
+ function normalizeUrl(value) {
14
+ const url = new URL(value);
15
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
16
+ throw new Error("Expected an HTTP(S) URL without embedded credentials");
17
+ url.hash = "";
18
+ return url.href;
19
+ }
20
+ function normalizeTargets(input) {
21
+ if (!Array.isArray(input))
22
+ throw new TypeError("Input must be an array of URLs or objects with a url field");
23
+ const seen = /* @__PURE__ */ new Set();
24
+ return input.map((item, index) => {
25
+ const value = typeof item === "string" ? { url: item } : item;
26
+ if (!value || typeof value !== "object" || !("url" in value) || typeof value.url !== "string")
27
+ throw new TypeError(`Invalid link at index ${index}: expected a url string`);
28
+ let url;
29
+ try {
30
+ url = normalizeUrl(value.url.trim());
31
+ } catch {
32
+ throw new TypeError(`Invalid HTTP(S) URL at index ${index}`);
33
+ }
34
+ if ("name" in value && value.name !== void 0 && typeof value.name !== "string")
35
+ throw new TypeError(`Invalid name at index ${index}: expected a string`);
36
+ return { url, ..."name" in value && typeof value.name === "string" ? { name: value.name } : {} };
37
+ }).filter((item) => {
38
+ if (seen.has(item.url))
39
+ return false;
40
+ seen.add(item.url);
41
+ return true;
42
+ });
43
+ }
44
+
45
+ function integerOption(value, fallback, name, minimum, maximum) {
46
+ const result = value ?? fallback;
47
+ if (!Number.isInteger(result) || result < minimum || result > maximum)
48
+ throw new RangeError(`${name} must be an integer between ${minimum} and ${maximum}`);
49
+ return result;
50
+ }
51
+ function normalizeCheckOptions(options) {
52
+ const concurrency = integerOption(options.concurrency, 5, "concurrency", 1, 100);
53
+ const timeoutMs = integerOption(options.timeoutMs, 1e4, "timeoutMs", 1, 3e5);
54
+ const retries = integerOption(options.retries, 1, "retries", 0, 5);
55
+ const maxRedirects = integerOption(options.maxRedirects, 5, "maxRedirects", 0, 20);
56
+ const observer = options.observer ?? hostname();
57
+ if (!observer.trim())
58
+ throw new TypeError("observer must not be empty");
59
+ if (options.previousReport && options.previousReport.observer !== observer)
60
+ throw new Error("Previous report belongs to another observer; use a separate history file");
61
+ return { concurrency, timeoutMs, retries, maxRedirects, observer };
62
+ }
63
+
64
+ function failureDetail(result) {
65
+ const error = result.failureDetails?.find((item) => item instanceof Error);
66
+ const parts = [];
67
+ let current = error;
68
+ for (let depth = 0; current && typeof current === "object" && depth < 5; depth++) {
69
+ for (const key of ["name", "code", "message"]) {
70
+ const value = Reflect.get(current, key);
71
+ if (typeof value === "string")
72
+ parts.push(value);
73
+ }
74
+ current = "cause" in current ? current.cause : void 0;
75
+ }
76
+ const detail = parts.join(": ") || `HTTP ${result.status ?? 0}`;
77
+ if (/ENOTFOUND|EAI_AGAIN|EAI_FAIL/.test(detail))
78
+ return { reason: "dns", detail };
79
+ if (/CERT|TLS|SSL/i.test(detail))
80
+ return { reason: "tls", detail };
81
+ if (/timeout|timed out|ETIMEDOUT|UND_ERR_.*TIMEOUT/i.test(detail))
82
+ return { reason: "timeout", detail };
83
+ return { reason: result.status ? "http" : "network", detail };
84
+ }
85
+ async function probe(url, timeoutMs, maxRedirects) {
86
+ const redirects = [];
87
+ const visited = /* @__PURE__ */ new Set();
88
+ let current = url;
89
+ for (; ; ) {
90
+ visited.add(current);
91
+ const checker = new LinkChecker();
92
+ const { links } = await checker.check({
93
+ path: current,
94
+ recurse: false,
95
+ concurrency: 1,
96
+ timeout: timeoutMs,
97
+ retry: false,
98
+ retryErrors: false,
99
+ redirects: "error",
100
+ linksToSkip: async (candidate) => candidate !== current
101
+ });
102
+ const result = links.find((item) => item.url === current && !item.parent);
103
+ if (!result)
104
+ throw new Error("The HTTP checker returned no result for the requested URL");
105
+ const httpStatus = result.status || void 0;
106
+ const base = { finalUrl: current, httpStatus, redirects };
107
+ if (httpStatus && [301, 302, 303, 307, 308].includes(httpStatus)) {
108
+ const response = result.failureDetails?.find((item) => "headers" in item);
109
+ const location = response && "headers" in response ? response.headers.location : void 0;
110
+ let next;
111
+ try {
112
+ if (!location)
113
+ throw new Error("Missing redirect location");
114
+ next = normalizeUrl(new URL(location, current).href);
115
+ } catch {
116
+ return { ...base, status: "unavailable", reason: "redirect", detail: "Missing or invalid HTTP(S) redirect destination" };
117
+ }
118
+ redirects.push({ url: current, status: httpStatus, location: next });
119
+ if (visited.has(next) || redirects.length > maxRedirects) {
120
+ return {
121
+ ...base,
122
+ status: "unavailable",
123
+ reason: "redirect",
124
+ detail: visited.has(next) ? "Redirect loop" : "Redirect limit exceeded"
125
+ };
126
+ }
127
+ current = next;
128
+ continue;
129
+ }
130
+ if (httpStatus && httpStatus >= 200 && httpStatus < 300)
131
+ return { ...base, status: "reachable" };
132
+ if (httpStatus && [401, 403, 429, 451, 999].includes(httpStatus))
133
+ return { ...base, status: "restricted", reason: "http", detail: `HTTP ${httpStatus}: access requires review` };
134
+ return { ...base, status: "unavailable", ...failureDetail(result) };
135
+ }
136
+ }
137
+ async function checkLinks(input, options = {}) {
138
+ const targets = normalizeTargets(input);
139
+ const { concurrency, timeoutMs, retries, maxRedirects, observer } = normalizeCheckOptions(options);
140
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
141
+ const previous = new Map(options.previousReport?.results.map((item) => [item.url, item]));
142
+ const results = [];
143
+ let cursor = 0;
144
+ await Promise.all(Array.from({ length: Math.min(concurrency, targets.length) }, async () => {
145
+ while (cursor < targets.length) {
146
+ const index = cursor++;
147
+ const target = targets[index];
148
+ const start = Date.now();
149
+ let attempts = 0;
150
+ let observation;
151
+ do {
152
+ if (attempts > 0)
153
+ await setTimeout(500 * 2 ** (attempts - 1));
154
+ attempts++;
155
+ observation = await probe(target.url, timeoutMs, maxRedirects);
156
+ } while (attempts <= retries && observation.status === "unavailable" && observation.reason !== "redirect" && (!observation.httpStatus || observation.httpStatus >= 500));
157
+ const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
158
+ const old = previous.get(target.url);
159
+ const failed = observation.status === "unavailable";
160
+ const result = {
161
+ ...target,
162
+ ...observation,
163
+ checkedAt,
164
+ durationMs: Date.now() - start,
165
+ attempts,
166
+ consecutiveFailures: failed ? (old?.status === "unavailable" ? old.consecutiveFailures : 0) + 1 : 0,
167
+ firstFailureAt: failed ? old?.status === "unavailable" ? old.firstFailureAt ?? checkedAt : checkedAt : void 0,
168
+ lastSuccessAt: observation.status === "reachable" ? checkedAt : old?.lastSuccessAt,
169
+ changed: Boolean(old && (old.status !== observation.status || old.finalUrl !== observation.finalUrl)),
170
+ recovered: observation.status === "reachable" && old?.status === "unavailable"
171
+ };
172
+ results[index] = result;
173
+ await options.onResult?.(result);
174
+ }
175
+ }));
176
+ const summary = { total: results.length, reachable: 0, restricted: 0, unavailable: 0, redirected: 0, recovered: 0 };
177
+ for (const result of results) {
178
+ summary[result.status]++;
179
+ summary.redirected += Number(result.redirects.length > 0);
180
+ summary.recovered += Number(result.recovered);
181
+ }
182
+ return { schemaVersion: 1, observer, startedAt, completedAt: (/* @__PURE__ */ new Date()).toISOString(), summary, results };
183
+ }
184
+
185
+ function planReporters(config, options = {}) {
186
+ const entries = typeof config === "string" ? [config] : config;
187
+ if (!Array.isArray(entries))
188
+ throw new TypeError("reporter must be a name or an array of names / [name, options] tuples.");
189
+ const cwd = options.cwd ?? process.cwd();
190
+ const output = resolve(cwd, options.outputDir ?? "reports/meodp");
191
+ const path = (value) => {
192
+ if (typeof value !== "string" || !value.trim() || value.includes("\0"))
193
+ throw new TypeError("Reporter output paths must be nonempty strings.");
194
+ return resolve(cwd, value);
195
+ };
196
+ const planned = entries.map((entry) => {
197
+ if (typeof entry !== "string" && (!Array.isArray(entry) || entry.length < 1 || entry.length > 2))
198
+ throw new TypeError("Each reporter must be a name or [name, options] tuple.");
199
+ const [name, settings = {}] = typeof entry === "string" ? [entry] : entry;
200
+ if (!["json", "markdown", "html"].includes(name))
201
+ throw new TypeError(`Unknown reporter ${JSON.stringify(name)}; use json, markdown, or html.`);
202
+ if (!settings || typeof settings !== "object" || Array.isArray(settings))
203
+ throw new TypeError("Reporter options must be an object.");
204
+ const allowed = name === "html" ? ["outputFile", "outputFolder", "dataUrl"] : ["outputFile"];
205
+ for (const key of Object.keys(settings)) {
206
+ if (!allowed.includes(key))
207
+ throw new TypeError(`Unknown ${name} reporter option: ${key}`);
208
+ }
209
+ const file = "outputFile" in settings && settings.outputFile !== void 0 ? path(settings.outputFile) : void 0;
210
+ if (name !== "html")
211
+ return { reporter: name, file: file ?? join(output, name === "json" ? "report.json" : "report.md") };
212
+ const folder = "outputFolder" in settings && settings.outputFolder !== void 0 ? path(settings.outputFolder) : void 0;
213
+ if (file && folder)
214
+ throw new TypeError("HTML reporter accepts either outputFile or outputFolder, not both.");
215
+ const dataUrl = ("dataUrl" in settings ? settings.dataUrl : void 0) ?? options.dataUrl;
216
+ if (dataUrl !== void 0 && typeof dataUrl !== "string")
217
+ throw new TypeError("HTML reporter dataUrl must be a string.");
218
+ if (dataUrl !== void 0)
219
+ createReportHtml(void 0, { dataUrl });
220
+ return { reporter: name, file: file ?? join(folder ?? output, "index.html"), json: file ? void 0 : join(folder ?? output, "report.json"), dataUrl };
221
+ });
222
+ const outputs = /* @__PURE__ */ new Map();
223
+ for (const item of planned) {
224
+ for (const [file, kind] of [[item.file, item.reporter], [item.json, "json"]]) {
225
+ if (!file)
226
+ continue;
227
+ const existing = outputs.get(file);
228
+ if (existing && (existing !== "json" || kind !== "json"))
229
+ throw new TypeError(`Reporter outputs collide at ${file}`);
230
+ outputs.set(file, kind);
231
+ }
232
+ }
233
+ for (const file of outputs.keys()) {
234
+ let parent = dirname(file);
235
+ while (parent !== dirname(parent)) {
236
+ if (outputs.has(parent))
237
+ throw new TypeError(`Reporter output is also used as a directory: ${parent}`);
238
+ parent = dirname(parent);
239
+ }
240
+ }
241
+ return planned;
242
+ }
243
+ async function writeReporters(report, reporter, options = {}) {
244
+ const plan = planReporters(reporter, options);
245
+ if (report)
246
+ report = parseReport(report);
247
+ if (!report && plan.some((item) => item.reporter !== "html"))
248
+ throw new TypeError("JSON and Markdown reporters require an input report.");
249
+ const writes = /* @__PURE__ */ new Map();
250
+ const outputs = plan.map((item) => {
251
+ const files = [item.file];
252
+ if (item.reporter === "html") {
253
+ writes.set(item.file, createReportHtml(report, { dataUrl: item.dataUrl ?? (report && item.json ? "./report.json" : void 0) }));
254
+ if (report && item.json) {
255
+ writes.set(item.json, formatReport(report, "json"));
256
+ files.push(item.json);
257
+ }
258
+ } else {
259
+ writes.set(item.file, formatReport(report, item.reporter));
260
+ }
261
+ return { reporter: item.reporter, files };
262
+ });
263
+ for (const [file, content] of writes) {
264
+ await mkdir(dirname(file), { recursive: true });
265
+ await writeFile(file, content);
266
+ }
267
+ return outputs;
268
+ }
269
+
270
+ const unzip = promisify(gunzip);
271
+ class DocumentError extends Error {
272
+ constructor(message, retryable = false) {
273
+ super(message);
274
+ this.retryable = retryable;
275
+ }
276
+ }
277
+ function discoveryOptions(options) {
278
+ const normalized = normalizeCheckOptions(options);
279
+ if (options.discover !== void 0 && typeof options.discover !== "boolean")
280
+ throw new TypeError("discover must be a boolean");
281
+ return {
282
+ ...normalized,
283
+ maxUrls: integerOption(options.maxUrls, 1e4, "maxUrls", 1, 5e4),
284
+ maxSitemaps: integerOption(options.maxSitemaps, 100, "maxSitemaps", 1, 1e4),
285
+ maxSitemapBytes: integerOption(options.maxSitemapBytes, 10 * 1024 * 1024, "maxSitemapBytes", 1, 100 * 1024 * 1024)
286
+ };
287
+ }
288
+ async function readDocument(source, options, allowMissing = false) {
289
+ for (let attempt = 0; ; attempt++) {
290
+ try {
291
+ let url = source;
292
+ const visited = /* @__PURE__ */ new Set();
293
+ for (; ; ) {
294
+ visited.add(url);
295
+ const response = await fetch(url, {
296
+ redirect: "manual",
297
+ signal: AbortSignal.timeout(options.timeoutMs)
298
+ });
299
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
300
+ await response.body?.cancel();
301
+ const location = response.headers.get("location");
302
+ let next;
303
+ try {
304
+ if (!location)
305
+ throw new Error("Missing location");
306
+ next = normalizeUrl(new URL(location, url).href);
307
+ } catch {
308
+ throw new DocumentError(`Invalid sitemap/robots redirect from ${url}`);
309
+ }
310
+ if (visited.has(next) || visited.size > options.maxRedirects)
311
+ throw new DocumentError(`Sitemap/robots redirect loop or limit exceeded: ${url}`);
312
+ url = next;
313
+ continue;
314
+ }
315
+ if (!response.ok) {
316
+ await response.body?.cancel();
317
+ if (allowMissing && [404, 410].includes(response.status))
318
+ return void 0;
319
+ throw new DocumentError(`Unable to read ${url}: HTTP ${response.status}`, response.status >= 500);
320
+ }
321
+ const chunks = [];
322
+ let size = 0;
323
+ if (response.body) {
324
+ const reader = response.body.getReader();
325
+ try {
326
+ for (; ; ) {
327
+ const { done, value } = await reader.read();
328
+ if (done)
329
+ break;
330
+ size += value.byteLength;
331
+ if (size > options.maxSitemapBytes)
332
+ throw new DocumentError(`Discovery document exceeds maxSitemapBytes: ${url}`);
333
+ chunks.push(value);
334
+ }
335
+ } finally {
336
+ await reader.cancel().catch(() => {
337
+ });
338
+ reader.releaseLock();
339
+ }
340
+ }
341
+ let body = Buffer.concat(chunks);
342
+ if (body[0] === 31 && body[1] === 139) {
343
+ try {
344
+ body = await unzip(body, { maxOutputLength: options.maxSitemapBytes });
345
+ } catch {
346
+ throw new DocumentError(`Invalid gzip or decompressed document exceeds maxSitemapBytes: ${url}`);
347
+ }
348
+ }
349
+ return { url, text: body.toString("utf8") };
350
+ }
351
+ } catch (error) {
352
+ if (attempt >= options.retries || error instanceof DocumentError && !error.retryable)
353
+ throw new Error(`Sitemap discovery failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
354
+ await setTimeout(500 * 2 ** attempt);
355
+ }
356
+ }
357
+ }
358
+ function parseLocations(text, source) {
359
+ if (/<!DOCTYPE|<!ENTITY/i.test(text) || XMLValidator.validate(text) !== true)
360
+ throw new Error(`Invalid sitemap XML: ${source}`);
361
+ const parser = new XMLParser({
362
+ ignoreAttributes: true,
363
+ removeNSPrefix: true,
364
+ parseTagValue: false,
365
+ isArray: (name) => name === "url" || name === "sitemap"
366
+ });
367
+ const document = parser.parse(text);
368
+ const roots = Object.keys(document).filter((key) => !key.startsWith("?"));
369
+ if (roots.length !== 1 || !["urlset", "sitemapindex"].includes(roots[0]))
370
+ throw new Error(`Expected a sitemap urlset or sitemapindex: ${source}`);
371
+ const isIndex = roots[0] === "sitemapindex";
372
+ const root = document[roots[0]];
373
+ if (root?.[isIndex ? "url" : "sitemap"])
374
+ throw new Error(`Mixed sitemap index and page entries: ${source}`);
375
+ const entries = root?.[isIndex ? "sitemap" : "url"] ?? [];
376
+ const urls = entries.map((entry) => {
377
+ if (!entry || typeof entry !== "object" || !("loc" in entry) || typeof entry.loc !== "string" || !entry.loc.trim())
378
+ throw new Error(`Missing or invalid sitemap loc: ${source}`);
379
+ try {
380
+ return normalizeUrl(new URL(entry.loc.trim(), source).href);
381
+ } catch {
382
+ throw new Error(`Invalid HTTP(S) URL in sitemap: ${source}`);
383
+ }
384
+ });
385
+ return { isIndex, urls };
386
+ }
387
+ async function readSitemapUrls(source, options = {}) {
388
+ const input = normalizeUrl(source.trim());
389
+ const config = discoveryOptions(options);
390
+ let pending = [input];
391
+ if (options.discover) {
392
+ const robots = await readDocument(new URL("/robots.txt", input).href, config, true);
393
+ const declared = robots?.text.split(/\r?\n/).flatMap((line) => {
394
+ const match = line.match(/^\s*sitemap\s*:\s*(\S+)/i);
395
+ return match ? [normalizeUrl(new URL(match[1], robots.url).href)] : [];
396
+ }) ?? [];
397
+ pending = declared.length ? declared : [new URL("/sitemap.xml", input).href];
398
+ }
399
+ const queued = new Set(pending);
400
+ pending = [...queued];
401
+ const visited = /* @__PURE__ */ new Set();
402
+ const pages = /* @__PURE__ */ new Set();
403
+ let documentCount = 0;
404
+ for (let cursor = 0; cursor < pending.length; cursor++) {
405
+ const url = pending[cursor];
406
+ if (visited.has(url))
407
+ continue;
408
+ if (++documentCount > config.maxSitemaps)
409
+ throw new Error("Sitemap discovery exceeds maxSitemaps; no pages were checked");
410
+ visited.add(url);
411
+ const document = await readDocument(url, config);
412
+ if (!document)
413
+ throw new Error(`Unable to read sitemap: ${url}`);
414
+ visited.add(document.url);
415
+ const { isIndex, urls } = parseLocations(document.text, document.url);
416
+ for (const location of urls) {
417
+ if (isIndex) {
418
+ if (!queued.has(location) && !visited.has(location)) {
419
+ queued.add(location);
420
+ pending.push(location);
421
+ }
422
+ } else {
423
+ pages.add(location);
424
+ if (pages.size > config.maxUrls)
425
+ throw new Error("Sitemap discovery exceeds maxUrls; no pages were checked");
426
+ }
427
+ }
428
+ }
429
+ if (!pages.size)
430
+ throw new Error("Sitemap discovery found no page URLs; no pages were checked");
431
+ return [...pages];
432
+ }
433
+ async function checkSitemap(source, options = {}) {
434
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
435
+ const urls = await readSitemapUrls(source, options);
436
+ const report = await checkLinks(urls, options);
437
+ return { ...report, startedAt };
438
+ }
439
+
440
+ async function waitForReport(expected, { url, attempts = 12, delayMs = 15e3 }) {
441
+ if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > 120 || !Number.isFinite(delayMs) || delayMs < 0 || delayMs > 3e5)
442
+ throw new Error("Invalid report verification retry options.");
443
+ const reportUrl = new URL(url);
444
+ if (!["https:", "http:"].includes(reportUrl.protocol) || reportUrl.username || reportUrl.password)
445
+ throw new Error("Report verification requires an HTTP(S) URL without credentials.");
446
+ reportUrl.searchParams.set("observation", expected.completedAt);
447
+ const expectedJson = JSON.stringify(parseReport(expected));
448
+ for (let attempt = 0; attempt < attempts; attempt++) {
449
+ try {
450
+ const options = { cache: "no-store", signal: AbortSignal.timeout(1e4) };
451
+ const response = await fetch(reportUrl, options);
452
+ if (response.ok && JSON.stringify(parseReport(await response.json())) === expectedJson) {
453
+ const page = await fetch(new URL("./", url), { cache: "no-store", signal: AbortSignal.timeout(1e4) });
454
+ if (page.ok && (await page.text()).includes('id="meodp-data"'))
455
+ return;
456
+ }
457
+ } catch {
458
+ }
459
+ if (attempt + 1 < attempts)
460
+ await setTimeout(delayMs);
461
+ }
462
+ throw new Error("The public status page did not serve this report in time. Check the hosting deployment; saved report files remain available.");
463
+ }
464
+
465
+ export { checkLinks as a, waitForReport as b, checkSitemap as c, normalizeTargets as n, planReporters as p, readSitemapUrls as r, writeReporters as w };
@@ -0,0 +1,41 @@
1
+ import { C as CheckReport } from './meodp.93e06e00.js';
2
+
3
+ interface ReportSiteOptions {
4
+ /** Automatically load this HTTP(S) or relative JSON URL when hosted. */
5
+ dataUrl?: string;
6
+ }
7
+
8
+ type ReporterName = 'json' | 'markdown' | 'html';
9
+ type HtmlReporterOptions = ReportSiteOptions & ({
10
+ outputFolder?: string;
11
+ outputFile?: never;
12
+ } | {
13
+ outputFile: string;
14
+ outputFolder?: never;
15
+ });
16
+ type ReporterDescription = ReporterName | readonly ['json' | 'markdown', {
17
+ outputFile?: string;
18
+ }?] | readonly ['html', HtmlReporterOptions?];
19
+ /** A name, a list of names, or a list of [name, options] tuples. */
20
+ type ReporterConfig = ReporterName | readonly ReporterDescription[];
21
+ interface ReporterOutput {
22
+ reporter: ReporterName;
23
+ files: string[];
24
+ }
25
+ interface ReporterOptions extends ReportSiteOptions {
26
+ /** Default directory for reporters without explicit output paths. */
27
+ outputDir?: string;
28
+ /** Resolve relative output paths from this directory (default: working directory). */
29
+ cwd?: string;
30
+ }
31
+ /** Run built-in reporters on saved data; no network requests or browser launch. */
32
+ declare function writeReporters(report: CheckReport | undefined, reporter: ReporterConfig, options?: ReporterOptions): Promise<ReporterOutput[]>;
33
+
34
+ interface ReportVerificationOptions {
35
+ url: string;
36
+ attempts?: number;
37
+ delayMs?: number;
38
+ }
39
+ declare function waitForReport(expected: CheckReport, { url, attempts, delayMs }: ReportVerificationOptions): Promise<void>;
40
+
41
+ export { type HtmlReporterOptions as H, type ReporterConfig as R, type ReportVerificationOptions as a, type ReportSiteOptions as b, type ReporterDescription as c, type ReporterName as d, type ReporterOptions as e, type ReporterOutput as f, waitForReport as g, writeReporters as w };
@@ -0,0 +1,64 @@
1
+ interface LinkTarget {
2
+ url: string;
3
+ name?: string;
4
+ }
5
+ type Availability = 'reachable' | 'restricted' | 'unavailable';
6
+ type FailureReason = 'http' | 'dns' | 'tls' | 'timeout' | 'network' | 'redirect';
7
+ interface LinkObservation extends LinkTarget {
8
+ status: Availability;
9
+ checkedAt: string;
10
+ durationMs: number;
11
+ attempts: number;
12
+ finalUrl: string;
13
+ httpStatus?: number;
14
+ redirects: {
15
+ url: string;
16
+ status: number;
17
+ location: string;
18
+ }[];
19
+ reason?: FailureReason;
20
+ detail?: string;
21
+ consecutiveFailures: number;
22
+ firstFailureAt?: string;
23
+ lastSuccessAt?: string;
24
+ changed: boolean;
25
+ recovered: boolean;
26
+ }
27
+ interface CheckReport {
28
+ schemaVersion: 1;
29
+ observer: string;
30
+ startedAt: string;
31
+ completedAt: string;
32
+ summary: Record<Availability, number> & {
33
+ total: number;
34
+ redirected: number;
35
+ recovered: number;
36
+ };
37
+ results: LinkObservation[];
38
+ }
39
+ interface CheckOptions {
40
+ /** Maximum simultaneous site checks. Default: 5. */
41
+ concurrency?: number;
42
+ /** Timeout for each HTTP request, including its body. Default: 10000 ms. */
43
+ timeoutMs?: number;
44
+ /** Retries for transport errors and 5xx responses. Default: 1; 429 is not retried. */
45
+ retries?: number;
46
+ /** Maximum followed redirects per attempt. Default: 5. */
47
+ maxRedirects?: number;
48
+ /** Identifies the network/environment. Must match previousReport.observer. */
49
+ observer?: string;
50
+ previousReport?: CheckReport;
51
+ onResult?: (result: LinkObservation) => void | Promise<void>;
52
+ }
53
+ interface SitemapOptions extends CheckOptions {
54
+ /** Treat the input as a site URL: read robots.txt, falling back to /sitemap.xml. */
55
+ discover?: boolean;
56
+ /** Reject before page checks if discovery exceeds this many unique pages. Default: 10000. */
57
+ maxUrls?: number;
58
+ /** Maximum number of unique sitemap documents to read. Default: 100. */
59
+ maxSitemaps?: number;
60
+ /** Maximum downloaded and decompressed size per discovery document. Default: 10 MiB. */
61
+ maxSitemapBytes?: number;
62
+ }
63
+
64
+ export type { Availability as A, CheckReport as C, FailureReason as F, LinkTarget as L, SitemapOptions as S, CheckOptions as a, LinkObservation as b };
@@ -0,0 +1,64 @@
1
+ interface LinkTarget {
2
+ url: string;
3
+ name?: string;
4
+ }
5
+ type Availability = 'reachable' | 'restricted' | 'unavailable';
6
+ type FailureReason = 'http' | 'dns' | 'tls' | 'timeout' | 'network' | 'redirect';
7
+ interface LinkObservation extends LinkTarget {
8
+ status: Availability;
9
+ checkedAt: string;
10
+ durationMs: number;
11
+ attempts: number;
12
+ finalUrl: string;
13
+ httpStatus?: number;
14
+ redirects: {
15
+ url: string;
16
+ status: number;
17
+ location: string;
18
+ }[];
19
+ reason?: FailureReason;
20
+ detail?: string;
21
+ consecutiveFailures: number;
22
+ firstFailureAt?: string;
23
+ lastSuccessAt?: string;
24
+ changed: boolean;
25
+ recovered: boolean;
26
+ }
27
+ interface CheckReport {
28
+ schemaVersion: 1;
29
+ observer: string;
30
+ startedAt: string;
31
+ completedAt: string;
32
+ summary: Record<Availability, number> & {
33
+ total: number;
34
+ redirected: number;
35
+ recovered: number;
36
+ };
37
+ results: LinkObservation[];
38
+ }
39
+ interface CheckOptions {
40
+ /** Maximum simultaneous site checks. Default: 5. */
41
+ concurrency?: number;
42
+ /** Timeout for each HTTP request, including its body. Default: 10000 ms. */
43
+ timeoutMs?: number;
44
+ /** Retries for transport errors and 5xx responses. Default: 1; 429 is not retried. */
45
+ retries?: number;
46
+ /** Maximum followed redirects per attempt. Default: 5. */
47
+ maxRedirects?: number;
48
+ /** Identifies the network/environment. Must match previousReport.observer. */
49
+ observer?: string;
50
+ previousReport?: CheckReport;
51
+ onResult?: (result: LinkObservation) => void | Promise<void>;
52
+ }
53
+ interface SitemapOptions extends CheckOptions {
54
+ /** Treat the input as a site URL: read robots.txt, falling back to /sitemap.xml. */
55
+ discover?: boolean;
56
+ /** Reject before page checks if discovery exceeds this many unique pages. Default: 10000. */
57
+ maxUrls?: number;
58
+ /** Maximum number of unique sitemap documents to read. Default: 100. */
59
+ maxSitemaps?: number;
60
+ /** Maximum downloaded and decompressed size per discovery document. Default: 10 MiB. */
61
+ maxSitemapBytes?: number;
62
+ }
63
+
64
+ export type { Availability as A, CheckReport as C, FailureReason as F, LinkTarget as L, SitemapOptions as S, CheckOptions as a, LinkObservation as b };