meodp 0.0.8 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +204 -22
- package/bin/index.mjs +10 -2
- package/dist/check/cli.cjs +166 -0
- package/dist/check/cli.d.cts +5 -0
- package/dist/check/cli.d.mts +5 -0
- package/dist/check/cli.d.ts +5 -0
- package/dist/check/cli.mjs +158 -0
- package/dist/check/index.cjs +25 -0
- package/dist/check/index.d.cts +107 -0
- package/dist/check/index.d.mts +107 -0
- package/dist/check/index.d.ts +107 -0
- package/dist/check/index.mjs +11 -0
- package/dist/cli/index.cjs +5 -5
- package/dist/cli/index.d.cts +1 -1
- package/dist/cli/index.d.mts +1 -1
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.mjs +5 -5
- package/dist/cli/main.cjs +220 -0
- package/dist/cli/main.d.cts +4 -0
- package/dist/cli/main.d.mts +4 -0
- package/dist/cli/main.d.ts +4 -0
- package/dist/cli/main.mjs +214 -0
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +2 -2
- package/dist/shared/{meodp.6b19529e.cjs → meodp.15f2977d.cjs} +1 -1
- package/dist/shared/{meodp.b58178b8.mjs → meodp.9a096f50.mjs} +1 -1
- package/dist/shared/meodp.a0bcc3e7.cjs +580 -0
- package/dist/shared/meodp.d0916bc5.mjs +569 -0
- package/package.json +58 -31
- package/bin/index.ts +0 -6
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const promises = require('node:timers/promises');
|
|
4
|
+
const linkinator = require('linkinator');
|
|
5
|
+
const node_os = require('node:os');
|
|
6
|
+
const node_crypto = require('node:crypto');
|
|
7
|
+
const promises$1 = require('node:fs/promises');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const node_buffer = require('node:buffer');
|
|
10
|
+
const node_util = require('node:util');
|
|
11
|
+
const node_zlib = require('node:zlib');
|
|
12
|
+
const fastXmlParser = require('fast-xml-parser');
|
|
13
|
+
|
|
14
|
+
function normalizeUrl(value) {
|
|
15
|
+
const url = new URL(value);
|
|
16
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
|
|
17
|
+
throw new Error("Expected an HTTP(S) URL without embedded credentials");
|
|
18
|
+
url.hash = "";
|
|
19
|
+
return url.href;
|
|
20
|
+
}
|
|
21
|
+
function normalizeTargets(input) {
|
|
22
|
+
if (!Array.isArray(input))
|
|
23
|
+
throw new TypeError("Input must be an array of URLs or objects with a url field");
|
|
24
|
+
const seen = /* @__PURE__ */ new Set();
|
|
25
|
+
return input.map((item, index) => {
|
|
26
|
+
const value = typeof item === "string" ? { url: item } : item;
|
|
27
|
+
if (!value || typeof value !== "object" || !("url" in value) || typeof value.url !== "string")
|
|
28
|
+
throw new TypeError(`Invalid link at index ${index}: expected a url string`);
|
|
29
|
+
let url;
|
|
30
|
+
try {
|
|
31
|
+
url = normalizeUrl(value.url.trim());
|
|
32
|
+
} catch {
|
|
33
|
+
throw new TypeError(`Invalid HTTP(S) URL at index ${index}`);
|
|
34
|
+
}
|
|
35
|
+
if ("name" in value && value.name !== void 0 && typeof value.name !== "string")
|
|
36
|
+
throw new TypeError(`Invalid name at index ${index}: expected a string`);
|
|
37
|
+
return { url, ..."name" in value && typeof value.name === "string" ? { name: value.name } : {} };
|
|
38
|
+
}).filter((item) => {
|
|
39
|
+
if (seen.has(item.url))
|
|
40
|
+
return false;
|
|
41
|
+
seen.add(item.url);
|
|
42
|
+
return true;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function integerOption(value, fallback, name, minimum, maximum) {
|
|
47
|
+
const result = value ?? fallback;
|
|
48
|
+
if (!Number.isInteger(result) || result < minimum || result > maximum)
|
|
49
|
+
throw new RangeError(`${name} must be an integer between ${minimum} and ${maximum}`);
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
function normalizeCheckOptions(options) {
|
|
53
|
+
const concurrency = integerOption(options.concurrency, 5, "concurrency", 1, 100);
|
|
54
|
+
const timeoutMs = integerOption(options.timeoutMs, 1e4, "timeoutMs", 1, 3e5);
|
|
55
|
+
const retries = integerOption(options.retries, 1, "retries", 0, 5);
|
|
56
|
+
const maxRedirects = integerOption(options.maxRedirects, 5, "maxRedirects", 0, 20);
|
|
57
|
+
const observer = options.observer ?? node_os.hostname();
|
|
58
|
+
if (!observer.trim())
|
|
59
|
+
throw new TypeError("observer must not be empty");
|
|
60
|
+
if (options.previousReport && options.previousReport.observer !== observer)
|
|
61
|
+
throw new Error("Previous report belongs to another observer; use a separate history file");
|
|
62
|
+
return { concurrency, timeoutMs, retries, maxRedirects, observer };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function failureDetail(result) {
|
|
66
|
+
const error = result.failureDetails?.find((item) => item instanceof Error);
|
|
67
|
+
const parts = [];
|
|
68
|
+
let current = error;
|
|
69
|
+
for (let depth = 0; current && typeof current === "object" && depth < 5; depth++) {
|
|
70
|
+
for (const key of ["name", "code", "message"]) {
|
|
71
|
+
const value = Reflect.get(current, key);
|
|
72
|
+
if (typeof value === "string")
|
|
73
|
+
parts.push(value);
|
|
74
|
+
}
|
|
75
|
+
current = "cause" in current ? current.cause : void 0;
|
|
76
|
+
}
|
|
77
|
+
const detail = parts.join(": ") || `HTTP ${result.status ?? 0}`;
|
|
78
|
+
if (/ENOTFOUND|EAI_AGAIN|EAI_FAIL/.test(detail))
|
|
79
|
+
return { reason: "dns", detail };
|
|
80
|
+
if (/CERT|TLS|SSL/i.test(detail))
|
|
81
|
+
return { reason: "tls", detail };
|
|
82
|
+
if (/timeout|timed out|ETIMEDOUT|UND_ERR_.*TIMEOUT/i.test(detail))
|
|
83
|
+
return { reason: "timeout", detail };
|
|
84
|
+
return { reason: result.status ? "http" : "network", detail };
|
|
85
|
+
}
|
|
86
|
+
async function probe(url, timeoutMs, maxRedirects) {
|
|
87
|
+
const redirects = [];
|
|
88
|
+
const visited = /* @__PURE__ */ new Set();
|
|
89
|
+
let current = url;
|
|
90
|
+
for (; ; ) {
|
|
91
|
+
visited.add(current);
|
|
92
|
+
const checker = new linkinator.LinkChecker();
|
|
93
|
+
const { links } = await checker.check({
|
|
94
|
+
path: current,
|
|
95
|
+
recurse: false,
|
|
96
|
+
concurrency: 1,
|
|
97
|
+
timeout: timeoutMs,
|
|
98
|
+
retry: false,
|
|
99
|
+
retryErrors: false,
|
|
100
|
+
redirects: "error",
|
|
101
|
+
linksToSkip: async (candidate) => candidate !== current
|
|
102
|
+
});
|
|
103
|
+
const result = links.find((item) => item.url === current && !item.parent);
|
|
104
|
+
if (!result)
|
|
105
|
+
throw new Error("The HTTP checker returned no result for the requested URL");
|
|
106
|
+
const httpStatus = result.status || void 0;
|
|
107
|
+
const base = { finalUrl: current, httpStatus, redirects };
|
|
108
|
+
if (httpStatus && [301, 302, 303, 307, 308].includes(httpStatus)) {
|
|
109
|
+
const response = result.failureDetails?.find((item) => "headers" in item);
|
|
110
|
+
const location = response && "headers" in response ? response.headers.location : void 0;
|
|
111
|
+
let next;
|
|
112
|
+
try {
|
|
113
|
+
if (!location)
|
|
114
|
+
throw new Error("Missing redirect location");
|
|
115
|
+
next = normalizeUrl(new URL(location, current).href);
|
|
116
|
+
} catch {
|
|
117
|
+
return { ...base, status: "unavailable", reason: "redirect", detail: "Missing or invalid HTTP(S) redirect destination" };
|
|
118
|
+
}
|
|
119
|
+
redirects.push({ url: current, status: httpStatus, location: next });
|
|
120
|
+
if (visited.has(next) || redirects.length > maxRedirects) {
|
|
121
|
+
return {
|
|
122
|
+
...base,
|
|
123
|
+
status: "unavailable",
|
|
124
|
+
reason: "redirect",
|
|
125
|
+
detail: visited.has(next) ? "Redirect loop" : "Redirect limit exceeded"
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
current = next;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (httpStatus && httpStatus >= 200 && httpStatus < 300)
|
|
132
|
+
return { ...base, status: "reachable" };
|
|
133
|
+
if (httpStatus && [401, 403, 429, 451, 999].includes(httpStatus))
|
|
134
|
+
return { ...base, status: "restricted", reason: "http", detail: `HTTP ${httpStatus}: access requires review` };
|
|
135
|
+
return { ...base, status: "unavailable", ...failureDetail(result) };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function checkLinks(input, options = {}) {
|
|
139
|
+
const targets = normalizeTargets(input);
|
|
140
|
+
const { concurrency, timeoutMs, retries, maxRedirects, observer } = normalizeCheckOptions(options);
|
|
141
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
142
|
+
const previous = new Map(options.previousReport?.results.map((item) => [item.url, item]));
|
|
143
|
+
const results = [];
|
|
144
|
+
let cursor = 0;
|
|
145
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, targets.length) }, async () => {
|
|
146
|
+
while (cursor < targets.length) {
|
|
147
|
+
const index = cursor++;
|
|
148
|
+
const target = targets[index];
|
|
149
|
+
const start = Date.now();
|
|
150
|
+
let attempts = 0;
|
|
151
|
+
let observation;
|
|
152
|
+
do {
|
|
153
|
+
if (attempts > 0)
|
|
154
|
+
await promises.setTimeout(500 * 2 ** (attempts - 1));
|
|
155
|
+
attempts++;
|
|
156
|
+
observation = await probe(target.url, timeoutMs, maxRedirects);
|
|
157
|
+
} while (attempts <= retries && observation.status === "unavailable" && observation.reason !== "redirect" && (!observation.httpStatus || observation.httpStatus >= 500));
|
|
158
|
+
const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
159
|
+
const old = previous.get(target.url);
|
|
160
|
+
const failed = observation.status === "unavailable";
|
|
161
|
+
const result = {
|
|
162
|
+
...target,
|
|
163
|
+
...observation,
|
|
164
|
+
checkedAt,
|
|
165
|
+
durationMs: Date.now() - start,
|
|
166
|
+
attempts,
|
|
167
|
+
consecutiveFailures: failed ? (old?.status === "unavailable" ? old.consecutiveFailures : 0) + 1 : 0,
|
|
168
|
+
firstFailureAt: failed ? old?.status === "unavailable" ? old.firstFailureAt ?? checkedAt : checkedAt : void 0,
|
|
169
|
+
lastSuccessAt: observation.status === "reachable" ? checkedAt : old?.lastSuccessAt,
|
|
170
|
+
changed: Boolean(old && (old.status !== observation.status || old.finalUrl !== observation.finalUrl)),
|
|
171
|
+
recovered: observation.status === "reachable" && old?.status === "unavailable"
|
|
172
|
+
};
|
|
173
|
+
results[index] = result;
|
|
174
|
+
await options.onResult?.(result);
|
|
175
|
+
}
|
|
176
|
+
}));
|
|
177
|
+
const summary = { total: results.length, reachable: 0, restricted: 0, unavailable: 0, redirected: 0, recovered: 0 };
|
|
178
|
+
for (const result of results) {
|
|
179
|
+
summary[result.status]++;
|
|
180
|
+
summary.redirected += Number(result.redirects.length > 0);
|
|
181
|
+
summary.recovered += Number(result.recovered);
|
|
182
|
+
}
|
|
183
|
+
return { schemaVersion: 1, observer, startedAt, completedAt: (/* @__PURE__ */ new Date()).toISOString(), summary, results };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseReport(data) {
|
|
187
|
+
const invalid = (field) => {
|
|
188
|
+
throw new TypeError(`Invalid or unsupported report: ${field}`);
|
|
189
|
+
};
|
|
190
|
+
const record = (value) => {
|
|
191
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
192
|
+
return invalid("expected an object");
|
|
193
|
+
return value;
|
|
194
|
+
};
|
|
195
|
+
const string = (value, field) => typeof value === "string" ? value : invalid(field);
|
|
196
|
+
const integer = (value, field, minimum = 0) => typeof value === "number" && Number.isSafeInteger(value) && value >= minimum ? value : invalid(field);
|
|
197
|
+
const bool = (value, field) => typeof value === "boolean" ? value : invalid(field);
|
|
198
|
+
const date = (value, field) => {
|
|
199
|
+
const text = string(value, field);
|
|
200
|
+
return Number.isFinite(Date.parse(text)) ? text : invalid(field);
|
|
201
|
+
};
|
|
202
|
+
const url = (value) => {
|
|
203
|
+
const text = string(value, "URL");
|
|
204
|
+
try {
|
|
205
|
+
const parsed = new URL(text);
|
|
206
|
+
if (!["https:", "http:"].includes(parsed.protocol) || parsed.username || parsed.password)
|
|
207
|
+
return invalid("URL must use HTTP(S) without credentials");
|
|
208
|
+
} catch {
|
|
209
|
+
return invalid("URL");
|
|
210
|
+
}
|
|
211
|
+
return text;
|
|
212
|
+
};
|
|
213
|
+
const root = record(data);
|
|
214
|
+
if (root.schemaVersion !== 1 || !Array.isArray(root.results))
|
|
215
|
+
return invalid("schemaVersion or results");
|
|
216
|
+
const results = root.results.map((value) => {
|
|
217
|
+
const item = record(value);
|
|
218
|
+
if (!["reachable", "restricted", "unavailable"].includes(String(item.status)) || !Array.isArray(item.redirects))
|
|
219
|
+
return invalid("status or redirects");
|
|
220
|
+
const observation = {
|
|
221
|
+
url: url(item.url),
|
|
222
|
+
finalUrl: url(item.finalUrl),
|
|
223
|
+
status: item.status,
|
|
224
|
+
checkedAt: date(item.checkedAt, "checkedAt"),
|
|
225
|
+
durationMs: typeof item.durationMs === "number" && Number.isFinite(item.durationMs) && item.durationMs >= 0 ? item.durationMs : invalid("durationMs"),
|
|
226
|
+
attempts: integer(item.attempts, "attempts", 1),
|
|
227
|
+
consecutiveFailures: integer(item.consecutiveFailures, "consecutiveFailures"),
|
|
228
|
+
changed: bool(item.changed, "changed"),
|
|
229
|
+
recovered: bool(item.recovered, "recovered"),
|
|
230
|
+
redirects: item.redirects.map((value2) => {
|
|
231
|
+
const hop = record(value2);
|
|
232
|
+
return { url: url(hop.url), status: integer(hop.status, "redirect status", 100), location: string(hop.location, "redirect location") };
|
|
233
|
+
})
|
|
234
|
+
};
|
|
235
|
+
if (item.name !== void 0)
|
|
236
|
+
observation.name = string(item.name, "name");
|
|
237
|
+
if (item.httpStatus !== void 0)
|
|
238
|
+
observation.httpStatus = integer(item.httpStatus, "httpStatus", 100);
|
|
239
|
+
if (item.reason !== void 0) {
|
|
240
|
+
if (!["http", "dns", "tls", "timeout", "network", "redirect"].includes(String(item.reason)))
|
|
241
|
+
return invalid("reason");
|
|
242
|
+
observation.reason = item.reason;
|
|
243
|
+
}
|
|
244
|
+
if (item.detail !== void 0)
|
|
245
|
+
observation.detail = string(item.detail, "detail");
|
|
246
|
+
if (item.firstFailureAt !== void 0)
|
|
247
|
+
observation.firstFailureAt = date(item.firstFailureAt, "firstFailureAt");
|
|
248
|
+
if (item.lastSuccessAt !== void 0)
|
|
249
|
+
observation.lastSuccessAt = date(item.lastSuccessAt, "lastSuccessAt");
|
|
250
|
+
return observation;
|
|
251
|
+
});
|
|
252
|
+
const summary = { total: results.length, reachable: 0, restricted: 0, unavailable: 0, redirected: 0, recovered: 0 };
|
|
253
|
+
for (const item of results) {
|
|
254
|
+
summary[item.status]++;
|
|
255
|
+
summary.redirected += Number(item.redirects.length > 0);
|
|
256
|
+
summary.recovered += Number(item.recovered);
|
|
257
|
+
}
|
|
258
|
+
const supplied = record(root.summary);
|
|
259
|
+
for (const [key, count] of Object.entries(summary)) {
|
|
260
|
+
if (supplied[key] !== count)
|
|
261
|
+
return invalid(`summary.${key} does not match results`);
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
schemaVersion: 1,
|
|
265
|
+
observer: string(root.observer, "observer"),
|
|
266
|
+
startedAt: date(root.startedAt, "startedAt"),
|
|
267
|
+
completedAt: date(root.completedAt, "completedAt"),
|
|
268
|
+
summary,
|
|
269
|
+
results
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const viewerScript = '"use strict";(()=>{function H(e){let t=d=>{throw new TypeError(`Invalid or unsupported report: ${d}`)},n=d=>!d||typeof d!="object"||Array.isArray(d)?t("expected an object"):d,s=(d,r)=>typeof d=="string"?d:t(r),o=(d,r,p=0)=>typeof d=="number"&&Number.isSafeInteger(d)&&d>=p?d:t(r),c=(d,r)=>typeof d=="boolean"?d:t(r),i=(d,r)=>{let p=s(d,r);return Number.isFinite(Date.parse(p))?p:t(r)},u=d=>{let r=s(d,"URL");try{let p=new URL(r);if(!["https:","http:"].includes(p.protocol)||p.username||p.password)return t("URL must use HTTP(S) without credentials")}catch{return t("URL")}return r},h=n(e);if(h.schemaVersion!==1||!Array.isArray(h.results))return t("schemaVersion or results");let L=h.results.map(d=>{let r=n(d);if(!["reachable","restricted","unavailable"].includes(String(r.status))||!Array.isArray(r.redirects))return t("status or redirects");let p={url:u(r.url),finalUrl:u(r.finalUrl),status:r.status,checkedAt:i(r.checkedAt,"checkedAt"),durationMs:typeof r.durationMs=="number"&&Number.isFinite(r.durationMs)&&r.durationMs>=0?r.durationMs:t("durationMs"),attempts:o(r.attempts,"attempts",1),consecutiveFailures:o(r.consecutiveFailures,"consecutiveFailures"),changed:c(r.changed,"changed"),recovered:c(r.recovered,"recovered"),redirects:r.redirects.map(z=>{let A=n(z);return{url:u(A.url),status:o(A.status,"redirect status",100),location:s(A.location,"redirect location")}})};if(r.name!==void 0&&(p.name=s(r.name,"name")),r.httpStatus!==void 0&&(p.httpStatus=o(r.httpStatus,"httpStatus",100)),r.reason!==void 0){if(!["http","dns","tls","timeout","network","redirect"].includes(String(r.reason)))return t("reason");p.reason=r.reason}return r.detail!==void 0&&(p.detail=s(r.detail,"detail")),r.firstFailureAt!==void 0&&(p.firstFailureAt=i(r.firstFailureAt,"firstFailureAt")),r.lastSuccessAt!==void 0&&(p.lastSuccessAt=i(r.lastSuccessAt,"lastSuccessAt")),p}),m={total:L.length,reachable:0,restricted:0,unavailable:0,redirected:0,recovered:0};for(let d of L)m[d.status]++,m.redirected+=+(d.redirects.length>0),m.recovered+=Number(d.recovered);let P=n(h.summary);for(let[d,r]of Object.entries(m))if(P[d]!==r)return t(`summary.${d} does not match results`);return{schemaVersion:1,observer:s(h.observer,"observer"),startedAt:i(h.startedAt,"startedAt"),completedAt:i(h.completedAt,"completedAt"),summary:m,results:L}}var T={reachable:"\\u53EF\\u8BBF\\u95EE",restricted:"\\u8BBF\\u95EE\\u53D7\\u9650",unavailable:"\\u6682\\u4E0D\\u53EF\\u8FBE",redirected:"\\u6709\\u8DF3\\u8F6C",recovered:"\\u5DF2\\u6062\\u590D",all:"\\u5168\\u90E8"},O={http:"HTTP",dns:"DNS",tls:"TLS",timeout:"\\u8D85\\u65F6",network:"\\u7F51\\u7EDC\\u9519\\u8BEF",redirect:"\\u8DF3\\u8F6C\\u5F02\\u5E38"},k=20,N=10*1024*1024,l=e=>document.getElementById(e),$=l("import-dialog"),R=l("search"),w=l("sort"),V=l("rows"),x=l("data-url"),S=l("file"),f,v="all",g=0,y=0;function a(e,t,n){let s=document.createElement(e);return t!==void 0&&(s.textContent=t),n&&(s.className=n),s}function E(e){return e?new Date(e).toLocaleString("zh-CN",{hour12:!1}):"\\u2014"}function B(e){return e<1e3?`${Math.round(e)} ms`:`${(e/1e3).toFixed(1)} s`}function M(e="",t=!1){let n=l("message");n.textContent=e,n.hidden=!e,n.className=t?"error-message":"info-message"}function I(e,t=e){let n=a("a",t);return n.href=e,n.target="_blank",n.rel="noopener noreferrer",n}function j(){let e=f?.summary,t=l("metrics");t.replaceChildren();for(let s of["all","reachable","restricted","unavailable"]){let o=a("div",void 0,`metric ${s}`);o.append(a("span",s==="all"?"\\u5168\\u90E8\\u7AD9\\u70B9":T[s]),a("strong",String(e?e[s==="all"?"total":s]:"\\u2014"))),t.append(o)}let n=l("distribution");if(n.replaceChildren(),e?.total)for(let s of["reachable","restricted","unavailable"]){let o=a("span",void 0,s);o.style.width=`${e[s]/e.total*100}%`,n.append(o)}l("metadata").textContent=f?`${E(f.completedAt)} \\xB7 ${f.observer}`:"\\u52A0\\u8F7D\\u68C0\\u6D4B\\u6570\\u636E\\uFF0C\\u67E5\\u770B\\u7AD9\\u70B9\\u53EF\\u8BBF\\u95EE\\u6027\\u3002",l("download").disabled=!f}function U(){let e=l("filters");e.replaceChildren();let t=["all","unavailable","restricted","reachable","redirected"];f?.summary.recovered&&t.push("recovered");for(let n of t){let s=f?.summary[n==="all"?"total":n]??0,o=a("button",void 0,v===n?"selected":"");o.type="button",o.dataset.filter=n,o.setAttribute("aria-pressed",String(v===n)),o.append(a("span",T[n]),a("span",String(s),"filter-count")),o.addEventListener("click",()=>{v=n,g=0,U(),b(),l("filters").querySelector(`[data-filter="${n}"]`)?.focus()}),e.append(o)}}function q(e){let t=a("div",void 0,"detail-body");t.append(a("h3","\\u68C0\\u6D4B\\u8BE6\\u60C5"));let n=a("dl",void 0,"facts"),s=[["\\u8FDE\\u7EED\\u5931\\u8D25",`${e.consecutiveFailures} \\u6B21`],["\\u8BF7\\u6C42\\u5C1D\\u8BD5",`${e.attempts} \\u6B21`],["\\u6700\\u540E\\u6210\\u529F",E(e.lastSuccessAt)],["\\u9996\\u6B21\\u8FDE\\u7EED\\u5931\\u8D25",E(e.firstFailureAt)],["\\u68C0\\u6D4B\\u65F6\\u95F4",E(e.checkedAt)],["\\u8017\\u65F6",B(e.durationMs)],["HTTP / \\u539F\\u56E0",String(e.httpStatus??(e.reason?O[e.reason]:"\\u2014"))],["\\u72B6\\u6001\\u53D8\\u5316",e.recovered?"\\u672C\\u6B21\\u6062\\u590D\\u8BBF\\u95EE":e.changed?"\\u72B6\\u6001\\u6216\\u6700\\u7EC8 URL \\u5DF2\\u53D8\\u5316":"\\u65E0\\u53D8\\u5316"]];for(let[o,c]of s){let i=a("div");i.append(a("dt",o),a("dd",c)),n.append(i)}if(t.append(n),e.detail&&t.append(a("h4","\\u9519\\u8BEF\\u4FE1\\u606F"),a("pre",e.detail)),t.append(a("h4","\\u6700\\u7EC8 URL"),I(e.finalUrl)),e.redirects.length){t.append(a("h4","\\u8DF3\\u8F6C\\u94FE"));let o=a("ol",void 0,"redirect-chain");for(let c of e.redirects){let i=a("li");i.append(a("strong",String(c.status)),a("span",`${c.url} \\u2192 ${c.location}`)),o.append(i)}t.append(o)}return t}function K(e,t){let n=a("tr",void 0,"result-row"),s=a("td",void 0,"site");s.append(I(e.url,e.name||new URL(e.url).hostname),a("span",e.url,"site-url"));let o=a("td");o.append(a("span",T[e.status],`status ${e.status}`)),e.redirects.length&&o.append(a("span",`${e.redirects.length} \\u6B21\\u8DF3\\u8F6C`,"row-note")),e.recovered&&o.append(a("span","\\u672C\\u6B21\\u6062\\u590D","row-note"));let c=a("td",void 0,"detail-toggle"),i=a("button","\\u203A");i.type="button",i.setAttribute("aria-label",`\\u67E5\\u770B ${e.name||e.url} \\u7684\\u68C0\\u6D4B\\u8BE6\\u60C5`),i.setAttribute("aria-expanded","false"),i.setAttribute("aria-controls",`detail-${t}`);let u=a("tr",void 0,"detail-row");u.hidden=!0,u.id=`detail-${t}`;let h=a("td");return h.colSpan=5,u.append(h),i.addEventListener("click",()=>{u.hidden=!u.hidden,i.setAttribute("aria-expanded",String(!u.hidden)),n.classList.toggle("expanded",!u.hidden),!u.hidden&&!h.childNodes.length&&h.append(q(e))}),c.append(i),n.append(s,o,a("td",String(e.httpStatus??(e.reason?O[e.reason]:"\\u2014")),"technical"),a("td",B(e.durationMs),"technical"),c),[n,u]}function b(){let e=R.value.trim().toLocaleLowerCase(),t=(f?.results??[]).filter(c=>(v==="all"||(v==="redirected"?c.redirects.length>0:v==="recovered"?c.recovered:c.status===v))&&`${c.name??""} ${c.url} ${c.finalUrl}`.toLocaleLowerCase().includes(e)),n={unavailable:0,restricted:1,reachable:2};w.value==="severity"&&t.sort((c,i)=>n[c.status]-n[i.status]),w.value==="duration"&&t.sort((c,i)=>i.durationMs-c.durationMs),w.value==="failures"&&t.sort((c,i)=>i.consecutiveFailures-c.consecutiveFailures),w.value==="name"&&t.sort((c,i)=>(c.name||c.url).localeCompare(i.name||i.url,"zh-CN")),g=Math.max(0,Math.min(g,Math.ceil(t.length/k)-1));let s=g*k;V.replaceChildren(...t.slice(s,s+k).flatMap((c,i)=>K(c,s+i))),l("table").hidden=!t.length;let o=l("empty");o.hidden=t.length>0,f&&o.replaceChildren(a("strong",f.results.length?"\\u6CA1\\u6709\\u5339\\u914D\\u7684\\u7AD9\\u70B9":"\\u8FD9\\u4EFD\\u62A5\\u544A\\u4E2D\\u6CA1\\u6709\\u7AD9\\u70B9"),a("span",f.results.length?"\\u8BD5\\u8BD5\\u5176\\u4ED6\\u5173\\u952E\\u8BCD\\u6216\\u72B6\\u6001\\u7B5B\\u9009\\u3002":"\\u52A0\\u8F7D\\u5176\\u4ED6\\u62A5\\u544A\\u4EE5\\u67E5\\u770B\\u68C0\\u6D4B\\u7ED3\\u679C\\u3002")),l("result-count").textContent=`\\u663E\\u793A ${t.length?s+1:0}\\u2013${Math.min(s+k,t.length)}\\uFF0C\\u5171 ${t.length} \\u4E2A\\u7AD9\\u70B9`,l("previous").disabled=g===0,l("next").disabled=s+k>=t.length}function C(e){let t=H(e);if(t.results.length>5e4)throw new Error("\\u62A5\\u544A\\u8D85\\u8FC7 50,000 \\u4E2A\\u7AD9\\u70B9\\uFF0C\\u8BF7\\u62C6\\u5206\\u540E\\u52A0\\u8F7D\\u3002");f=t,v="all",g=0,R.value="",w.value="original",j(),U(),b()}function F(e){M(`\\u52A0\\u8F7D\\u5931\\u8D25\\uFF1A${e instanceof Error?e.message:String(e)}\\u3002${f?"\\u5DF2\\u4FDD\\u7559\\u5F53\\u524D\\u62A5\\u544A\\u3002":""}`,!0)}async function D(e){let t=++y;$.close();try{if(e.size>N)throw new Error("JSON \\u6587\\u4EF6\\u4E0D\\u80FD\\u8D85\\u8FC7 10 MiB");let n=await e.text();if(t!==y)return;C(JSON.parse(n)),M(`\\u5DF2\\u52A0\\u8F7D ${e.name}`)}catch(n){t===y&&F(n)}S.value=""}async function J(e,t=!1){let n=++y;$.close(),(!t||!f)&&M("\\u6B63\\u5728\\u52A0\\u8F7D\\u62A5\\u544A\\u6570\\u636E\\u2026");try{let s=new URL(e,window.location.href);if(!["http:","https:"].includes(s.protocol)||s.username||s.password)throw new Error("\\u8BF7\\u4F7F\\u7528\\u4E0D\\u542B\\u8D26\\u53F7\\u5BC6\\u7801\\u7684 HTTP(S) \\u6570\\u636E URL\\uFF1B\\u672C\\u5730\\u62A5\\u544A\\u53EF\\u9009\\u62E9\\u6587\\u4EF6\\u52A0\\u8F7D");let o=await fetch(s,{credentials:"omit",cache:"no-store",signal:AbortSignal.timeout(15e3),referrerPolicy:"no-referrer"});if(!o.ok)throw new Error(`HTTP ${o.status}`);if(!o.body)throw new Error("\\u6570\\u636E\\u4E3A\\u7A7A");let c=o.body.getReader(),i=[],u=0;for(;;){let{done:L,value:m}=await c.read();if(L)break;if(u+=m.byteLength,u>N)throw await c.cancel(),new Error("JSON \\u6587\\u4EF6\\u4E0D\\u80FD\\u8D85\\u8FC7 10 MiB");i.push(m)}let h=await new Blob(i).text();if(n!==y)return;C(JSON.parse(h)),x.value=e,M(t?"":"\\u5DF2\\u52A0\\u8F7D\\u6700\\u65B0\\u62A5\\u544A\\u6570\\u636E\\u3002")}catch(s){n===y&&F(s)}}l("open-import").addEventListener("click",()=>$.showModal());l("empty-import").addEventListener("click",()=>S.click());S.addEventListener("change",()=>{S.files?.[0]&&D(S.files[0])});l("url-form").addEventListener("submit",e=>{e.preventDefault(),J(x.value.trim())});R.addEventListener("input",()=>{g=0,b()});w.addEventListener("change",()=>{g=0,b()});l("previous").addEventListener("click",()=>{g--,b()});l("next").addEventListener("click",()=>{g++,b()});l("download").addEventListener("click",()=>{if(!f)return;let e=URL.createObjectURL(new Blob([JSON.stringify(f,null,2)],{type:"application/json"})),t=a("a");t.href=e,t.download="report.json",t.click(),setTimeout(()=>URL.revokeObjectURL(e),1e3)});document.addEventListener("dragover",e=>{e.preventDefault()});document.addEventListener("drop",e=>{e.preventDefault();let t=e.dataTransfer?.files[0];t&&D(t)});j();U();b();try{let e=JSON.parse(l("meodp-data").textContent||"{}");e.report&&C(e.report),typeof e.dataUrl=="string"&&(x.value=e.dataUrl,window.location.protocol!=="file:"&&J(e.dataUrl,!0))}catch(e){F(e)}})();\n';
|
|
274
|
+
const viewerCss = ":root {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;\n color: #172033;\n background: #fff;\n font-synthesis: none;\n --muted: #66758a;\n --line: #e2e8f0;\n --blue: #2563eb;\n --green: #14805e;\n --amber: #98600b;\n --red: #ca4853;\n}\n* {\n box-sizing: border-box;\n}\nbody {\n margin: 0;\n font-size: 15px;\n line-height: 1.5;\n}\nbutton,\ninput,\nselect {\n font: inherit;\n font-size: 14px;\n}\nbutton,\nselect {\n cursor: pointer;\n}\nbutton {\n background: #fff;\n color: var(--muted);\n border: 1px solid #d6deea;\n border-radius: 6px;\n padding: 9px 16px;\n transition: background 0.15s;\n}\nbutton:hover:not(:disabled) {\n background: #f1f6ff;\n color: var(--blue);\n}\nbutton:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.primary {\n background: var(--blue);\n border-color: var(--blue);\n color: #fff;\n}\n.primary:hover:not(:disabled) {\n background: #1d4ed8;\n color: #fff;\n}\n:focus-visible {\n outline: 3px solid #93b6ff;\n outline-offset: 3px;\n}\ninput,\nselect {\n color: #172033;\n background: #fff;\n border: 1px solid #d6deea;\n border-radius: 6px;\n padding: 11px 14px;\n min-width: 0;\n}\ninput::placeholder {\n color: var(--muted);\n}\nheader {\n border-bottom: 1px solid var(--line);\n}\n.header-inner {\n max-width: 1248px;\n padding: 14px 24px;\n margin: auto;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n}\n.wordmark {\n font-size: 23px;\n font-weight: 750;\n letter-spacing: -0.8px;\n}\n.actions {\n display: flex;\n gap: 12px;\n}\nmain {\n max-width: 1248px;\n margin: 0 auto;\n padding: 36px 24px 48px;\n}\nh1 {\n font-size: 34px;\n line-height: 1.3;\n letter-spacing: -1px;\n margin: 0 0 6px;\n}\nh2 {\n font-size: 22px;\n margin: 0;\n}\nh3 {\n font-size: 14px;\n margin: 0 0 16px;\n}\nh4 {\n font-size: 13px;\n margin: 18px 0 8px;\n color: var(--muted);\n}\np {\n margin: 8px 0;\n}\n.muted,\n.note {\n color: var(--muted);\n}\n#metadata {\n overflow-wrap: anywhere;\n}\n.note {\n font-size: 13px;\n}\n.metrics {\n display: grid;\n grid-template-columns: repeat(4, 1fr);\n margin: 26px 0 16px;\n}\n.metric {\n padding: 0 32px;\n border-left: 1px solid var(--line);\n}\n.metric:first-child {\n padding-left: 0;\n border: 0;\n}\n.metric > span {\n color: var(--muted);\n font-size: 14px;\n display: block;\n}\n.metric > strong {\n font-size: 38px;\n font-weight: 650;\n line-height: 1.25;\n font-variant-numeric: tabular-nums;\n}\n.reachable {\n color: var(--green);\n}\n.restricted {\n color: var(--amber);\n}\n.unavailable {\n color: var(--red);\n}\n.distribution {\n height: 8px;\n display: flex;\n overflow: hidden;\n border-radius: 3px;\n background: #f1f5f9;\n}\n.distribution > span + span {\n border-left: 1px solid white;\n}\n.distribution .reachable {\n background: var(--green);\n}\n.distribution .restricted {\n background: #b47713;\n}\n.distribution .unavailable {\n background: var(--red);\n}\n#results {\n margin-top: 28px;\n}\n#filters {\n display: flex;\n gap: 18px;\n border-bottom: 1px solid var(--line);\n overflow-x: auto;\n}\n#filters button {\n padding: 12px 4px;\n border: none;\n border-bottom: 2px solid transparent;\n border-radius: 0;\n white-space: nowrap;\n}\n#filters button.selected {\n color: var(--blue);\n border-bottom-color: var(--blue);\n font-weight: 600;\n}\n.filter-count {\n margin-left: 10px;\n font-variant-numeric: tabular-nums;\n}\n.toolbar {\n display: flex;\n gap: 14px;\n margin: 14px 0;\n}\n.search {\n display: flex;\n align-items: center;\n flex: 1;\n min-width: 0;\n position: relative;\n}\n.search > svg {\n position: absolute;\n left: 14px;\n font-size: 25px;\n color: var(--muted);\n line-height: 1;\n}\n.search input {\n width: 100%;\n padding-left: 44px;\n}\n.toolbar select {\n width: 200px;\n}\ntable {\n border-collapse: collapse;\n width: 100%;\n table-layout: fixed;\n font-size: 14px;\n}\nth {\n background: #f7f9fc;\n text-align: left;\n font-weight: 600;\n color: #43516a;\n padding: 12px 16px;\n}\nth:first-child {\n width: 37%;\n border-radius: 5px 0 0 5px;\n}\nth:nth-child(2) {\n width: 18%;\n}\nth:nth-child(3) {\n width: 21%;\n}\nth:nth-child(4) {\n width: 15%;\n}\nth:last-child {\n width: 9%;\n border-radius: 0 5px 5px 0;\n}\ntd {\n padding: 12px 16px;\n border-bottom: 1px solid var(--line);\n vertical-align: middle;\n font-variant-numeric: tabular-nums;\n}\na {\n color: inherit;\n text-decoration: none;\n}\na:hover {\n color: var(--blue);\n text-decoration: underline;\n}\n.site a {\n font-size: 15px;\n font-weight: 600;\n overflow-wrap: anywhere;\n}\n.site-url {\n display: block;\n font-size: 13px;\n color: var(--muted);\n overflow-wrap: anywhere;\n}\n.status {\n display: inline-flex;\n align-items: center;\n gap: 10px;\n white-space: nowrap;\n}\n.status::before {\n content: '';\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: currentColor;\n flex-shrink: 0;\n}\n.row-note {\n display: block;\n color: var(--muted);\n font-size: 12px;\n margin-left: 18px;\n}\n.detail-toggle,\n.detail-heading {\n text-align: right;\n}\n.detail-toggle button {\n border: 0;\n font-size: 26px;\n line-height: 1;\n padding: 5px 12px;\n color: #172033;\n min-width: 40px;\n min-height: 40px;\n}\n.detail-toggle button[aria-expanded='true'] {\n transform: rotate(90deg);\n}\n.result-row:hover,\n.result-row.expanded {\n background: #fafcff;\n}\n.detail-row > td {\n padding: 0;\n}\n.detail-body {\n padding: 22px 24px;\n background: #f0f6ff;\n overflow-wrap: anywhere;\n}\n.facts {\n display: grid;\n grid-template-columns: repeat(4, 1fr);\n gap: 16px 24px;\n margin: 0;\n}\ndt {\n color: var(--muted);\n font-size: 12px;\n}\ndd {\n margin: 4px 0 0;\n font-size: 14px;\n}\npre {\n font-size: 13px;\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n color: #ae3440;\n margin: 0;\n}\n.detail-body a {\n color: var(--blue);\n}\n.redirect-chain {\n padding-left: 20px;\n margin: 8px 0 0;\n}\n.redirect-chain li {\n padding: 4px 0;\n}\n.redirect-chain strong {\n margin-right: 12px;\n}\n.pagination {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n margin-top: 16px;\n color: var(--muted);\n font-size: 13px;\n}\n.pagination > div {\n display: flex;\n gap: 12px;\n}\n.pagination button {\n padding: 7px 12px;\n}\n.empty {\n padding: 64px 16px;\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 16px;\n color: var(--muted);\n text-align: center;\n border-block: 1px solid var(--line);\n}\n.empty span {\n font-size: 13px;\n}\n[hidden] {\n display: none !important;\n}\n#message {\n padding: 12px 16px;\n border-radius: 6px;\n overflow-wrap: anywhere;\n margin: 20px 0 0;\n font-size: 14px;\n}\n.error-message {\n background: #fff2f3;\n color: #ad3440;\n}\n.info-message {\n background: #eff6ff;\n color: #255aab;\n}\ndialog {\n width: min(560px, calc(100% - 32px));\n border: 1px solid var(--line);\n border-radius: 12px;\n padding: 28px;\n color: #172033;\n box-shadow: 0 24px 80px #17203325;\n}\ndialog::backdrop {\n background: #17203360;\n}\n.dialog-heading {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 16px;\n margin-bottom: 12px;\n}\n.dialog-heading button {\n border: 0;\n font-size: 24px;\n line-height: 1;\n padding: 8px;\n}\n.file-label {\n display: block;\n margin: 24px 0;\n font-weight: 600;\n}\n.file-label input {\n margin-top: 10px;\n display: block;\n width: 100%;\n font-weight: 400;\n}\n#url-form > label {\n display: block;\n margin-bottom: 10px;\n font-weight: 600;\n}\n.url-controls {\n display: flex;\n gap: 8px;\n margin-bottom: 14px;\n}\n.url-controls input {\n flex: 1;\n}\n.sr-only,\n.skip-link:not(:focus) {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n}\n.skip-link:focus {\n display: block;\n padding: 8px;\n}\nnoscript {\n display: block;\n padding: 24px;\n}\n@media (max-width: 700px) {\n main {\n padding: 28px 18px;\n }\n .header-inner {\n padding: 12px 18px;\n }\n .wordmark {\n font-size: 21px;\n }\n .actions {\n gap: 8px;\n }\n .actions button {\n padding: 8px 10px;\n font-size: 13px;\n }\n h1 {\n font-size: 27px;\n letter-spacing: -0.5px;\n }\n #metadata {\n font-size: 13px;\n }\n .metrics {\n grid-template-columns: repeat(2, 1fr);\n gap: 20px 0;\n }\n .metric {\n padding-left: 24px;\n }\n .metric:nth-child(3) {\n padding-left: 0;\n border: 0;\n }\n .metric > strong {\n font-size: 32px;\n }\n #filters {\n gap: 16px;\n }\n .toolbar {\n flex-wrap: wrap;\n gap: 10px;\n }\n .search {\n flex-basis: 100%;\n }\n .toolbar select {\n width: 100%;\n }\n .technical {\n display: none;\n }\n th,\n td {\n padding: 12px 8px;\n }\n th:first-child {\n width: 55%;\n }\n th:nth-child(2) {\n width: 31%;\n }\n th:last-child {\n width: 14%;\n }\n .status {\n gap: 6px;\n font-size: 13px;\n }\n .site a {\n font-size: 14px;\n }\n .site-url {\n font-size: 12px;\n }\n .detail-toggle button {\n padding: 5px 8px;\n }\n .detail-body {\n padding: 20px 16px;\n }\n .facts {\n grid-template-columns: repeat(2, 1fr);\n gap: 16px;\n }\n .pagination {\n align-items: flex-start;\n flex-wrap: wrap;\n }\n dialog {\n padding: 20px;\n }\n}\n@media (prefers-reduced-motion: reduce) {\n * {\n transition: none !important;\n }\n}\n";
|
|
275
|
+
|
|
276
|
+
function createReportHtml(report, options = {}) {
|
|
277
|
+
if (options.dataUrl !== void 0) {
|
|
278
|
+
const url = new URL(options.dataUrl, "https://meodp.invalid/");
|
|
279
|
+
if (!options.dataUrl.trim() || !["http:", "https:"].includes(url.protocol) || url.username || url.password)
|
|
280
|
+
throw new TypeError("dataUrl must be an HTTP(S) or relative URL without credentials");
|
|
281
|
+
}
|
|
282
|
+
const data = JSON.stringify({ report: report ? parseReport(report) : null, dataUrl: options.dataUrl ?? null }).replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
|
|
283
|
+
return `<!doctype html>
|
|
284
|
+
<html lang="zh-CN">
|
|
285
|
+
<head>
|
|
286
|
+
<meta charset="utf-8">
|
|
287
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
288
|
+
<meta name="color-scheme" content="light">
|
|
289
|
+
<meta name="referrer" content="no-referrer">
|
|
290
|
+
<title>\u53CB\u94FE\u53EF\u8BBF\u95EE\u6027\u62A5\u544A \xB7 meodp</title>
|
|
291
|
+
<style>${viewerCss}</style>
|
|
292
|
+
</head>
|
|
293
|
+
<body>
|
|
294
|
+
<a class="skip-link" href="#results">\u8DF3\u5230\u68C0\u6D4B\u7ED3\u679C</a>
|
|
295
|
+
<header><div class="header-inner"><span class="wordmark">meodp</span><div class="actions">
|
|
296
|
+
<button id="open-import" type="button">\u52A0\u8F7D JSON</button><button id="download" class="primary" type="button" disabled>\u4E0B\u8F7D JSON</button>
|
|
297
|
+
</div></div></header>
|
|
298
|
+
<main>
|
|
299
|
+
<section aria-labelledby="report-title"><h1 id="report-title">\u53CB\u94FE\u53EF\u8BBF\u95EE\u6027\u62A5\u544A</h1><p id="metadata" class="muted">\u52A0\u8F7D\u68C0\u6D4B\u6570\u636E\uFF0C\u67E5\u770B\u7AD9\u70B9\u53EF\u8BBF\u95EE\u6027\u3002</p>
|
|
300
|
+
<div id="metrics" class="metrics"></div><div id="distribution" class="distribution" aria-hidden="true"></div>
|
|
301
|
+
<p class="note">\u8BBF\u95EE\u53D7\u9650\u4E0D\u7B49\u4E8E\u5931\u6548\uFF1B\u7ED3\u679C\u4EC5\u4EE3\u8868\u672C\u6B21\u68C0\u6D4B\u73AF\u5883\u3002</p></section>
|
|
302
|
+
<p id="message" role="status" aria-live="polite" hidden></p>
|
|
303
|
+
<section id="results" aria-label="\u68C0\u6D4B\u7ED3\u679C" tabindex="-1">
|
|
304
|
+
<nav id="filters" aria-label="\u72B6\u6001\u7B5B\u9009"></nav>
|
|
305
|
+
<div class="toolbar"><label class="search"><svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4.5 4.5"/></svg><input id="search" type="search" aria-label="\u641C\u7D22\u7AD9\u70B9\u540D\u79F0\u6216 URL" placeholder="\u641C\u7D22\u7AD9\u70B9\u540D\u79F0\u6216 URL"></label>
|
|
306
|
+
<select id="sort" aria-label="\u6392\u5E8F\u65B9\u5F0F"><option value="original">\u539F\u59CB\u987A\u5E8F</option><option value="severity">\u4F18\u5148\u67E5\u770B\u5F02\u5E38</option><option value="duration">\u8017\u65F6\u4ECE\u9AD8\u5230\u4F4E</option><option value="failures">\u8FDE\u7EED\u5931\u8D25\u4ECE\u591A\u5230\u5C11</option><option value="name">\u6309\u7AD9\u70B9\u540D\u79F0</option></select></div>
|
|
307
|
+
<div id="empty" class="empty">\u5C1A\u672A\u52A0\u8F7D\u62A5\u544A<button id="empty-import" type="button">\u9009\u62E9 JSON \u6587\u4EF6</button><span>\u4E5F\u53EF\u4EE5\u5C06\u62A5\u544A\u6587\u4EF6\u62D6\u5165\u6B64\u9875\u9762</span></div>
|
|
308
|
+
<table id="table" hidden><caption class="sr-only">\u7AD9\u70B9\u68C0\u6D4B\u7ED3\u679C\uFF0C\u5C55\u5F00\u8BE6\u60C5\u53EF\u67E5\u770B\u8DF3\u8F6C\u548C\u5386\u53F2\u8BB0\u5F55</caption><thead><tr><th>\u7AD9\u70B9</th><th>\u72B6\u6001</th><th class="technical">HTTP / \u539F\u56E0</th><th class="technical">\u8017\u65F6</th><th class="detail-heading">\u8BE6\u60C5</th></tr></thead><tbody id="rows"></tbody></table>
|
|
309
|
+
<footer class="pagination"><span id="result-count" role="status" aria-live="polite"></span><div><button id="previous" type="button" disabled>\u4E0A\u4E00\u9875</button><button id="next" type="button" disabled>\u4E0B\u4E00\u9875</button></div></footer>
|
|
310
|
+
</section>
|
|
311
|
+
</main>
|
|
312
|
+
<dialog id="import-dialog" aria-labelledby="import-title"><form method="dialog" class="dialog-heading"><h2 id="import-title">\u52A0\u8F7D\u68C0\u6D4B\u62A5\u544A</h2><button aria-label="\u5173\u95ED" type="submit">\xD7</button></form>
|
|
313
|
+
<p class="muted">\u9009\u62E9 meodp \u751F\u6210\u7684 report.json\uFF0C\u6216\u52A0\u8F7D\u6258\u7BA1\u7684\u6570\u636E\u3002</p>
|
|
314
|
+
<label class="file-label" for="file">\u9009\u62E9 JSON \u6587\u4EF6<input id="file" type="file" accept=".json,application/json"></label>
|
|
315
|
+
<form id="url-form"><label for="data-url">\u62A5\u544A\u6570\u636E URL</label><div class="url-controls"><input id="data-url" type="text" placeholder="./report.json \u6216 https://\u2026" required><button class="primary" type="submit">\u52A0\u8F7D</button></div></form>
|
|
316
|
+
<p class="note">\u8DE8\u57DF\u6570\u636E\u9700\u8981\u6E90\u7AD9\u5141\u8BB8 CORS\u3002\u672C\u5730\u6587\u4EF6\u4EC5\u5728\u6D4F\u89C8\u5668\u5185\u8BFB\u53D6\u3002</p></dialog>
|
|
317
|
+
<noscript><p>\u4EA4\u4E92\u62A5\u544A\u9700\u8981\u542F\u7528 JavaScript\u3002\u4E5F\u53EF\u67E5\u770B\u4E00\u540C\u751F\u6210\u7684 report.md \u6216 report.json\u3002</p></noscript>
|
|
318
|
+
<script id="meodp-data" type="application/json">${data}<\/script>
|
|
319
|
+
<script>${viewerScript.replace(/<\/script/gi, "<\\/script")}<\/script>
|
|
320
|
+
</body></html>
|
|
321
|
+
`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function escapeCell(value) {
|
|
325
|
+
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("|", "|").replaceAll("[", "[").replaceAll("]", "]").replaceAll("`", "`").replace(/[\r\n]+/g, " ");
|
|
326
|
+
}
|
|
327
|
+
function urlLink(url) {
|
|
328
|
+
const destination = url.replace(/[<>|\s]/g, (character) => encodeURIComponent(character));
|
|
329
|
+
return `[${escapeCell(url)}](<${destination}>)`;
|
|
330
|
+
}
|
|
331
|
+
function table(results) {
|
|
332
|
+
return [
|
|
333
|
+
"| Name | URL | Status | HTTP | Detail / final URL | Failed runs | Last success |",
|
|
334
|
+
"| --- | --- | --- | --- | --- | --- | --- |",
|
|
335
|
+
...results.map((result) => `| ${escapeCell(result.name ?? "")} | ${urlLink(result.url)} | ${result.status} | ${result.httpStatus ?? "\u2014"} | ${escapeCell(result.detail ?? (result.finalUrl !== result.url ? result.finalUrl : ""))} | ${result.consecutiveFailures} | ${result.lastSuccessAt ?? "\u2014"} |`)
|
|
336
|
+
].join("\n");
|
|
337
|
+
}
|
|
338
|
+
function formatReport(report, format = "markdown") {
|
|
339
|
+
if (format === "html")
|
|
340
|
+
return createReportHtml(report);
|
|
341
|
+
if (format === "json")
|
|
342
|
+
return `${JSON.stringify(report, null, 2)}
|
|
343
|
+
`;
|
|
344
|
+
const { summary } = report;
|
|
345
|
+
const review = report.results.filter((item) => item.status !== "reachable" || item.redirects.length > 0);
|
|
346
|
+
return [
|
|
347
|
+
"# Link availability report",
|
|
348
|
+
"",
|
|
349
|
+
`Observer: ${escapeCell(report.observer)} \xB7 Completed: ${report.completedAt}`,
|
|
350
|
+
"",
|
|
351
|
+
`Total: **${summary.total}** \xB7 Reachable: **${summary.reachable}** \xB7 Restricted: **${summary.restricted}** \xB7 Unavailable: **${summary.unavailable}** \xB7 Redirected: **${summary.redirected}** \xB7 Recovered: **${summary.recovered}**`,
|
|
352
|
+
"",
|
|
353
|
+
"> Results describe HTTP access from this observer at the recorded time. Restricted access is inconclusive. Failed runs are separate observations, not proof of continuous downtime. A successful response does not verify page content.",
|
|
354
|
+
"",
|
|
355
|
+
"## Needs review",
|
|
356
|
+
"",
|
|
357
|
+
review.length ? table(review) : "No links need review.",
|
|
358
|
+
"",
|
|
359
|
+
"## All results",
|
|
360
|
+
"",
|
|
361
|
+
table(report.results),
|
|
362
|
+
""
|
|
363
|
+
].join("\n");
|
|
364
|
+
}
|
|
365
|
+
async function writeReports(report, directory) {
|
|
366
|
+
await promises$1.mkdir(directory, { recursive: true });
|
|
367
|
+
const paths = { json: path.join(directory, "report.json"), markdown: path.join(directory, "report.md"), html: path.join(directory, "report.html") };
|
|
368
|
+
await promises$1.writeFile(paths.json, formatReport(report, "json"));
|
|
369
|
+
await promises$1.writeFile(paths.markdown, formatReport(report, "markdown"));
|
|
370
|
+
await promises$1.writeFile(paths.html, formatReport(report, "html"));
|
|
371
|
+
return paths;
|
|
372
|
+
}
|
|
373
|
+
async function writeReportSite(report, directory, options = {}) {
|
|
374
|
+
const html = createReportHtml(report, { dataUrl: report ? "./report.json" : void 0, ...options });
|
|
375
|
+
await promises$1.mkdir(directory, { recursive: true });
|
|
376
|
+
const index = path.join(directory, "index.html");
|
|
377
|
+
const json = report ? path.join(directory, "report.json") : void 0;
|
|
378
|
+
if (json)
|
|
379
|
+
await promises$1.writeFile(json, formatReport(report, "json"));
|
|
380
|
+
await promises$1.writeFile(index, html);
|
|
381
|
+
return { index, json };
|
|
382
|
+
}
|
|
383
|
+
async function readReport(path) {
|
|
384
|
+
let data;
|
|
385
|
+
try {
|
|
386
|
+
data = JSON.parse(await promises$1.readFile(path, "utf8"));
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
|
|
389
|
+
return void 0;
|
|
390
|
+
throw error;
|
|
391
|
+
}
|
|
392
|
+
return parseReport(data);
|
|
393
|
+
}
|
|
394
|
+
async function saveReport(report, path$1) {
|
|
395
|
+
await promises$1.mkdir(path.dirname(path$1), { recursive: true });
|
|
396
|
+
const temporary = `${path$1}.${node_crypto.randomUUID()}.tmp`;
|
|
397
|
+
await promises$1.writeFile(temporary, formatReport(report, "json"));
|
|
398
|
+
await promises$1.rename(temporary, path$1);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const unzip = node_util.promisify(node_zlib.gunzip);
|
|
402
|
+
class DocumentError extends Error {
|
|
403
|
+
constructor(message, retryable = false) {
|
|
404
|
+
super(message);
|
|
405
|
+
this.retryable = retryable;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
function discoveryOptions(options) {
|
|
409
|
+
const normalized = normalizeCheckOptions(options);
|
|
410
|
+
if (options.discover !== void 0 && typeof options.discover !== "boolean")
|
|
411
|
+
throw new TypeError("discover must be a boolean");
|
|
412
|
+
return {
|
|
413
|
+
...normalized,
|
|
414
|
+
maxUrls: integerOption(options.maxUrls, 1e4, "maxUrls", 1, 5e4),
|
|
415
|
+
maxSitemaps: integerOption(options.maxSitemaps, 100, "maxSitemaps", 1, 1e4),
|
|
416
|
+
maxSitemapBytes: integerOption(options.maxSitemapBytes, 10 * 1024 * 1024, "maxSitemapBytes", 1, 100 * 1024 * 1024)
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
async function readDocument(source, options, allowMissing = false) {
|
|
420
|
+
for (let attempt = 0; ; attempt++) {
|
|
421
|
+
try {
|
|
422
|
+
let url = source;
|
|
423
|
+
const visited = /* @__PURE__ */ new Set();
|
|
424
|
+
for (; ; ) {
|
|
425
|
+
visited.add(url);
|
|
426
|
+
const response = await fetch(url, {
|
|
427
|
+
redirect: "manual",
|
|
428
|
+
signal: AbortSignal.timeout(options.timeoutMs)
|
|
429
|
+
});
|
|
430
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
431
|
+
await response.body?.cancel();
|
|
432
|
+
const location = response.headers.get("location");
|
|
433
|
+
let next;
|
|
434
|
+
try {
|
|
435
|
+
if (!location)
|
|
436
|
+
throw new Error("Missing location");
|
|
437
|
+
next = normalizeUrl(new URL(location, url).href);
|
|
438
|
+
} catch {
|
|
439
|
+
throw new DocumentError(`Invalid sitemap/robots redirect from ${url}`);
|
|
440
|
+
}
|
|
441
|
+
if (visited.has(next) || visited.size > options.maxRedirects)
|
|
442
|
+
throw new DocumentError(`Sitemap/robots redirect loop or limit exceeded: ${url}`);
|
|
443
|
+
url = next;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (!response.ok) {
|
|
447
|
+
await response.body?.cancel();
|
|
448
|
+
if (allowMissing && [404, 410].includes(response.status))
|
|
449
|
+
return void 0;
|
|
450
|
+
throw new DocumentError(`Unable to read ${url}: HTTP ${response.status}`, response.status >= 500);
|
|
451
|
+
}
|
|
452
|
+
const chunks = [];
|
|
453
|
+
let size = 0;
|
|
454
|
+
if (response.body) {
|
|
455
|
+
const reader = response.body.getReader();
|
|
456
|
+
try {
|
|
457
|
+
for (; ; ) {
|
|
458
|
+
const { done, value } = await reader.read();
|
|
459
|
+
if (done)
|
|
460
|
+
break;
|
|
461
|
+
size += value.byteLength;
|
|
462
|
+
if (size > options.maxSitemapBytes)
|
|
463
|
+
throw new DocumentError(`Discovery document exceeds maxSitemapBytes: ${url}`);
|
|
464
|
+
chunks.push(value);
|
|
465
|
+
}
|
|
466
|
+
} finally {
|
|
467
|
+
await reader.cancel().catch(() => {
|
|
468
|
+
});
|
|
469
|
+
reader.releaseLock();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
let body = node_buffer.Buffer.concat(chunks);
|
|
473
|
+
if (body[0] === 31 && body[1] === 139) {
|
|
474
|
+
try {
|
|
475
|
+
body = await unzip(body, { maxOutputLength: options.maxSitemapBytes });
|
|
476
|
+
} catch {
|
|
477
|
+
throw new DocumentError(`Invalid gzip or decompressed document exceeds maxSitemapBytes: ${url}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return { url, text: body.toString("utf8") };
|
|
481
|
+
}
|
|
482
|
+
} catch (error) {
|
|
483
|
+
if (attempt >= options.retries || error instanceof DocumentError && !error.retryable)
|
|
484
|
+
throw new Error(`Sitemap discovery failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
485
|
+
await promises.setTimeout(500 * 2 ** attempt);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function parseLocations(text, source) {
|
|
490
|
+
if (/<!DOCTYPE|<!ENTITY/i.test(text) || fastXmlParser.XMLValidator.validate(text) !== true)
|
|
491
|
+
throw new Error(`Invalid sitemap XML: ${source}`);
|
|
492
|
+
const parser = new fastXmlParser.XMLParser({
|
|
493
|
+
ignoreAttributes: true,
|
|
494
|
+
removeNSPrefix: true,
|
|
495
|
+
parseTagValue: false,
|
|
496
|
+
isArray: (name) => name === "url" || name === "sitemap"
|
|
497
|
+
});
|
|
498
|
+
const document = parser.parse(text);
|
|
499
|
+
const roots = Object.keys(document).filter((key) => !key.startsWith("?"));
|
|
500
|
+
if (roots.length !== 1 || !["urlset", "sitemapindex"].includes(roots[0]))
|
|
501
|
+
throw new Error(`Expected a sitemap urlset or sitemapindex: ${source}`);
|
|
502
|
+
const isIndex = roots[0] === "sitemapindex";
|
|
503
|
+
const root = document[roots[0]];
|
|
504
|
+
if (root?.[isIndex ? "url" : "sitemap"])
|
|
505
|
+
throw new Error(`Mixed sitemap index and page entries: ${source}`);
|
|
506
|
+
const entries = root?.[isIndex ? "sitemap" : "url"] ?? [];
|
|
507
|
+
const urls = entries.map((entry) => {
|
|
508
|
+
if (!entry || typeof entry !== "object" || !("loc" in entry) || typeof entry.loc !== "string" || !entry.loc.trim())
|
|
509
|
+
throw new Error(`Missing or invalid sitemap loc: ${source}`);
|
|
510
|
+
try {
|
|
511
|
+
return normalizeUrl(new URL(entry.loc.trim(), source).href);
|
|
512
|
+
} catch {
|
|
513
|
+
throw new Error(`Invalid HTTP(S) URL in sitemap: ${source}`);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
return { isIndex, urls };
|
|
517
|
+
}
|
|
518
|
+
async function readSitemapUrls(source, options = {}) {
|
|
519
|
+
const input = normalizeUrl(source.trim());
|
|
520
|
+
const config = discoveryOptions(options);
|
|
521
|
+
let pending = [input];
|
|
522
|
+
if (options.discover) {
|
|
523
|
+
const robots = await readDocument(new URL("/robots.txt", input).href, config, true);
|
|
524
|
+
const declared = robots?.text.split(/\r?\n/).flatMap((line) => {
|
|
525
|
+
const match = line.match(/^\s*sitemap\s*:\s*(\S+)/i);
|
|
526
|
+
return match ? [normalizeUrl(new URL(match[1], robots.url).href)] : [];
|
|
527
|
+
}) ?? [];
|
|
528
|
+
pending = declared.length ? declared : [new URL("/sitemap.xml", input).href];
|
|
529
|
+
}
|
|
530
|
+
const queued = new Set(pending);
|
|
531
|
+
pending = [...queued];
|
|
532
|
+
const visited = /* @__PURE__ */ new Set();
|
|
533
|
+
const pages = /* @__PURE__ */ new Set();
|
|
534
|
+
let documentCount = 0;
|
|
535
|
+
for (let cursor = 0; cursor < pending.length; cursor++) {
|
|
536
|
+
const url = pending[cursor];
|
|
537
|
+
if (visited.has(url))
|
|
538
|
+
continue;
|
|
539
|
+
if (++documentCount > config.maxSitemaps)
|
|
540
|
+
throw new Error("Sitemap discovery exceeds maxSitemaps; no pages were checked");
|
|
541
|
+
visited.add(url);
|
|
542
|
+
const document = await readDocument(url, config);
|
|
543
|
+
if (!document)
|
|
544
|
+
throw new Error(`Unable to read sitemap: ${url}`);
|
|
545
|
+
visited.add(document.url);
|
|
546
|
+
const { isIndex, urls } = parseLocations(document.text, document.url);
|
|
547
|
+
for (const location of urls) {
|
|
548
|
+
if (isIndex) {
|
|
549
|
+
if (!queued.has(location) && !visited.has(location)) {
|
|
550
|
+
queued.add(location);
|
|
551
|
+
pending.push(location);
|
|
552
|
+
}
|
|
553
|
+
} else {
|
|
554
|
+
pages.add(location);
|
|
555
|
+
if (pages.size > config.maxUrls)
|
|
556
|
+
throw new Error("Sitemap discovery exceeds maxUrls; no pages were checked");
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (!pages.size)
|
|
561
|
+
throw new Error("Sitemap discovery found no page URLs; no pages were checked");
|
|
562
|
+
return [...pages];
|
|
563
|
+
}
|
|
564
|
+
async function checkSitemap(source, options = {}) {
|
|
565
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
566
|
+
const urls = await readSitemapUrls(source, options);
|
|
567
|
+
const report = await checkLinks(urls, options);
|
|
568
|
+
return { ...report, startedAt };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
exports.checkLinks = checkLinks;
|
|
572
|
+
exports.checkSitemap = checkSitemap;
|
|
573
|
+
exports.formatReport = formatReport;
|
|
574
|
+
exports.normalizeTargets = normalizeTargets;
|
|
575
|
+
exports.parseReport = parseReport;
|
|
576
|
+
exports.readReport = readReport;
|
|
577
|
+
exports.readSitemapUrls = readSitemapUrls;
|
|
578
|
+
exports.saveReport = saveReport;
|
|
579
|
+
exports.writeReportSite = writeReportSite;
|
|
580
|
+
exports.writeReports = writeReports;
|