howone 0.1.52 → 0.2.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/bin/index.mjs +362 -5
- package/package.json +1 -1
- package/templates/vite/.howone/skills/web-clone/LICENSE +21 -0
- package/templates/vite/.howone/skills/web-clone/README.md +179 -0
- package/templates/vite/.howone/skills/web-clone/SKILL.md +243 -0
- package/templates/vite/.howone/skills/web-clone/references/assessment.md +77 -0
- package/templates/vite/.howone/skills/web-clone/references/complex-playbooks.md +46 -0
- package/templates/vite/.howone/skills/web-clone/references/deliverables.md +144 -0
- package/templates/vite/.howone/skills/web-clone/references/design-dna.md +125 -0
- package/templates/vite/.howone/skills/web-clone/references/effect-extraction.md +73 -0
- package/templates/vite/.howone/skills/web-clone/references/marbles-case.md +31 -0
- package/templates/vite/.howone/skills/web-clone/references/reverse-engineering.md +34 -0
- package/templates/vite/.howone/skills/web-clone/references/static-mirror.md +72 -0
- package/templates/vite/.howone/skills/web-clone/scripts/asset-harvest.mjs +101 -0
- package/templates/vite/.howone/skills/web-clone/scripts/audit-clone.mjs +151 -0
- package/templates/vite/.howone/skills/web-clone/scripts/compare-recon.mjs +265 -0
- package/templates/vite/.howone/skills/web-clone/scripts/dna-scaffold.mjs +214 -0
- package/templates/vite/.howone/skills/web-clone/scripts/init-clone.mjs +136 -0
- package/templates/vite/.howone/skills/web-clone/scripts/interaction-probe.mjs +314 -0
- package/templates/vite/.howone/skills/web-clone/scripts/lib/playwright-loader.mjs +39 -0
- package/templates/vite/.howone/skills/web-clone/scripts/mirror-site.mjs +121 -0
- package/templates/vite/.howone/skills/web-clone/scripts/network-capture.mjs +127 -0
- package/templates/vite/.howone/skills/web-clone/scripts/recon-site.mjs +235 -0
- package/templates/vite/.howone/skills/web-clone/scripts/route-crawl.mjs +228 -0
- package/templates/vite/.howone/skills/web-clone/scripts/sourcemap-hunt.mjs +112 -0
- package/templates/vite/.howone/skills/web-clone/scripts/visual-diff.mjs +161 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { loadPlaywright, launchChromium } from "./lib/playwright-loader.mjs";
|
|
5
|
+
|
|
6
|
+
function usage() {
|
|
7
|
+
console.log(`Usage:
|
|
8
|
+
node scripts/recon-site.mjs --url <url> --out <RECON dir> [--label original|clone] [--widths 1440,768,390] [--wait 1200]
|
|
9
|
+
|
|
10
|
+
Outputs:
|
|
11
|
+
<out>/<label>-recon.json
|
|
12
|
+
<out>/<label>-summary.md
|
|
13
|
+
<out>/screenshots/<label>-<width>.png
|
|
14
|
+
`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseArgs(argv) {
|
|
18
|
+
const out = {
|
|
19
|
+
url: "",
|
|
20
|
+
outDir: "RECON",
|
|
21
|
+
label: "site",
|
|
22
|
+
widths: [1440, 768, 390],
|
|
23
|
+
waitMs: 1200,
|
|
24
|
+
};
|
|
25
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
26
|
+
const arg = argv[i];
|
|
27
|
+
if (arg === "--help" || arg === "-h") out.help = true;
|
|
28
|
+
else if (arg === "--url") out.url = argv[++i] || "";
|
|
29
|
+
else if (arg === "--out") out.outDir = argv[++i] || "";
|
|
30
|
+
else if (arg === "--label") out.label = argv[++i] || "site";
|
|
31
|
+
else if (arg === "--widths") out.widths = (argv[++i] || "").split(",").map((n) => Number(n.trim())).filter(Boolean);
|
|
32
|
+
else if (arg === "--wait") out.waitMs = Number(argv[++i] || "1200");
|
|
33
|
+
else throw new Error(`Unexpected argument: ${arg}`);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function summarizeFlags(signals) {
|
|
39
|
+
return Object.entries(signals.frameworks || {})
|
|
40
|
+
.filter(([, value]) => value)
|
|
41
|
+
.map(([key]) => key);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writeSummary(file, data) {
|
|
45
|
+
const first = data.captures[0]?.signals || {};
|
|
46
|
+
const lines = [
|
|
47
|
+
`# ${data.label} recon`,
|
|
48
|
+
"",
|
|
49
|
+
`- URL: ${data.url}`,
|
|
50
|
+
`- Title: ${first.title || ""}`,
|
|
51
|
+
`- Lang: ${first.lang || ""}`,
|
|
52
|
+
`- Viewports: ${data.captures.map((c) => c.viewport.width).join(", ")}`,
|
|
53
|
+
`- Framework signals: ${summarizeFlags(first).join(", ") || "none"}`,
|
|
54
|
+
`- Canvas count: ${first.counts?.canvas ?? 0}`,
|
|
55
|
+
`- Video count: ${first.counts?.video ?? 0}`,
|
|
56
|
+
`- Image count: ${first.counts?.images ?? 0}`,
|
|
57
|
+
`- Link count: ${first.counts?.links ?? 0}`,
|
|
58
|
+
`- Console errors: ${data.console.errors.length}`,
|
|
59
|
+
`- Page errors: ${data.console.pageErrors.length}`,
|
|
60
|
+
"",
|
|
61
|
+
"## Screenshots",
|
|
62
|
+
...data.captures.map((c) => `- ${c.viewport.width}: ${c.screenshot}`),
|
|
63
|
+
"",
|
|
64
|
+
];
|
|
65
|
+
fs.writeFileSync(file, lines.join("\n"));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function collectSignals(page) {
|
|
69
|
+
return page.evaluate(() => {
|
|
70
|
+
const bySelector = (selector) => Array.from(document.querySelectorAll(selector));
|
|
71
|
+
const text = (node) => (node?.textContent || "").trim().replace(/\s+/g, " ");
|
|
72
|
+
const win = window;
|
|
73
|
+
const scripts = bySelector("script[src]").map((s) => s.src);
|
|
74
|
+
const stylesheets = bySelector("link[rel='stylesheet']").map((s) => s.href);
|
|
75
|
+
const headings = bySelector("h1,h2,h3").slice(0, 60).map((h) => ({
|
|
76
|
+
tag: h.tagName.toLowerCase(),
|
|
77
|
+
text: text(h).slice(0, 160),
|
|
78
|
+
}));
|
|
79
|
+
const sections = bySelector("header,nav,main,section,article,aside,footer").slice(0, 80).map((node) => {
|
|
80
|
+
const rect = node.getBoundingClientRect();
|
|
81
|
+
const style = getComputedStyle(node);
|
|
82
|
+
return {
|
|
83
|
+
tag: node.tagName.toLowerCase(),
|
|
84
|
+
id: node.id || "",
|
|
85
|
+
className: String(node.className || "").slice(0, 160),
|
|
86
|
+
text: text(node).slice(0, 240),
|
|
87
|
+
rect: {
|
|
88
|
+
x: Math.round(rect.x),
|
|
89
|
+
y: Math.round(rect.y),
|
|
90
|
+
width: Math.round(rect.width),
|
|
91
|
+
height: Math.round(rect.height),
|
|
92
|
+
},
|
|
93
|
+
style: {
|
|
94
|
+
display: style.display,
|
|
95
|
+
position: style.position,
|
|
96
|
+
backgroundColor: style.backgroundColor,
|
|
97
|
+
color: style.color,
|
|
98
|
+
fontFamily: style.fontFamily,
|
|
99
|
+
fontSize: style.fontSize,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
const cssVariables = Array.from(document.styleSheets).flatMap((sheet) => {
|
|
104
|
+
try {
|
|
105
|
+
return Array.from(sheet.cssRules || []);
|
|
106
|
+
} catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}).flatMap((rule) => {
|
|
110
|
+
const style = rule.style;
|
|
111
|
+
if (!style) return [];
|
|
112
|
+
return Array.from(style)
|
|
113
|
+
.filter((name) => name.startsWith("--"))
|
|
114
|
+
.map((name) => [name, style.getPropertyValue(name).trim()]);
|
|
115
|
+
}).slice(0, 200);
|
|
116
|
+
const images = bySelector("img").slice(0, 80).map((img) => ({
|
|
117
|
+
src: img.currentSrc || img.src,
|
|
118
|
+
alt: img.alt || "",
|
|
119
|
+
width: img.naturalWidth || img.width || 0,
|
|
120
|
+
height: img.naturalHeight || img.height || 0,
|
|
121
|
+
}));
|
|
122
|
+
const canvases = bySelector("canvas").map((canvas) => ({
|
|
123
|
+
width: canvas.width,
|
|
124
|
+
height: canvas.height,
|
|
125
|
+
cssWidth: canvas.getBoundingClientRect().width,
|
|
126
|
+
cssHeight: canvas.getBoundingClientRect().height,
|
|
127
|
+
}));
|
|
128
|
+
return {
|
|
129
|
+
href: location.href,
|
|
130
|
+
title: document.title,
|
|
131
|
+
lang: document.documentElement.lang || "",
|
|
132
|
+
bodyTextChars: (document.body?.innerText || "").length,
|
|
133
|
+
scrollHeight: document.documentElement.scrollHeight,
|
|
134
|
+
viewport: {
|
|
135
|
+
width: window.innerWidth,
|
|
136
|
+
height: window.innerHeight,
|
|
137
|
+
},
|
|
138
|
+
h1: bySelector("h1").map((h) => text(h)).filter(Boolean).slice(0, 10),
|
|
139
|
+
headings,
|
|
140
|
+
metaDescription: document.querySelector("meta[name='description']")?.content || "",
|
|
141
|
+
counts: {
|
|
142
|
+
links: bySelector("a[href]").length,
|
|
143
|
+
images: bySelector("img").length,
|
|
144
|
+
video: bySelector("video").length,
|
|
145
|
+
canvas: bySelector("canvas").length,
|
|
146
|
+
sections: sections.length,
|
|
147
|
+
forms: bySelector("form").length,
|
|
148
|
+
buttons: bySelector("button").length,
|
|
149
|
+
inputs: bySelector("input,textarea,select").length,
|
|
150
|
+
interactive: bySelector("a[href],button,input,textarea,select,summary,[role='button'],[tabindex]").length,
|
|
151
|
+
scripts: scripts.length,
|
|
152
|
+
stylesheets: stylesheets.length,
|
|
153
|
+
},
|
|
154
|
+
frameworks: {
|
|
155
|
+
react: Boolean(win.__REACT_DEVTOOLS_GLOBAL_HOOK__) || Boolean(document.querySelector("#__next,[data-reactroot],[data-reactid]")),
|
|
156
|
+
next: Boolean(document.querySelector("#__next")) || scripts.some((src) => src.includes("/_next/")),
|
|
157
|
+
vue: Boolean(win.__VUE__) || Boolean(document.querySelector("[data-v-app]")),
|
|
158
|
+
nuxt: Boolean(win.__NUXT__) || scripts.some((src) => src.includes("/_nuxt/")),
|
|
159
|
+
svelte: Boolean(document.querySelector("[data-svelte-h]")),
|
|
160
|
+
astro: Boolean(document.querySelector("[data-astro-cid]")) || scripts.some((src) => src.includes("astro")),
|
|
161
|
+
three: Boolean(win.THREE) || scripts.some((src) => /three(\.module)?(\.min)?\.js/i.test(src)),
|
|
162
|
+
gsap: Boolean(win.gsap) || scripts.some((src) => src.toLowerCase().includes("gsap")),
|
|
163
|
+
lenis: Boolean(win.Lenis) || scripts.some((src) => src.toLowerCase().includes("lenis")),
|
|
164
|
+
},
|
|
165
|
+
scripts: scripts.slice(0, 120),
|
|
166
|
+
stylesheets: stylesheets.slice(0, 80),
|
|
167
|
+
sections,
|
|
168
|
+
cssVariables,
|
|
169
|
+
fonts: Array.from(document.fonts || []).map((font) => font.family).filter(Boolean).slice(0, 40),
|
|
170
|
+
images,
|
|
171
|
+
canvases,
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const args = parseArgs(process.argv.slice(2));
|
|
178
|
+
if (args.help || !args.url) {
|
|
179
|
+
usage();
|
|
180
|
+
process.exit(args.help ? 0 : 1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const { chromium } = loadPlaywright();
|
|
184
|
+
const outDir = path.resolve(args.outDir);
|
|
185
|
+
const screenshotsDir = path.join(outDir, "screenshots");
|
|
186
|
+
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
187
|
+
|
|
188
|
+
const consoleState = { errors: [], warnings: [], pageErrors: [] };
|
|
189
|
+
const browser = await launchChromium(chromium);
|
|
190
|
+
const captures = [];
|
|
191
|
+
|
|
192
|
+
for (const width of args.widths) {
|
|
193
|
+
const page = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 });
|
|
194
|
+
page.on("console", (message) => {
|
|
195
|
+
const entry = { type: message.type(), text: message.text(), viewport: width };
|
|
196
|
+
if (message.type() === "error") consoleState.errors.push(entry);
|
|
197
|
+
if (message.type() === "warning") consoleState.warnings.push(entry);
|
|
198
|
+
});
|
|
199
|
+
page.on("pageerror", (error) => {
|
|
200
|
+
consoleState.pageErrors.push({ message: error.message, viewport: width });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await page.goto(args.url, { waitUntil: "domcontentloaded", timeout: 45000 });
|
|
204
|
+
await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => {});
|
|
205
|
+
if (args.waitMs > 0) await page.waitForTimeout(args.waitMs);
|
|
206
|
+
|
|
207
|
+
const signals = await collectSignals(page);
|
|
208
|
+
const screenshotName = `${args.label}-${width}.png`;
|
|
209
|
+
const screenshotPath = path.join(screenshotsDir, screenshotName);
|
|
210
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
211
|
+
captures.push({
|
|
212
|
+
viewport: { width, height: 900 },
|
|
213
|
+
screenshot: path.relative(outDir, screenshotPath),
|
|
214
|
+
signals,
|
|
215
|
+
});
|
|
216
|
+
await page.close();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
await browser.close();
|
|
220
|
+
|
|
221
|
+
const result = {
|
|
222
|
+
label: args.label,
|
|
223
|
+
url: args.url,
|
|
224
|
+
capturedAt: new Date().toISOString(),
|
|
225
|
+
console: consoleState,
|
|
226
|
+
captures,
|
|
227
|
+
};
|
|
228
|
+
const jsonFile = path.join(outDir, `${args.label}-recon.json`);
|
|
229
|
+
fs.writeFileSync(jsonFile, `${JSON.stringify(result, null, 2)}\n`);
|
|
230
|
+
writeSummary(path.join(outDir, `${args.label}-summary.md`), result);
|
|
231
|
+
console.log(jsonFile);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
console.error(`recon-site failed: ${error.message}`);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import { loadPlaywright, launchChromium } from "./lib/playwright-loader.mjs";
|
|
6
|
+
|
|
7
|
+
function usage() {
|
|
8
|
+
console.log(`Usage:
|
|
9
|
+
node scripts/route-crawl.mjs --url <url> --out RECON/routes [--label original] [--max-pages 25] [--max-depth 2] [--width 1440] [--wait 800] [--allow-subdomains]
|
|
10
|
+
|
|
11
|
+
Crawls same-site internal links, captures a screenshot per route, and writes a route map.
|
|
12
|
+
`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseArgs(argv) {
|
|
16
|
+
const out = {
|
|
17
|
+
url: "",
|
|
18
|
+
outDir: "RECON/routes",
|
|
19
|
+
label: "site",
|
|
20
|
+
maxPages: 25,
|
|
21
|
+
maxDepth: 2,
|
|
22
|
+
width: 1440,
|
|
23
|
+
waitMs: 800,
|
|
24
|
+
allowSubdomains: false,
|
|
25
|
+
};
|
|
26
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
27
|
+
const arg = argv[i];
|
|
28
|
+
if (arg === "--help" || arg === "-h") out.help = true;
|
|
29
|
+
else if (arg === "--url") out.url = argv[++i] || "";
|
|
30
|
+
else if (arg === "--out") out.outDir = argv[++i] || "RECON/routes";
|
|
31
|
+
else if (arg === "--label") out.label = argv[++i] || "site";
|
|
32
|
+
else if (arg === "--max-pages") out.maxPages = Number(argv[++i] || "25");
|
|
33
|
+
else if (arg === "--max-depth") out.maxDepth = Number(argv[++i] || "2");
|
|
34
|
+
else if (arg === "--width") out.width = Number(argv[++i] || "1440");
|
|
35
|
+
else if (arg === "--wait") out.waitMs = Number(argv[++i] || "800");
|
|
36
|
+
else if (arg === "--allow-subdomains") out.allowSubdomains = true;
|
|
37
|
+
else throw new Error(`Unexpected argument: ${arg}`);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeUrl(rawUrl, baseUrl) {
|
|
43
|
+
try {
|
|
44
|
+
const url = new URL(rawUrl, baseUrl);
|
|
45
|
+
if (!["http:", "https:"].includes(url.protocol)) return "";
|
|
46
|
+
url.hash = "";
|
|
47
|
+
url.searchParams.sort();
|
|
48
|
+
return url.toString().replace(/\/$/, "");
|
|
49
|
+
} catch {
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sameSite(candidate, origin, allowSubdomains) {
|
|
55
|
+
const url = new URL(candidate);
|
|
56
|
+
const root = new URL(origin);
|
|
57
|
+
if (url.origin === root.origin) return true;
|
|
58
|
+
return allowSubdomains && url.hostname.endsWith(`.${root.hostname}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function routeFileName(url) {
|
|
62
|
+
const parsed = new URL(url);
|
|
63
|
+
const clean = `${parsed.hostname}${parsed.pathname}`.replace(/[^a-z0-9._-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 90) || "route";
|
|
64
|
+
const hash = crypto.createHash("sha1").update(url).digest("hex").slice(0, 8);
|
|
65
|
+
return `${clean}-${hash}.png`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function summarizeMarkdown(result) {
|
|
69
|
+
const lines = [
|
|
70
|
+
`# ${result.label} route map`,
|
|
71
|
+
"",
|
|
72
|
+
`- URL: ${result.url}`,
|
|
73
|
+
`- Captured routes: ${result.routes.length}`,
|
|
74
|
+
`- Max depth: ${result.maxDepth}`,
|
|
75
|
+
`- Max pages: ${result.maxPages}`,
|
|
76
|
+
"",
|
|
77
|
+
"## Routes",
|
|
78
|
+
"| Depth | Status | Path | Title | H1 | Links | Screenshot |",
|
|
79
|
+
"|---:|---:|---|---|---|---:|---|",
|
|
80
|
+
];
|
|
81
|
+
for (const route of result.routes) {
|
|
82
|
+
const url = new URL(route.url);
|
|
83
|
+
const pathLabel = `${url.pathname}${url.search}`;
|
|
84
|
+
lines.push(`| ${route.depth} | ${route.status || ""} | ${pathLabel || "/"} | ${route.title.replaceAll("|", "\\|")} | ${route.h1.join(" / ").replaceAll("|", "\\|")} | ${route.linkCount} | ${route.screenshot} |`);
|
|
85
|
+
}
|
|
86
|
+
if (result.skipped.length) {
|
|
87
|
+
lines.push("");
|
|
88
|
+
lines.push("## Skipped / Failed");
|
|
89
|
+
for (const item of result.skipped.slice(0, 80)) {
|
|
90
|
+
lines.push(`- ${item.url} · ${item.reason}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return `${lines.join("\n")}\n`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function collectPage(page) {
|
|
97
|
+
return page.evaluate(() => {
|
|
98
|
+
const text = (node) => (node?.textContent || "").trim().replace(/\s+/g, " ");
|
|
99
|
+
const links = Array.from(document.querySelectorAll("a[href]")).map((a) => ({
|
|
100
|
+
href: a.href,
|
|
101
|
+
text: text(a).slice(0, 120),
|
|
102
|
+
}));
|
|
103
|
+
const headings = Array.from(document.querySelectorAll("h1,h2,h3")).slice(0, 40).map((node) => ({
|
|
104
|
+
tag: node.tagName.toLowerCase(),
|
|
105
|
+
text: text(node).slice(0, 160),
|
|
106
|
+
}));
|
|
107
|
+
return {
|
|
108
|
+
href: location.href,
|
|
109
|
+
title: document.title || "",
|
|
110
|
+
lang: document.documentElement.lang || "",
|
|
111
|
+
metaDescription: document.querySelector("meta[name='description']")?.content || "",
|
|
112
|
+
h1: Array.from(document.querySelectorAll("h1")).map((node) => text(node)).filter(Boolean).slice(0, 8),
|
|
113
|
+
headings,
|
|
114
|
+
scrollHeight: document.documentElement.scrollHeight,
|
|
115
|
+
counts: {
|
|
116
|
+
links: links.length,
|
|
117
|
+
images: document.images.length,
|
|
118
|
+
canvas: document.querySelectorAll("canvas").length,
|
|
119
|
+
forms: document.forms.length,
|
|
120
|
+
buttons: document.querySelectorAll("button,[role='button']").length,
|
|
121
|
+
},
|
|
122
|
+
links,
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const args = parseArgs(process.argv.slice(2));
|
|
129
|
+
if (args.help || !args.url) {
|
|
130
|
+
usage();
|
|
131
|
+
process.exit(args.help ? 0 : 1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const startUrl = normalizeUrl(args.url, args.url);
|
|
135
|
+
if (!startUrl) throw new Error(`Invalid URL: ${args.url}`);
|
|
136
|
+
|
|
137
|
+
const outDir = path.resolve(args.outDir);
|
|
138
|
+
const screenshotsDir = path.join(outDir, "screenshots");
|
|
139
|
+
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
140
|
+
|
|
141
|
+
const { chromium } = loadPlaywright();
|
|
142
|
+
const browser = await launchChromium(chromium);
|
|
143
|
+
const page = await browser.newPage({ viewport: { width: args.width, height: 900 }, deviceScaleFactor: 1 });
|
|
144
|
+
|
|
145
|
+
const queue = [{ url: startUrl, depth: 0, from: "" }];
|
|
146
|
+
const seen = new Set();
|
|
147
|
+
const routes = [];
|
|
148
|
+
const skipped = [];
|
|
149
|
+
|
|
150
|
+
while (queue.length && routes.length < args.maxPages) {
|
|
151
|
+
const current = queue.shift();
|
|
152
|
+
if (!current || seen.has(current.url)) continue;
|
|
153
|
+
seen.add(current.url);
|
|
154
|
+
|
|
155
|
+
if (current.depth > args.maxDepth) {
|
|
156
|
+
skipped.push({ url: current.url, reason: `depth>${args.maxDepth}` });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!sameSite(current.url, startUrl, args.allowSubdomains)) {
|
|
160
|
+
skipped.push({ url: current.url, reason: "external" });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const consoleErrors = [];
|
|
165
|
+
const onConsole = (message) => {
|
|
166
|
+
if (message.type() === "error") consoleErrors.push(message.text());
|
|
167
|
+
};
|
|
168
|
+
page.on("console", onConsole);
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const response = await page.goto(current.url, { waitUntil: "domcontentloaded", timeout: 45000 });
|
|
172
|
+
await page.waitForLoadState("networkidle", { timeout: 6000 }).catch(() => {});
|
|
173
|
+
if (args.waitMs > 0) await page.waitForTimeout(args.waitMs);
|
|
174
|
+
const data = await collectPage(page);
|
|
175
|
+
const screenshotName = routeFileName(current.url);
|
|
176
|
+
const screenshotPath = path.join(screenshotsDir, screenshotName);
|
|
177
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
178
|
+
|
|
179
|
+
routes.push({
|
|
180
|
+
url: current.url,
|
|
181
|
+
from: current.from,
|
|
182
|
+
depth: current.depth,
|
|
183
|
+
status: response?.status() || 0,
|
|
184
|
+
title: data.title,
|
|
185
|
+
lang: data.lang,
|
|
186
|
+
metaDescription: data.metaDescription,
|
|
187
|
+
h1: data.h1,
|
|
188
|
+
headings: data.headings,
|
|
189
|
+
counts: data.counts,
|
|
190
|
+
linkCount: data.links.length,
|
|
191
|
+
screenshot: path.relative(outDir, screenshotPath),
|
|
192
|
+
consoleErrors,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
for (const link of data.links) {
|
|
196
|
+
const nextUrl = normalizeUrl(link.href, current.url);
|
|
197
|
+
if (!nextUrl || seen.has(nextUrl)) continue;
|
|
198
|
+
if (!sameSite(nextUrl, startUrl, args.allowSubdomains)) continue;
|
|
199
|
+
queue.push({ url: nextUrl, depth: current.depth + 1, from: current.url });
|
|
200
|
+
}
|
|
201
|
+
} catch (error) {
|
|
202
|
+
skipped.push({ url: current.url, reason: error.message });
|
|
203
|
+
} finally {
|
|
204
|
+
page.off("console", onConsole);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
await browser.close();
|
|
209
|
+
|
|
210
|
+
const result = {
|
|
211
|
+
label: args.label,
|
|
212
|
+
url: startUrl,
|
|
213
|
+
capturedAt: new Date().toISOString(),
|
|
214
|
+
maxPages: args.maxPages,
|
|
215
|
+
maxDepth: args.maxDepth,
|
|
216
|
+
allowSubdomains: args.allowSubdomains,
|
|
217
|
+
routes,
|
|
218
|
+
skipped,
|
|
219
|
+
};
|
|
220
|
+
const jsonFile = path.join(outDir, `${args.label}-route-map.json`);
|
|
221
|
+
const mdFile = path.join(outDir, `${args.label}-route-map.md`);
|
|
222
|
+
fs.writeFileSync(jsonFile, `${JSON.stringify(result, null, 2)}\n`);
|
|
223
|
+
fs.writeFileSync(mdFile, summarizeMarkdown(result));
|
|
224
|
+
console.log(jsonFile);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
console.error(`route-crawl failed: ${error.message}`);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
|
|
6
|
+
function usage() {
|
|
7
|
+
console.log(`Usage:
|
|
8
|
+
node scripts/sourcemap-hunt.mjs --recon original-recon.json --out RECON/sourcemaps [--all-external]
|
|
9
|
+
|
|
10
|
+
Finds sourceMappingURL hints in discovered JavaScript bundles and tries to download source maps.
|
|
11
|
+
`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseArgs(argv) {
|
|
15
|
+
const out = { recon: "", outDir: "RECON/sourcemaps", allExternal: false };
|
|
16
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
17
|
+
const arg = argv[i];
|
|
18
|
+
if (arg === "--help" || arg === "-h") out.help = true;
|
|
19
|
+
else if (arg === "--recon") out.recon = argv[++i] || "";
|
|
20
|
+
else if (arg === "--out") out.outDir = argv[++i] || "RECON/sourcemaps";
|
|
21
|
+
else if (arg === "--all-external") out.allExternal = true;
|
|
22
|
+
else throw new Error(`Unexpected argument: ${arg}`);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function collectScripts(recon) {
|
|
28
|
+
const scripts = new Set();
|
|
29
|
+
for (const capture of recon.captures || []) {
|
|
30
|
+
for (const script of capture.signals?.scripts || []) {
|
|
31
|
+
if (/^https?:\/\//i.test(script)) scripts.add(script);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return Array.from(scripts);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function fileNameFor(url, suffix = "") {
|
|
38
|
+
const parsed = new URL(url);
|
|
39
|
+
const base = path.basename(parsed.pathname).replace(/[^a-z0-9._-]+/gi, "-").slice(0, 90) || "bundle.js";
|
|
40
|
+
const hash = crypto.createHash("sha1").update(url).digest("hex").slice(0, 10);
|
|
41
|
+
return `${base}-${hash}${suffix}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function resolveMapUrl(scriptUrl, mapHint) {
|
|
45
|
+
if (!mapHint) return `${scriptUrl}.map`;
|
|
46
|
+
if (mapHint.startsWith("data:")) return "";
|
|
47
|
+
return new URL(mapHint, scriptUrl).toString();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function fetchText(url) {
|
|
51
|
+
const response = await fetch(url, {
|
|
52
|
+
headers: {
|
|
53
|
+
"user-agent": "web-clone-skill/1.0 sourcemap-hunt",
|
|
54
|
+
"accept": "*/*",
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
58
|
+
return response.text();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const args = parseArgs(process.argv.slice(2));
|
|
63
|
+
if (args.help || !args.recon) {
|
|
64
|
+
usage();
|
|
65
|
+
process.exit(args.help ? 0 : 1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const recon = JSON.parse(fs.readFileSync(args.recon, "utf8"));
|
|
69
|
+
const originHost = recon.url ? new URL(recon.url).hostname : "";
|
|
70
|
+
const scripts = collectScripts(recon).filter((script) => args.allExternal || new URL(script).hostname === originHost);
|
|
71
|
+
const outDir = path.resolve(args.outDir);
|
|
72
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
73
|
+
|
|
74
|
+
const results = [];
|
|
75
|
+
for (const scriptUrl of scripts) {
|
|
76
|
+
const entry = { scriptUrl, status: "unknown", mapUrl: "", mapFile: "", error: "" };
|
|
77
|
+
try {
|
|
78
|
+
const js = await fetchText(scriptUrl);
|
|
79
|
+
const hint = js.match(/[#@]\s*sourceMappingURL=([^\s*]+)/)?.[1] || "";
|
|
80
|
+
entry.mapUrl = resolveMapUrl(scriptUrl, hint);
|
|
81
|
+
if (!entry.mapUrl) {
|
|
82
|
+
entry.status = "inline-or-data-map";
|
|
83
|
+
results.push(entry);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const mapText = await fetchText(entry.mapUrl);
|
|
87
|
+
const mapFile = path.join(outDir, fileNameFor(entry.mapUrl, ".map"));
|
|
88
|
+
fs.writeFileSync(mapFile, mapText);
|
|
89
|
+
entry.mapFile = mapFile;
|
|
90
|
+
entry.status = "ok";
|
|
91
|
+
} catch (error) {
|
|
92
|
+
entry.status = "error";
|
|
93
|
+
entry.error = error.message;
|
|
94
|
+
}
|
|
95
|
+
results.push(entry);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const manifest = {
|
|
99
|
+
source: args.recon,
|
|
100
|
+
url: recon.url,
|
|
101
|
+
allExternal: args.allExternal,
|
|
102
|
+
scriptCount: scripts.length,
|
|
103
|
+
mapCount: results.filter((item) => item.status === "ok").length,
|
|
104
|
+
results,
|
|
105
|
+
};
|
|
106
|
+
const manifestFile = path.join(outDir, "sourcemap-manifest.json");
|
|
107
|
+
fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
108
|
+
console.log(manifestFile);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.error(`sourcemap-hunt failed: ${error.message}`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|