@elitedcs/ghl-mcp 3.56.0 → 3.58.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/CHANGELOG.md +135 -0
- package/README.md +35 -1
- package/dist/index.js +1962 -1612
- package/package.json +2 -2
- package/skills/clone-site/README.md +42 -0
- package/skills/clone-site/SKILL.md +247 -0
- package/skills/clone-site/references/facts-and-substitution.md +96 -0
- package/skills/clone-site/references/hosting-verify-and-audit.md +101 -0
- package/skills/clone-site/references/rights-and-lanes.md +72 -0
- package/skills/clone-site/scripts/audit.mjs +364 -0
- package/skills/clone-site/scripts/extract-design.mjs +372 -0
- package/skills/clone-site/scripts/lib.mjs +262 -0
- package/skills/clone-site/scripts/mirror.mjs +489 -0
- package/skills/clone-site/scripts/repair.mjs +113 -0
- package/skills/clone-site/scripts/substitute.mjs +418 -0
- package/skills/clone-site/scripts/verify.mjs +193 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mirror.mjs — deterministic site mirror. Copies bytes; never regenerates.
|
|
4
|
+
*
|
|
5
|
+
* GUARDRAIL: --rights is REQUIRED. Without it the script prints the step-0
|
|
6
|
+
* question and exits 2. With --rights none it refuses to copy bytes and points
|
|
7
|
+
* at the style lane (extract-design.mjs), exit 3. The declaration is written
|
|
8
|
+
* into run-report.json, which every downstream script reads.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node mirror.mjs --url <url> --out <dir> --rights <own|client-authorized|written-permission|none>
|
|
12
|
+
* [--declared "free-text note"] [--max-mb 25] [--no-external-media]
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import {
|
|
18
|
+
parseArgs, fetchRaw, fetchText, headSize, mapLimit, ensureDir, ext, isAssetUrl,
|
|
19
|
+
humanBytes, writeJson, nowIso, RIGHTS_CHOICES, RIGHTS_QUESTION, siteDir, reportsDir,
|
|
20
|
+
} from "./lib.mjs";
|
|
21
|
+
|
|
22
|
+
const args = parseArgs(process.argv.slice(2));
|
|
23
|
+
|
|
24
|
+
if (!args.url || !args.out) {
|
|
25
|
+
console.error("usage: mirror.mjs --url <url> --out <dir> --rights <own|client-authorized|written-permission|none>");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
if (!args.rights || args.rights === true) {
|
|
29
|
+
console.error(RIGHTS_QUESTION);
|
|
30
|
+
process.exit(2);
|
|
31
|
+
}
|
|
32
|
+
if (!RIGHTS_CHOICES.includes(args.rights)) {
|
|
33
|
+
console.error(`Unknown --rights "${args.rights}". One of: ${RIGHTS_CHOICES.join(", ")}`);
|
|
34
|
+
process.exit(2);
|
|
35
|
+
}
|
|
36
|
+
if (args.rights === "none") {
|
|
37
|
+
console.error(
|
|
38
|
+
[
|
|
39
|
+
"RIGHTS = none — full byte-for-byte cloning is not the right lane here.",
|
|
40
|
+
"",
|
|
41
|
+
"Run the style lane instead. It extracts the page's real design system",
|
|
42
|
+
"(palette, type scale, spacing, section structure) so the rebuild matches",
|
|
43
|
+
"the original's quality, and you supply fresh copy and licensed media:",
|
|
44
|
+
"",
|
|
45
|
+
` node extract-design.mjs --url "${args.url}" --out <dir>`,
|
|
46
|
+
"",
|
|
47
|
+
"No page bytes, images, video, or copy are copied in that lane.",
|
|
48
|
+
].join("\n")
|
|
49
|
+
);
|
|
50
|
+
process.exit(3);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const SRC = args.url;
|
|
54
|
+
const RUN_DIR = path.resolve(String(args.out));
|
|
55
|
+
// site/ is the deployable folder; reports/ never ships with it.
|
|
56
|
+
const OUT = siteDir(RUN_DIR);
|
|
57
|
+
const REPORTS = reportsDir(RUN_DIR);
|
|
58
|
+
const MAX_BYTES = Math.round(Number(args["max-mb"] || 25) * 1024 * 1024);
|
|
59
|
+
const REHOST_EXTERNAL = args["no-external-media"] !== true;
|
|
60
|
+
|
|
61
|
+
const srcUrl = new URL(SRC);
|
|
62
|
+
const ORIGIN = srcUrl.origin;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Cross-origin re-hosting covers MEDIA only. Third-party JS/CSS (analytics
|
|
66
|
+
* beacons, chat widgets, payment SDKs) stays external: re-hosting a loader
|
|
67
|
+
* breaks it, copies someone else's code, and hides it from the audit — those
|
|
68
|
+
* URLs must stay visible in the HTML so audit.mjs can flag them as MUST REPOINT.
|
|
69
|
+
*/
|
|
70
|
+
const REHOST_EXT = [
|
|
71
|
+
".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".ico", ".avif", ".bmp",
|
|
72
|
+
".mp4", ".webm", ".mov", ".m4v", ".mp3", ".wav", ".ogg",
|
|
73
|
+
".woff", ".woff2", ".ttf", ".otf", ".eot", ".pdf",
|
|
74
|
+
];
|
|
75
|
+
const isRehostable = (u) => REHOST_EXT.includes(ext(u));
|
|
76
|
+
|
|
77
|
+
fs.mkdirSync(OUT, { recursive: true });
|
|
78
|
+
fs.mkdirSync(REPORTS, { recursive: true });
|
|
79
|
+
|
|
80
|
+
/** absolute url -> { local, bytes, contentType, source, status } */
|
|
81
|
+
const downloaded = new Map();
|
|
82
|
+
const skipped = [];
|
|
83
|
+
const oversized = [];
|
|
84
|
+
const failed = [];
|
|
85
|
+
const externalRefsLeft = new Set();
|
|
86
|
+
|
|
87
|
+
function localPathFor(absUrl, kind) {
|
|
88
|
+
const u = new URL(absUrl);
|
|
89
|
+
if (u.origin === ORIGIN) {
|
|
90
|
+
const p = u.pathname.replace(/^\/+/, "");
|
|
91
|
+
return p || "index-asset";
|
|
92
|
+
}
|
|
93
|
+
// External CDN asset — re-host under media/<host-slug>/<path>
|
|
94
|
+
const hostSlug = u.hostname.replace(/[^a-z0-9]+/gi, "-").toLowerCase();
|
|
95
|
+
const p = u.pathname.replace(/^\/+/, "") || "asset";
|
|
96
|
+
return path.posix.join("media", hostSlug, p);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function download(absUrl, kind) {
|
|
100
|
+
if (downloaded.has(absUrl)) return downloaded.get(absUrl);
|
|
101
|
+
const e = ext(absUrl);
|
|
102
|
+
const head = await headSize(absUrl);
|
|
103
|
+
if (head.bytes !== null && head.bytes > MAX_BYTES) {
|
|
104
|
+
oversized.push({ url: absUrl, bytes: head.bytes, contentType: head.contentType });
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const res = await fetchRaw(absUrl);
|
|
108
|
+
if (!res.ok || res.buf.length === 0) {
|
|
109
|
+
failed.push({ url: absUrl, status: res.status, error: res.error || null });
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
if (res.buf.length > MAX_BYTES) {
|
|
113
|
+
oversized.push({ url: absUrl, bytes: res.buf.length, contentType: res.contentType });
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
// A page-shaped body where an image belongs = the origin soft-404'd.
|
|
117
|
+
const looksHtml = /^\s*(<!doctype html|<html)/i.test(res.buf.slice(0, 200).toString("utf8"));
|
|
118
|
+
const wantsHtml = e === "" || e === ".html";
|
|
119
|
+
if (looksHtml && !wantsHtml) {
|
|
120
|
+
skipped.push({ url: absUrl, reason: "origin returned HTML where an asset was expected (asset does not exist / belongs to another tenant)" });
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const local = localPathFor(absUrl, kind);
|
|
124
|
+
const dest = path.join(OUT, local);
|
|
125
|
+
ensureDir(dest);
|
|
126
|
+
fs.writeFileSync(dest, res.buf);
|
|
127
|
+
const rec = { local, bytes: res.buf.length, contentType: res.contentType, kind, status: res.status };
|
|
128
|
+
downloaded.set(absUrl, rec);
|
|
129
|
+
return rec;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function abs(ref, base) {
|
|
133
|
+
try {
|
|
134
|
+
return new URL(ref, base).toString();
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function skipRef(ref) {
|
|
141
|
+
return (
|
|
142
|
+
!ref ||
|
|
143
|
+
ref.startsWith("data:") ||
|
|
144
|
+
ref.startsWith("#") ||
|
|
145
|
+
ref.startsWith("mailto:") ||
|
|
146
|
+
ref.startsWith("tel:") ||
|
|
147
|
+
ref.startsWith("javascript:") ||
|
|
148
|
+
ref.startsWith("blob:")
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* URLs buried in JSON-encoded attributes and inline JSON — the miss that leaves
|
|
154
|
+
* a page-builder hero blank.
|
|
155
|
+
*
|
|
156
|
+
* Elementor background slideshows, WP block attributes, __NEXT_DATA__ and
|
|
157
|
+
* Shopify section JSON all store media as escaped JSON inside the markup:
|
|
158
|
+
*
|
|
159
|
+
* data-settings="{"url":"https:\/\/host\/img.jpg"}"
|
|
160
|
+
*
|
|
161
|
+
* The slashes are backslash-escaped and the quotes are entities, so neither the
|
|
162
|
+
* src/href scan nor a plain URL regex finds them. They are never downloaded —
|
|
163
|
+
* and once the domain is rebranded those URLs point at a domain that does not
|
|
164
|
+
* exist, so the hero renders empty. Live-caught 2026-07-30.
|
|
165
|
+
*
|
|
166
|
+
* Returns the raw matched text (escaping intact, so it can be rewritten in
|
|
167
|
+
* place) alongside the real absolute URL.
|
|
168
|
+
*/
|
|
169
|
+
function escapedJsonRefs(text) {
|
|
170
|
+
const mediaExt = "png|jpe?g|webp|gif|svg|avif|ico|bmp|mp4|webm|mov|m4v|mp3|wav|ogg|pdf";
|
|
171
|
+
const out = [];
|
|
172
|
+
const re = new RegExp(`https?:(?:\\\\?/){2}[^\\s"'<>]+?\\.(?:${mediaExt})`, "gi");
|
|
173
|
+
for (const m of text.matchAll(re)) {
|
|
174
|
+
const raw = m[0];
|
|
175
|
+
if (!raw.includes("\\/")) continue; // plain URLs are already handled elsewhere
|
|
176
|
+
out.push({ raw, abs: raw.replace(/\\\//g, "/") });
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Refs in an HTML document: src, href, poster, srcset, inline url(). */
|
|
182
|
+
function htmlRefs(html) {
|
|
183
|
+
const refs = new Set();
|
|
184
|
+
for (const m of html.matchAll(/(?:src|href|poster|data-src|content)=["']([^"']+)["']/gi)) refs.add(m[1]);
|
|
185
|
+
for (const m of html.matchAll(/srcset=["']([^"']+)["']/gi)) {
|
|
186
|
+
for (const part of m[1].split(",")) {
|
|
187
|
+
const u = part.trim().split(/\s+/)[0];
|
|
188
|
+
if (u) refs.add(u);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (const m of html.matchAll(/url\(([^)]+)\)/gi)) refs.add(m[1].trim().replace(/^['"]|['"]$/g, ""));
|
|
192
|
+
return [...refs];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Refs inside a stylesheet. */
|
|
196
|
+
function cssRefs(css) {
|
|
197
|
+
const refs = new Set();
|
|
198
|
+
for (const m of css.matchAll(/url\(([^)]+)\)/gi)) refs.add(m[1].trim().replace(/^['"]|['"]$/g, ""));
|
|
199
|
+
for (const m of css.matchAll(/@import\s+["']([^"']+)["']/gi)) refs.add(m[1]);
|
|
200
|
+
return [...refs];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Refs inside a JS bundle — the two forms HTML-only scanners miss:
|
|
205
|
+
* 1. full CDN URLs baked into the bundle
|
|
206
|
+
* 2. ROOT-RELATIVE paths ("/logo.png") — the one that caused 6 broken images
|
|
207
|
+
* in the 2026-07-28 proving run.
|
|
208
|
+
*/
|
|
209
|
+
function jsRefs(js) {
|
|
210
|
+
const refs = new Set();
|
|
211
|
+
const mediaExt = "png|jpe?g|webp|gif|svg|avif|ico|bmp|mp4|webm|mov|m4v|mp3|wav|ogg|woff2?|ttf|otf|pdf";
|
|
212
|
+
for (const m of js.matchAll(new RegExp(`https?://[A-Za-z0-9._~:/?#\\[\\]@!$&'*+,;%=-]+?\\.(?:${mediaExt})`, "gi"))) {
|
|
213
|
+
refs.add(m[0]);
|
|
214
|
+
}
|
|
215
|
+
for (const m of js.matchAll(new RegExp(`["'\`](/[A-Za-z0-9._/-]+?\\.(?:${mediaExt}))["'\`]`, "gi"))) {
|
|
216
|
+
refs.add(m[1]);
|
|
217
|
+
}
|
|
218
|
+
return [...refs];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Replace every occurrence of `from` with `to`, literal (no regex surprises). */
|
|
222
|
+
function replaceAllLiteral(text, from, to) {
|
|
223
|
+
if (!from || from === to) return text;
|
|
224
|
+
return text.split(from).join(to);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const log = (...m) => console.error(...m);
|
|
228
|
+
|
|
229
|
+
/** Same-origin PAGE links (not assets, not feeds/admin endpoints). */
|
|
230
|
+
function pageLinks(html, baseUrl) {
|
|
231
|
+
const out = new Set();
|
|
232
|
+
for (const m of html.matchAll(/href=["']([^"'#]+)["']/gi)) {
|
|
233
|
+
const href = m[1].trim();
|
|
234
|
+
if (/^(mailto:|tel:|javascript:|data:)/i.test(href)) continue;
|
|
235
|
+
let u;
|
|
236
|
+
try { u = new URL(href, baseUrl); } catch { continue; }
|
|
237
|
+
if (u.origin !== ORIGIN) continue;
|
|
238
|
+
const p = u.pathname;
|
|
239
|
+
if (/\.(png|jpe?g|webp|gif|svg|ico|avif|css|js|mjs|pdf|zip|xml|json|mp4|webm|mov|mp3|woff2?|ttf|otf)$/i.test(p)) continue;
|
|
240
|
+
if (/^\/wp-(json|admin|login|content|includes)|\/feed\/?$|\/comments\/feed/i.test(p)) continue;
|
|
241
|
+
out.add(canonPage(u.origin + p));
|
|
242
|
+
}
|
|
243
|
+
return [...out];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Canonical page key so "/x" and "/x/" (and the bare origin) are one page. */
|
|
247
|
+
function canonPage(u) {
|
|
248
|
+
const x = new URL(u);
|
|
249
|
+
x.hash = ""; x.search = "";
|
|
250
|
+
const p = x.pathname.replace(/\/+$/, "");
|
|
251
|
+
return x.origin + (p || "/");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** "/contact/" -> "contact/index.html"; "/" -> "index.html". */
|
|
255
|
+
function pageSavePath(pageUrl) {
|
|
256
|
+
const p = new URL(pageUrl).pathname.replace(/^\/+|\/+$/g, "");
|
|
257
|
+
return p ? path.posix.join(p, "index.html") : "index.html";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const processedCss = new Set();
|
|
261
|
+
const processedJs = new Set();
|
|
262
|
+
|
|
263
|
+
/** Mirror ONE page: assets, escaped JSON, origin-localisation, write to disk. */
|
|
264
|
+
async function mirrorPage(pageUrl) {
|
|
265
|
+
const page = await fetchText(pageUrl);
|
|
266
|
+
if (!page.ok || !page.text) {
|
|
267
|
+
failed.push({ url: pageUrl, status: page.status, error: "page fetch failed" });
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
let html = page.text;
|
|
271
|
+
|
|
272
|
+
// pass 1 — refs in this page's HTML
|
|
273
|
+
const pass1 = [];
|
|
274
|
+
for (const ref of htmlRefs(html)) {
|
|
275
|
+
if (skipRef(ref)) continue;
|
|
276
|
+
const a = abs(ref, pageUrl);
|
|
277
|
+
if (!a) continue;
|
|
278
|
+
const u = new URL(a);
|
|
279
|
+
if (!isAssetUrl(a)) {
|
|
280
|
+
if (u.origin !== ORIGIN) externalRefsLeft.add(a);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (u.origin !== ORIGIN && (!REHOST_EXTERNAL || !isRehostable(a))) {
|
|
284
|
+
externalRefsLeft.add(a);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
pass1.push({ ref, abs: a });
|
|
288
|
+
}
|
|
289
|
+
await mapLimit(pass1, 6, async (r) => download(r.abs, "html"));
|
|
290
|
+
|
|
291
|
+
// pass 2 — refs inside newly downloaded CSS
|
|
292
|
+
for (const [cssAbs, rec] of [...downloaded.entries()].filter(([u]) => ext(u) === ".css")) {
|
|
293
|
+
if (processedCss.has(cssAbs)) continue;
|
|
294
|
+
processedCss.add(cssAbs);
|
|
295
|
+
const file = path.join(OUT, rec.local);
|
|
296
|
+
let css = fs.readFileSync(file, "utf8");
|
|
297
|
+
const targets = [];
|
|
298
|
+
for (const ref of cssRefs(css).filter((r) => !skipRef(r))) {
|
|
299
|
+
const a = abs(ref, cssAbs);
|
|
300
|
+
if (!a || !isAssetUrl(a)) continue;
|
|
301
|
+
const u = new URL(a);
|
|
302
|
+
if (u.origin !== ORIGIN && (!REHOST_EXTERNAL || !isRehostable(a))) { externalRefsLeft.add(a); continue; }
|
|
303
|
+
targets.push({ ref, abs: a });
|
|
304
|
+
}
|
|
305
|
+
await mapLimit(targets, 6, async (t) => download(t.abs, "css"));
|
|
306
|
+
for (const t of targets) {
|
|
307
|
+
const got = downloaded.get(t.abs);
|
|
308
|
+
if (got) css = replaceAllLiteral(css, t.ref, "/" + got.local);
|
|
309
|
+
}
|
|
310
|
+
fs.writeFileSync(file, css);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// pass 3 — refs inside newly downloaded JS bundles
|
|
314
|
+
for (const [jsAbs, rec] of [...downloaded.entries()].filter(([u]) => [".js", ".mjs"].includes(ext(u)))) {
|
|
315
|
+
if (processedJs.has(jsAbs)) continue;
|
|
316
|
+
processedJs.add(jsAbs);
|
|
317
|
+
const file = path.join(OUT, rec.local);
|
|
318
|
+
let js = fs.readFileSync(file, "utf8");
|
|
319
|
+
const targets = [];
|
|
320
|
+
for (const ref of jsRefs(js)) {
|
|
321
|
+
const a = abs(ref, ORIGIN + "/");
|
|
322
|
+
if (!a) continue;
|
|
323
|
+
const u = new URL(a);
|
|
324
|
+
if (u.origin !== ORIGIN && (!REHOST_EXTERNAL || !isRehostable(a))) { externalRefsLeft.add(a); continue; }
|
|
325
|
+
targets.push({ ref, abs: a });
|
|
326
|
+
}
|
|
327
|
+
await mapLimit(targets, 6, async (t) => download(t.abs, "js"));
|
|
328
|
+
for (const t of targets) {
|
|
329
|
+
const got = downloaded.get(t.abs);
|
|
330
|
+
if (got && t.ref !== "/" + got.local) js = replaceAllLiteral(js, t.ref, "/" + got.local);
|
|
331
|
+
}
|
|
332
|
+
fs.writeFileSync(file, js);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// pass 4 — URLs hidden in escaped JSON attributes
|
|
336
|
+
{
|
|
337
|
+
const unique = new Map();
|
|
338
|
+
for (const r of escapedJsonRefs(html)) if (!unique.has(r.abs)) unique.set(r.abs, r);
|
|
339
|
+
const targets = [];
|
|
340
|
+
for (const [absUrl, r] of unique) {
|
|
341
|
+
const u = new URL(absUrl);
|
|
342
|
+
if (u.origin !== ORIGIN && (!REHOST_EXTERNAL || !isRehostable(absUrl))) { externalRefsLeft.add(absUrl); continue; }
|
|
343
|
+
targets.push(r);
|
|
344
|
+
}
|
|
345
|
+
await mapLimit(targets, 6, async (t) => download(t.abs, "json-attr"));
|
|
346
|
+
for (const t of targets) {
|
|
347
|
+
const got = downloaded.get(t.abs);
|
|
348
|
+
if (got) html = replaceAllLiteral(html, t.raw, ("/" + got.local).replace(/\//g, "\\/"));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// localise same-origin ASSET urls (plain + escaped), incl. inline JS config
|
|
353
|
+
{
|
|
354
|
+
const ASSET_DIR = /\/(?:wp-content|wp-includes|assets|static|_next|dist|build|media|uploads|files|cdn)\//i;
|
|
355
|
+
const originForms = [...new Set([ORIGIN, ORIGIN.replace(/^https:/, "http:"), ORIGIN.replace(/^http:/, "https:")])];
|
|
356
|
+
for (const form of originForms) {
|
|
357
|
+
for (const needle of [form, form.replace(/\//g, "\\/")]) {
|
|
358
|
+
let idx = html.indexOf(needle);
|
|
359
|
+
while (idx !== -1) {
|
|
360
|
+
const after = html.slice(idx + needle.length, idx + needle.length + 300);
|
|
361
|
+
const stop = after.search(/["'\s<>)]|\\"/);
|
|
362
|
+
const tail = (stop === -1 ? after : after.slice(0, stop)).replace(/\\\//g, "/");
|
|
363
|
+
if (tail && (ASSET_DIR.test(tail) || isAssetUrl(tail))) {
|
|
364
|
+
html = html.slice(0, idx) + html.slice(idx + needle.length);
|
|
365
|
+
idx = html.indexOf(needle, idx);
|
|
366
|
+
} else {
|
|
367
|
+
idx = html.indexOf(needle, idx + needle.length);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// rewrite this page's own asset refs to local paths
|
|
375
|
+
for (const { ref, abs: a } of pass1) {
|
|
376
|
+
const got = downloaded.get(a);
|
|
377
|
+
if (!got) continue;
|
|
378
|
+
const localRef = "/" + got.local;
|
|
379
|
+
if (ref !== localRef) html = replaceAllLiteral(html, ref, localRef);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// <a href> to our own pages -> root-relative, so the nav works locally AND
|
|
383
|
+
// after deploy. canonical/og:url keep their host so the rebrand can set the
|
|
384
|
+
// client's real domain.
|
|
385
|
+
const hostPattern = ORIGIN.replace(/^https?:\/\//, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
386
|
+
html = html.replace(new RegExp(`(<a\\b[^>]*?href=["'])https?://(?:www\\.)?${hostPattern}`, "gi"), "$1");
|
|
387
|
+
|
|
388
|
+
const savedAs = pageSavePath(pageUrl);
|
|
389
|
+
const dest = path.join(OUT, savedAs);
|
|
390
|
+
ensureDir(dest);
|
|
391
|
+
fs.writeFileSync(dest, html);
|
|
392
|
+
return { savedAs, links: pageLinks(page.text, pageUrl) };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function main() {
|
|
396
|
+
const CRAWL = args.crawl === true || args.pages !== undefined || args.depth !== undefined;
|
|
397
|
+
const MAX_PAGES = Number(args["max-pages"] || args.pages || 25);
|
|
398
|
+
const DEPTH = Number(args.depth || 1);
|
|
399
|
+
|
|
400
|
+
const queue = [{ url: SRC, depth: 0 }];
|
|
401
|
+
const seen = new Set([canonPage(SRC)]);
|
|
402
|
+
const pages = [];
|
|
403
|
+
|
|
404
|
+
while (queue.length && pages.length < (CRAWL ? MAX_PAGES : 1)) {
|
|
405
|
+
const { url, depth } = queue.shift();
|
|
406
|
+
log(`→ ${url}`);
|
|
407
|
+
const res = await mirrorPage(url);
|
|
408
|
+
if (!res) continue;
|
|
409
|
+
pages.push({ url, savedAs: res.savedAs });
|
|
410
|
+
if (!CRAWL || depth >= DEPTH) continue;
|
|
411
|
+
for (const link of res.links) {
|
|
412
|
+
const key = canonPage(link);
|
|
413
|
+
if (seen.has(key) || seen.size >= MAX_PAGES * 4) continue;
|
|
414
|
+
seen.add(key);
|
|
415
|
+
queue.push({ url: link, depth: depth + 1 });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (pages.length === 0) {
|
|
420
|
+
console.error(`FAILED to fetch ${SRC}. Nothing was written — do not reconstruct the page from memory.`);
|
|
421
|
+
process.exit(4);
|
|
422
|
+
}
|
|
423
|
+
// Guarantee a root document even when the entry URL was a sub-page.
|
|
424
|
+
if (!fs.existsSync(path.join(OUT, "index.html"))) {
|
|
425
|
+
fs.copyFileSync(path.join(OUT, pages[0].savedAs), path.join(OUT, "index.html"));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const byType = {};
|
|
429
|
+
let totalBytes = 0;
|
|
430
|
+
for (const rec of downloaded.values()) {
|
|
431
|
+
const e = ext(rec.local) || "(none)";
|
|
432
|
+
byType[e] = byType[e] || { count: 0, bytes: 0 };
|
|
433
|
+
byType[e].count++; byType[e].bytes += rec.bytes; totalBytes += rec.bytes;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const report = {
|
|
437
|
+
tool: "clone-site/mirror.mjs",
|
|
438
|
+
generatedAt: nowIso(),
|
|
439
|
+
rights: {
|
|
440
|
+
declaration: args.rights,
|
|
441
|
+
note: typeof args.declared === "string" ? args.declared : null,
|
|
442
|
+
recordedBy: "user declaration at step 0 — this tool does not verify ownership",
|
|
443
|
+
},
|
|
444
|
+
source: { url: SRC, origin: ORIGIN },
|
|
445
|
+
output: { runDir: RUN_DIR, siteDir: OUT, reportsDir: REPORTS, entry: "index.html" },
|
|
446
|
+
pages,
|
|
447
|
+
crawl: { enabled: CRAWL, depth: CRAWL ? DEPTH : 0, maxPages: CRAWL ? MAX_PAGES : 1, discovered: seen.size },
|
|
448
|
+
inventory: { files: downloaded.size + pages.length, totalBytes, totalHuman: humanBytes(totalBytes), byType },
|
|
449
|
+
oversized,
|
|
450
|
+
skippedForeignOrMissing: skipped,
|
|
451
|
+
failed,
|
|
452
|
+
externalRefsLeft: [...externalRefsLeft].sort(),
|
|
453
|
+
assets: [...downloaded.entries()].map(([url, rec]) => ({ url, ...rec })),
|
|
454
|
+
};
|
|
455
|
+
writeJson(path.join(REPORTS, "run-report.json"), report);
|
|
456
|
+
|
|
457
|
+
const lines = [];
|
|
458
|
+
lines.push("");
|
|
459
|
+
lines.push(`MIRROR COMPLETE ${SRC}`);
|
|
460
|
+
lines.push(` rights declared : ${args.rights}${args.declared && args.declared !== true ? ` — "${args.declared}"` : ""}`);
|
|
461
|
+
lines.push(` pages mirrored : ${pages.length}${CRAWL ? ` (crawl depth ${DEPTH}, cap ${MAX_PAGES}, ${seen.size} discovered)` : " — single page. Add --crawl to follow the site's own navigation."}`);
|
|
462
|
+
for (const p of pages.slice(0, 15)) lines.push(` ${p.savedAs}`);
|
|
463
|
+
if (pages.length > 15) lines.push(` …and ${pages.length - 15} more`);
|
|
464
|
+
lines.push(` deployable site : ${OUT}`);
|
|
465
|
+
lines.push(` reports (never deploy these): ${REPORTS}/`);
|
|
466
|
+
lines.push(` files stored : ${downloaded.size + pages.length} (${humanBytes(totalBytes)})`);
|
|
467
|
+
const typeLine = Object.entries(byType).sort((a, b) => b[1].count - a[1].count).map(([e, v]) => `${e} ${v.count}`).join(" ");
|
|
468
|
+
if (typeLine) lines.push(` by type : ${typeLine}`);
|
|
469
|
+
if (oversized.length) {
|
|
470
|
+
lines.push(` OVERSIZED (>${args["max-mb"] || 25}MB, NOT downloaded — route to R2/Stream): ${oversized.length}`);
|
|
471
|
+
for (const o of oversized.slice(0, 10)) lines.push(` ${humanBytes(o.bytes)} ${o.url}`);
|
|
472
|
+
}
|
|
473
|
+
if (skipped.length) lines.push(` EXCLUDED (origin served HTML — missing or another tenant's asset): ${skipped.length}`);
|
|
474
|
+
if (failed.length) {
|
|
475
|
+
lines.push(` FAILED to fetch: ${failed.length}`);
|
|
476
|
+
for (const f of failed.slice(0, 10)) lines.push(` [${f.status}] ${f.url}`);
|
|
477
|
+
}
|
|
478
|
+
if (externalRefsLeft.size) lines.push(` third-party refs left as-is (fonts/analytics/SDKs): ${externalRefsLeft.size}`);
|
|
479
|
+
lines.push("");
|
|
480
|
+
lines.push(` run report: ${path.join(REPORTS, "run-report.json")}`);
|
|
481
|
+
lines.push(` NEXT: substitute.mjs --dir ${RUN_DIR} (plan) → audit.mjs → render+repair.mjs → deploy site/ → verify.mjs`);
|
|
482
|
+
lines.push("");
|
|
483
|
+
console.log(lines.join("\n"));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
main().catch((e) => {
|
|
487
|
+
console.error("mirror failed:", e);
|
|
488
|
+
process.exit(1);
|
|
489
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* repair.mjs — fetch assets the clone only asks for AT RUNTIME.
|
|
4
|
+
*
|
|
5
|
+
* Static scanning cannot see a URL that JavaScript builds on the fly. The
|
|
6
|
+
* common case is webpack lazy chunks: a page-builder stores a base path plus a
|
|
7
|
+
* chunk→hash map and concatenates them at runtime, so the filename exists in no
|
|
8
|
+
* attribute, stylesheet or literal string. Those files are never mirrored, and
|
|
9
|
+
* after rebranding they resolve against a domain that does not exist.
|
|
10
|
+
*
|
|
11
|
+
* Live-caught 2026-07-30: six Elementor handler chunks 503'd on a rebranded
|
|
12
|
+
* clone. `section-frontend-handlers` is what paints section backgrounds — with
|
|
13
|
+
* it missing the hero rendered blank and white headline text sat on white, so
|
|
14
|
+
* the "byte-for-byte" clone looked nothing like the original. Nothing in the
|
|
15
|
+
* file-level checks could see it; only loading the page could.
|
|
16
|
+
*
|
|
17
|
+
* The loop: render the clone → capture failed requests → repair → render again,
|
|
18
|
+
* until nothing fails.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* node repair.mjs --dir <run-dir> --origin https://original.example --urls failed.txt
|
|
22
|
+
* node repair.mjs --dir <run-dir> --origin https://original.example --url "/path/a.js" --url "/path/b.js"
|
|
23
|
+
*
|
|
24
|
+
* `failed.txt` is one URL or path per line; the clone's own host is stripped, so
|
|
25
|
+
* you can paste what the browser reported verbatim.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import * as fs from "node:fs";
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
import { parseArgs, fetchRaw, mapLimit, ensureDir, ext, humanBytes, readJson, writeJson, nowIso, resolveRun } from "./lib.mjs";
|
|
31
|
+
|
|
32
|
+
const args = parseArgs(process.argv.slice(2));
|
|
33
|
+
if (!args.dir || !args.origin) {
|
|
34
|
+
console.error("usage: repair.mjs --dir <run-dir> --origin <original-site-url> (--urls failed.txt | --url <path> ...)");
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
const RUN = resolveRun(String(args.dir));
|
|
38
|
+
const SITE = RUN.site;
|
|
39
|
+
const REPORTS = RUN.reports;
|
|
40
|
+
const ORIGIN = String(args.origin).replace(/\/+$/, "");
|
|
41
|
+
|
|
42
|
+
// Collect the failing references.
|
|
43
|
+
const raw = [];
|
|
44
|
+
if (args.urls && args.urls !== true) {
|
|
45
|
+
raw.push(...fs.readFileSync(path.resolve(String(args.urls)), "utf8").split(/\r?\n/));
|
|
46
|
+
}
|
|
47
|
+
const single = process.argv.reduce((acc, a, i, arr) => (a === "--url" && arr[i + 1] ? [...acc, arr[i + 1]] : acc), []);
|
|
48
|
+
raw.push(...single);
|
|
49
|
+
|
|
50
|
+
/** Reduce anything the browser reported to an origin-relative path. */
|
|
51
|
+
function toPath(entry) {
|
|
52
|
+
let v = String(entry).trim();
|
|
53
|
+
if (!v || v.startsWith("#")) return null;
|
|
54
|
+
if (/^chrome-extension:/i.test(v)) return null; // the user's own extensions
|
|
55
|
+
if (/^https?:\/\//i.test(v)) {
|
|
56
|
+
try {
|
|
57
|
+
v = new URL(v).pathname + new URL(v).search;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!v.startsWith("/")) v = "/" + v;
|
|
63
|
+
return v;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const paths = [...new Set(raw.map(toPath).filter(Boolean))];
|
|
67
|
+
if (paths.length === 0) {
|
|
68
|
+
console.error("No usable URLs given. Pass --urls <file> or one or more --url <path>.");
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const results = await mapLimit(paths, 6, async (p) => {
|
|
73
|
+
const clean = p.split("?")[0];
|
|
74
|
+
const local = clean.replace(/^\/+/, "");
|
|
75
|
+
const dest = path.join(SITE, local);
|
|
76
|
+
if (fs.existsSync(dest)) return { path: p, status: "already present" };
|
|
77
|
+
|
|
78
|
+
const res = await fetchRaw(ORIGIN + p);
|
|
79
|
+
if (!res.ok || res.buf.length === 0) {
|
|
80
|
+
return { path: p, status: `FAILED (${res.status}${res.error ? " " + res.error : ""})` };
|
|
81
|
+
}
|
|
82
|
+
const looksHtml = /^\s*(<!doctype html|<html)/i.test(res.buf.slice(0, 200).toString("utf8"));
|
|
83
|
+
if (looksHtml && ![".html", ".htm", ""].includes(ext(clean))) {
|
|
84
|
+
return { path: p, status: "SKIPPED — origin served HTML (the file does not exist there either)" };
|
|
85
|
+
}
|
|
86
|
+
ensureDir(dest);
|
|
87
|
+
fs.writeFileSync(dest, res.buf);
|
|
88
|
+
return { path: p, status: "repaired", bytes: res.buf.length, local };
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const repaired = results.filter((r) => r.status === "repaired");
|
|
92
|
+
const failed = results.filter((r) => String(r.status).startsWith("FAILED"));
|
|
93
|
+
const skipped = results.filter((r) => String(r.status).startsWith("SKIPPED"));
|
|
94
|
+
const present = results.filter((r) => r.status === "already present");
|
|
95
|
+
|
|
96
|
+
// Record the repair in the run's reports so the audit can mention it.
|
|
97
|
+
const file = path.join(REPORTS, "repair-report.json");
|
|
98
|
+
let prior = [];
|
|
99
|
+
try { prior = readJson(file).runs || []; } catch {}
|
|
100
|
+
writeJson(file, { runs: [...prior, { at: nowIso(), origin: ORIGIN, results }] });
|
|
101
|
+
|
|
102
|
+
const out = [];
|
|
103
|
+
out.push("");
|
|
104
|
+
out.push(`RUNTIME REPAIR ${ORIGIN}`);
|
|
105
|
+
out.push(` requested: ${paths.length} repaired: ${repaired.length} already present: ${present.length} skipped: ${skipped.length} failed: ${failed.length}`);
|
|
106
|
+
for (const r of repaired) out.push(` + ${humanBytes(r.bytes)} ${r.local}`);
|
|
107
|
+
for (const r of skipped) out.push(` · ${r.path} — ${r.status}`);
|
|
108
|
+
for (const r of failed) out.push(` ! ${r.path} — ${r.status}`);
|
|
109
|
+
out.push("");
|
|
110
|
+
out.push(" These files are only requested once the page RUNS, so re-render the clone and capture");
|
|
111
|
+
out.push(" failed requests again. Repeat until nothing fails — one repair pass often reveals the next.");
|
|
112
|
+
out.push("");
|
|
113
|
+
console.log(out.join("\n"));
|