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.
Files changed (26) hide show
  1. package/bin/index.mjs +362 -5
  2. package/package.json +1 -1
  3. package/templates/vite/.howone/skills/web-clone/LICENSE +21 -0
  4. package/templates/vite/.howone/skills/web-clone/README.md +179 -0
  5. package/templates/vite/.howone/skills/web-clone/SKILL.md +243 -0
  6. package/templates/vite/.howone/skills/web-clone/references/assessment.md +77 -0
  7. package/templates/vite/.howone/skills/web-clone/references/complex-playbooks.md +46 -0
  8. package/templates/vite/.howone/skills/web-clone/references/deliverables.md +144 -0
  9. package/templates/vite/.howone/skills/web-clone/references/design-dna.md +125 -0
  10. package/templates/vite/.howone/skills/web-clone/references/effect-extraction.md +73 -0
  11. package/templates/vite/.howone/skills/web-clone/references/marbles-case.md +31 -0
  12. package/templates/vite/.howone/skills/web-clone/references/reverse-engineering.md +34 -0
  13. package/templates/vite/.howone/skills/web-clone/references/static-mirror.md +72 -0
  14. package/templates/vite/.howone/skills/web-clone/scripts/asset-harvest.mjs +101 -0
  15. package/templates/vite/.howone/skills/web-clone/scripts/audit-clone.mjs +151 -0
  16. package/templates/vite/.howone/skills/web-clone/scripts/compare-recon.mjs +265 -0
  17. package/templates/vite/.howone/skills/web-clone/scripts/dna-scaffold.mjs +214 -0
  18. package/templates/vite/.howone/skills/web-clone/scripts/init-clone.mjs +136 -0
  19. package/templates/vite/.howone/skills/web-clone/scripts/interaction-probe.mjs +314 -0
  20. package/templates/vite/.howone/skills/web-clone/scripts/lib/playwright-loader.mjs +39 -0
  21. package/templates/vite/.howone/skills/web-clone/scripts/mirror-site.mjs +121 -0
  22. package/templates/vite/.howone/skills/web-clone/scripts/network-capture.mjs +127 -0
  23. package/templates/vite/.howone/skills/web-clone/scripts/recon-site.mjs +235 -0
  24. package/templates/vite/.howone/skills/web-clone/scripts/route-crawl.mjs +228 -0
  25. package/templates/vite/.howone/skills/web-clone/scripts/sourcemap-hunt.mjs +112 -0
  26. package/templates/vite/.howone/skills/web-clone/scripts/visual-diff.mjs +161 -0
@@ -0,0 +1,314 @@
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/interaction-probe.mjs --url <url> --out RECON/interactions [--label original] [--max-clicks 12] [--max-hovers 8] [--wait 800] [--width 1440]
10
+
11
+ Exercises scroll, hover, safe clicks, and canvas drag gestures, then saves screenshots plus state/network evidence.
12
+ `);
13
+ }
14
+
15
+ function parseArgs(argv) {
16
+ const out = {
17
+ url: "",
18
+ outDir: "RECON/interactions",
19
+ label: "site",
20
+ maxClicks: 12,
21
+ maxHovers: 8,
22
+ waitMs: 800,
23
+ width: 1440,
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] || "RECON/interactions";
30
+ else if (arg === "--label") out.label = argv[++i] || "site";
31
+ else if (arg === "--max-clicks") out.maxClicks = Number(argv[++i] || "12");
32
+ else if (arg === "--max-hovers") out.maxHovers = Number(argv[++i] || "8");
33
+ else if (arg === "--wait") out.waitMs = Number(argv[++i] || "800");
34
+ else if (arg === "--width") out.width = Number(argv[++i] || "1440");
35
+ else throw new Error(`Unexpected argument: ${arg}`);
36
+ }
37
+ return out;
38
+ }
39
+
40
+ function shortHash(value) {
41
+ return crypto.createHash("sha1").update(value).digest("hex").slice(0, 10);
42
+ }
43
+
44
+ function safeFileName(label) {
45
+ return label.replace(/[^a-z0-9._-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 90) || "step";
46
+ }
47
+
48
+ function markdown(result) {
49
+ const lines = [
50
+ `# ${result.label} interaction probe`,
51
+ "",
52
+ `- URL: ${result.url}`,
53
+ `- Actions: ${result.actions.length}`,
54
+ `- Console errors: ${result.consoleErrors.length}`,
55
+ `- Network events: ${result.network.length}`,
56
+ "",
57
+ "## Actions",
58
+ "| # | Type | Target | Changed | URL after | Screenshot |",
59
+ "|---:|---|---|---:|---|---|",
60
+ ];
61
+ result.actions.forEach((action, index) => {
62
+ lines.push(`| ${index + 1} | ${action.type} | ${String(action.target || "").replaceAll("|", "\\|")} | ${action.changed ? "yes" : "no"} | ${action.after.url.replaceAll("|", "\\|")} | ${action.screenshot || ""} |`);
63
+ });
64
+ if (result.findings.length) {
65
+ lines.push("");
66
+ lines.push("## Findings");
67
+ for (const finding of result.findings) lines.push(`- ${finding}`);
68
+ }
69
+ return `${lines.join("\n")}\n`;
70
+ }
71
+
72
+ async function snapshot(page) {
73
+ return page.evaluate(() => {
74
+ const text = (node) => (node?.textContent || "").trim().replace(/\s+/g, " ");
75
+ const visible = (selector) => Array.from(document.querySelectorAll(selector)).filter((node) => {
76
+ const rect = node.getBoundingClientRect();
77
+ const style = getComputedStyle(node);
78
+ return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
79
+ }).length;
80
+ const bodyText = document.body?.innerText || "";
81
+ const html = document.documentElement.outerHTML;
82
+ return {
83
+ url: location.href,
84
+ title: document.title || "",
85
+ activeElement: document.activeElement?.tagName?.toLowerCase() || "",
86
+ scrollY: Math.round(window.scrollY),
87
+ bodyTextChars: bodyText.length,
88
+ bodyTextStart: bodyText.trim().replace(/\s+/g, " ").slice(0, 500),
89
+ counts: {
90
+ dialogs: visible("dialog,[role='dialog'],[aria-modal='true']"),
91
+ popovers: visible("[popover],.popover,.modal,.drawer,.menu,[role='menu']"),
92
+ canvases: document.querySelectorAll("canvas").length,
93
+ videos: document.querySelectorAll("video").length,
94
+ buttons: document.querySelectorAll("button,[role='button']").length,
95
+ forms: document.forms.length,
96
+ },
97
+ htmlHash: "__HASH_PLACEHOLDER__",
98
+ htmlLength: html.length,
99
+ };
100
+ });
101
+ }
102
+
103
+ async function hashSnapshot(page, state) {
104
+ const htmlHash = await page.evaluate(() => {
105
+ const html = document.documentElement.outerHTML;
106
+ let hash = 0;
107
+ for (let i = 0; i < html.length; i += 1) {
108
+ hash = ((hash << 5) - hash + html.charCodeAt(i)) | 0;
109
+ }
110
+ return String(hash);
111
+ });
112
+ return { ...state, htmlHash };
113
+ }
114
+
115
+ async function candidates(page) {
116
+ return page.evaluate(() => {
117
+ function selectorFor(node) {
118
+ if (node.id && !/\s/.test(node.id)) return `#${CSS.escape(node.id)}`;
119
+ const parts = [];
120
+ let current = node;
121
+ while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 6) {
122
+ const tag = current.tagName.toLowerCase();
123
+ const parent = current.parentElement;
124
+ if (!parent) {
125
+ parts.unshift(tag);
126
+ break;
127
+ }
128
+ const sameTag = Array.from(parent.children).filter((child) => child.tagName === current.tagName);
129
+ const index = sameTag.indexOf(current) + 1;
130
+ parts.unshift(`${tag}:nth-of-type(${index})`);
131
+ current = parent;
132
+ }
133
+ return parts.join(" > ");
134
+ }
135
+ const text = (node) => (node.innerText || node.textContent || node.getAttribute("aria-label") || "").trim().replace(/\s+/g, " ");
136
+ const visible = (node) => {
137
+ const rect = node.getBoundingClientRect();
138
+ const style = getComputedStyle(node);
139
+ return rect.width >= 8 && rect.height >= 8 && style.visibility !== "hidden" && style.display !== "none";
140
+ };
141
+ const interactive = Array.from(document.querySelectorAll("button,a[href],summary,input,textarea,select,[role='button'],[tabindex]"))
142
+ .filter(visible)
143
+ .slice(0, 80)
144
+ .map((node) => {
145
+ const rect = node.getBoundingClientRect();
146
+ const tag = node.tagName.toLowerCase();
147
+ const href = node.href || node.getAttribute("href") || "";
148
+ const safeClick = tag === "button" || tag === "summary" || node.getAttribute("role") === "button" || href.startsWith("#") || href.startsWith(location.origin);
149
+ return {
150
+ selector: selectorFor(node),
151
+ tag,
152
+ type: node.getAttribute("type") || "",
153
+ role: node.getAttribute("role") || "",
154
+ href,
155
+ text: text(node).slice(0, 120),
156
+ ariaLabel: node.getAttribute("aria-label") || "",
157
+ safeClick,
158
+ rect: {
159
+ x: Math.round(rect.x),
160
+ y: Math.round(rect.y),
161
+ width: Math.round(rect.width),
162
+ height: Math.round(rect.height),
163
+ },
164
+ };
165
+ });
166
+ const canvases = Array.from(document.querySelectorAll("canvas")).filter(visible).slice(0, 4).map((node) => {
167
+ const rect = node.getBoundingClientRect();
168
+ return {
169
+ selector: selectorFor(node),
170
+ rect: {
171
+ x: Math.round(rect.x),
172
+ y: Math.round(rect.y),
173
+ width: Math.round(rect.width),
174
+ height: Math.round(rect.height),
175
+ },
176
+ };
177
+ });
178
+ return { interactive, canvases };
179
+ });
180
+ }
181
+
182
+ function hasChanged(before, after) {
183
+ if (before.url !== after.url) return true;
184
+ if (before.htmlHash !== after.htmlHash) return true;
185
+ if (before.scrollY !== after.scrollY) return true;
186
+ return JSON.stringify(before.counts) !== JSON.stringify(after.counts);
187
+ }
188
+
189
+ async function freshPage(browser, args, network, consoleErrors) {
190
+ const page = await browser.newPage({ viewport: { width: args.width, height: 900 }, deviceScaleFactor: 1 });
191
+ page.on("console", (message) => {
192
+ if (message.type() === "error") consoleErrors.push(message.text());
193
+ });
194
+ page.on("response", (response) => {
195
+ const request = response.request();
196
+ if (["xhr", "fetch"].includes(request.resourceType())) {
197
+ network.push({ url: response.url(), status: response.status(), method: request.method(), resourceType: request.resourceType() });
198
+ }
199
+ });
200
+ await page.goto(args.url, { waitUntil: "domcontentloaded", timeout: 45000 });
201
+ await page.waitForLoadState("networkidle", { timeout: 6000 }).catch(() => {});
202
+ if (args.waitMs > 0) await page.waitForTimeout(args.waitMs);
203
+ return page;
204
+ }
205
+
206
+ async function captureAction(page, outDir, index, action, before) {
207
+ if (action.type === "scroll") {
208
+ await page.evaluate((ratio) => window.scrollTo({ top: Math.round((document.documentElement.scrollHeight - window.innerHeight) * ratio), behavior: "instant" }), action.ratio);
209
+ } else if (action.type === "hover") {
210
+ await page.locator(action.selector).first().hover({ timeout: 3000 });
211
+ } else if (action.type === "click") {
212
+ await page.locator(action.selector).first().click({ timeout: 4000 });
213
+ } else if (action.type === "canvas-drag") {
214
+ const rect = action.rect;
215
+ const x = rect.x + rect.width / 2;
216
+ const y = rect.y + rect.height / 2;
217
+ await page.mouse.move(x, y);
218
+ await page.mouse.down();
219
+ await page.mouse.move(x + Math.min(180, rect.width * 0.25), y + Math.min(120, rect.height * 0.2), { steps: 8 });
220
+ await page.mouse.up();
221
+ }
222
+ await page.waitForTimeout(450);
223
+ const after = await hashSnapshot(page, await snapshot(page));
224
+ const screenshotName = `${String(index + 1).padStart(2, "0")}-${safeFileName(`${action.type}-${action.target || action.selector || "page"}`)}-${shortHash(JSON.stringify(action))}.png`;
225
+ const screenshotPath = path.join(outDir, "screenshots", screenshotName);
226
+ await page.screenshot({ path: screenshotPath, fullPage: true });
227
+ return {
228
+ ...action,
229
+ before,
230
+ after,
231
+ changed: hasChanged(before, after),
232
+ screenshot: path.relative(outDir, screenshotPath),
233
+ };
234
+ }
235
+
236
+ try {
237
+ const args = parseArgs(process.argv.slice(2));
238
+ if (args.help || !args.url) {
239
+ usage();
240
+ process.exit(args.help ? 0 : 1);
241
+ }
242
+
243
+ const outDir = path.resolve(args.outDir);
244
+ fs.mkdirSync(path.join(outDir, "screenshots"), { recursive: true });
245
+
246
+ const { chromium } = loadPlaywright();
247
+ const browser = await launchChromium(chromium);
248
+ const network = [];
249
+ const consoleErrors = [];
250
+
251
+ const seedPage = await freshPage(browser, args, network, consoleErrors);
252
+ const initial = await hashSnapshot(seedPage, await snapshot(seedPage));
253
+ const discovered = await candidates(seedPage);
254
+ await seedPage.screenshot({ path: path.join(outDir, "screenshots", "00-initial.png"), fullPage: true });
255
+ await seedPage.close();
256
+
257
+ const actions = [
258
+ { type: "scroll", target: "middle", ratio: 0.5 },
259
+ { type: "scroll", target: "bottom", ratio: 1 },
260
+ ...discovered.interactive.slice(0, args.maxHovers).map((item) => ({ type: "hover", target: item.text || item.ariaLabel || item.selector, selector: item.selector, meta: item })),
261
+ ...discovered.interactive.filter((item) => item.safeClick).slice(0, args.maxClicks).map((item) => ({ type: "click", target: item.text || item.ariaLabel || item.selector, selector: item.selector, meta: item })),
262
+ ...discovered.canvases.map((item) => ({ type: "canvas-drag", target: item.selector, selector: item.selector, rect: item.rect })),
263
+ ];
264
+
265
+ const results = [];
266
+ for (let index = 0; index < actions.length; index += 1) {
267
+ const page = await freshPage(browser, args, network, consoleErrors);
268
+ const before = await hashSnapshot(page, await snapshot(page));
269
+ try {
270
+ results.push(await captureAction(page, outDir, index, actions[index], before));
271
+ } catch (error) {
272
+ results.push({
273
+ ...actions[index],
274
+ before,
275
+ after: before,
276
+ changed: false,
277
+ screenshot: "",
278
+ error: error.message,
279
+ });
280
+ } finally {
281
+ await page.close();
282
+ }
283
+ }
284
+
285
+ await browser.close();
286
+
287
+ const changedCount = results.filter((action) => action.changed).length;
288
+ const findings = [
289
+ `${discovered.interactive.length} visible interactive candidates discovered`,
290
+ `${discovered.canvases.length} visible canvas targets discovered`,
291
+ `${changedCount}/${results.length} actions changed DOM, URL, scroll, or visible overlay counts`,
292
+ ];
293
+ if (discovered.canvases.length) findings.push("Canvas drag evidence exists; inspect screenshots before simplifying WebGL/Canvas behavior.");
294
+
295
+ const result = {
296
+ label: args.label,
297
+ url: args.url,
298
+ capturedAt: new Date().toISOString(),
299
+ initial,
300
+ discovered,
301
+ actions: results,
302
+ network,
303
+ consoleErrors,
304
+ findings,
305
+ };
306
+ const jsonFile = path.join(outDir, `${args.label}-interactions.json`);
307
+ const mdFile = path.join(outDir, `${args.label}-interactions.md`);
308
+ fs.writeFileSync(jsonFile, `${JSON.stringify(result, null, 2)}\n`);
309
+ fs.writeFileSync(mdFile, markdown(result));
310
+ console.log(jsonFile);
311
+ } catch (error) {
312
+ console.error(`interaction-probe failed: ${error.message}`);
313
+ process.exit(1);
314
+ }
@@ -0,0 +1,39 @@
1
+ import { createRequire } from "node:module";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const require = createRequire(import.meta.url);
7
+ const here = dirname(fileURLToPath(import.meta.url));
8
+ const workspaceRoot = join(here, "..", "..", "..", "..");
9
+
10
+ export function loadPlaywright() {
11
+ const candidates = [
12
+ "playwright",
13
+ join(workspaceRoot, "node_modules/playwright"),
14
+ join(workspaceRoot, "node_modules/@playwright/test/node_modules/playwright"),
15
+ "/Users/erkelost/.codex/plugins/cache/openai-bundled/browser/node_modules/playwright",
16
+ "/Users/erkelost/.codex/plugins/cache/openai-primary-runtime/node_modules/playwright",
17
+ ];
18
+ for (const candidate of candidates) {
19
+ try {
20
+ if (candidate.startsWith("/") && !existsSync(candidate)) continue;
21
+ return require(candidate);
22
+ } catch {
23
+ // Try next candidate.
24
+ }
25
+ }
26
+ throw new Error("Playwright not found. Run `npm install -D playwright` in the clone project, or install the Browser skill dependencies.");
27
+ }
28
+
29
+ export async function launchChromium(chromium) {
30
+ try {
31
+ return await chromium.launch({ headless: true });
32
+ } catch (firstError) {
33
+ try {
34
+ return await chromium.launch({ headless: true, channel: "chrome" });
35
+ } catch {
36
+ throw firstError;
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ // mirror-site.mjs — 把"静态构建站"(Astro / Vite SSG / Hugo 等)的部署资产整套镜像下来,做 1:1 忠实复刻。
3
+ // 原理: 这类站的"真源码"不在 GitHub,但部署出来的静态资产(HTML + bundle + CSS + 运行时 fetch 的
4
+ // .sog/.buf/.wasm/.riv/字体/图)就是真相。用真浏览器全程滚动一遍捕获每一个真实请求,按路径镜像同源资产。
5
+ // 用法:
6
+ // node scripts/mirror-site.mjs --url <URL> --out <dir> [--scroll-step 700] [--settle 2500] [--max-ms 90000]
7
+ // 产物:
8
+ // <dir>/site/... 镜像的同源资产(保留路径;目录 URL 存为 index.html)
9
+ // <dir>/mirror-manifest.json 全部请求(同源+第三方) + 每项状态
10
+ // <dir>/own-asset-urls.txt 同源资产路径清单
11
+ // <dir>/third-party.json 第三方 host + 需自托管的 webfont CSS(typekit/google) 提示
12
+ // 纪律: 只搬"真实请求到的"资产,不臆造路径。第三方 CDN(字体/wasm/视频)不自动改写——按 third-party.json 人工处理。
13
+ // 后续手工: 自托管锁域名字体(典型 Typekit @import) → 改写 CSS @import 为本地 → 删追踪 → 从 site/ 作 web 根服务。
14
+ // 完整配方见 references/static-mirror.md。
15
+
16
+ import { loadPlaywright, launchChromium } from "./lib/playwright-loader.mjs";
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+
20
+ function parseArgs(argv) {
21
+ const o = { url: "", out: "", scrollStep: 700, settle: 2500, maxMs: 90000, help: false };
22
+ for (let i = 0; i < argv.length; i++) {
23
+ const a = argv[i];
24
+ if (a === "--help" || a === "-h") o.help = true;
25
+ else if (a === "--url") o.url = argv[++i] || "";
26
+ else if (a === "--out") o.out = argv[++i] || "";
27
+ else if (a === "--scroll-step") o.scrollStep = parseInt(argv[++i] || "700", 10);
28
+ else if (a === "--settle") o.settle = parseInt(argv[++i] || "2500", 10);
29
+ else if (a === "--max-ms") o.maxMs = parseInt(argv[++i] || "90000", 10);
30
+ }
31
+ return o;
32
+ }
33
+
34
+ function usage() {
35
+ console.log(`mirror-site.mjs — 静态构建站全量资产镜像(1:1 忠实复刻)
36
+
37
+ node scripts/mirror-site.mjs --url <URL> --out <dir> [--scroll-step 700] [--settle 2500] [--max-ms 90000]
38
+
39
+ 适用: Astro / Vite SSG / Hugo / 任何把客户端运行时输出成可下载静态资产的站(含 WebGL/Canvas 重前端)。
40
+ 不适用: 真·服务端渲染/数据驱动 SPA(需 network-capture.mjs 做 API 替身)。
41
+ 配方与后续改写步骤(自托管字体/删追踪/服务) → references/static-mirror.md`);
42
+ }
43
+
44
+ // 同源资产 URL → 本地相对路径(去 query;目录结尾存 index.html)
45
+ function urlToLocalPath(u, origin) {
46
+ let p = u.slice(origin.length);
47
+ const q = p.indexOf("?");
48
+ if (q >= 0) p = p.slice(0, q);
49
+ if (p === "" || p.endsWith("/")) p += "index.html";
50
+ return p.replace(/^\/+/, "");
51
+ }
52
+
53
+ const args = parseArgs(process.argv.slice(2));
54
+ if (args.help || !args.url || !args.out) {
55
+ usage();
56
+ process.exit(args.help ? 0 : 1);
57
+ }
58
+
59
+ const origin = new URL(args.url).origin;
60
+ const siteDir = path.join(path.resolve(args.out), "site");
61
+ fs.mkdirSync(siteDir, { recursive: true });
62
+
63
+ const responses = new Map(); // url -> {status, type, ct}
64
+ const pw = loadPlaywright();
65
+ const browser = await launchChromium(pw.chromium);
66
+ const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
67
+ const page = await ctx.newPage();
68
+ page.on("response", (resp) => {
69
+ try {
70
+ const h = resp.headers();
71
+ responses.set(resp.url(), { status: resp.status(), type: resp.request().resourceType(), ct: h["content-type"] || "" });
72
+ } catch {}
73
+ });
74
+
75
+ console.log(`▸ 加载 + 全程滚动捕获: ${args.url}`);
76
+ await page.goto(args.url, { waitUntil: "networkidle", timeout: args.maxMs }).catch((e) => console.warn(" goto:", e.message));
77
+ const total = await page.evaluate(() => document.documentElement.scrollHeight);
78
+ for (let y = 0; y <= total; y += args.scrollStep) {
79
+ await page.evaluate((yy) => window.scrollTo(0, yy), y);
80
+ await page.waitForTimeout(180);
81
+ }
82
+ await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
83
+ await page.waitForTimeout(args.settle);
84
+ await page.evaluate(() => window.scrollTo(0, 0));
85
+ await page.waitForTimeout(1200);
86
+
87
+ const all = [...responses.entries()].map(([url, m]) => ({ url, ...m }));
88
+ const ownUrls = all.filter((r) => r.url.startsWith(origin + "/") || r.url === origin || r.url === origin + "/");
89
+
90
+ console.log(`▸ 捕获请求 ${all.length} 个;同源 ${ownUrls.length} 个,开始下载…`);
91
+ let ok = 0, fail = 0;
92
+ const failed = [];
93
+ for (const r of ownUrls) {
94
+ const rel = urlToLocalPath(r.url, origin);
95
+ const dest = path.join(siteDir, rel);
96
+ try {
97
+ const resp = await ctx.request.get(r.url); // 复用浏览器网络栈(cookie/TUN/代理一致)
98
+ if (!resp.ok()) { fail++; failed.push(`HTTP${resp.status()} ${rel}`); continue; }
99
+ const buf = await resp.body();
100
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
101
+ fs.writeFileSync(dest, buf);
102
+ ok++;
103
+ } catch (e) {
104
+ fail++; failed.push(`${e.message} ${rel}`);
105
+ }
106
+ }
107
+
108
+ // 第三方 + webfont 提示
109
+ const thirdHosts = [...new Set(all.filter((r) => !r.url.startsWith(origin)).map((r) => { try { return new URL(r.url).host; } catch { return r.url; } }))];
110
+ const webfontCss = all.map((r) => r.url).filter((u) => /use\.typekit\.net\/[a-z0-9]+\.css|fonts\.googleapis\.com\/css/i.test(u));
111
+ const outRoot = path.resolve(args.out);
112
+ fs.writeFileSync(path.join(outRoot, "mirror-manifest.json"), JSON.stringify(all, null, 2));
113
+ fs.writeFileSync(path.join(outRoot, "own-asset-urls.txt"), ownUrls.map((r) => urlToLocalPath(r.url, origin)).sort().join("\n") + "\n");
114
+ fs.writeFileSync(path.join(outRoot, "third-party.json"), JSON.stringify({ hosts: thirdHosts, webfont_css_to_selfhost: webfontCss }, null, 2));
115
+
116
+ console.log(`✅ 镜像完成: ${ok} 成功 / ${fail} 失败 → ${siteDir}`);
117
+ if (failed.length) console.log(" ⚠️ 失败:\n " + failed.slice(0, 20).join("\n "));
118
+ console.log(`▸ 第三方 host: ${thirdHosts.join(", ") || "(无)"}`);
119
+ if (webfontCss.length) console.log(`▸ 需自托管的 webfont CSS(锁域名,见 static-mirror.md): \n ${webfontCss.join("\n ")}`);
120
+ console.log(`▸ 下一步: 自托管字体 + 改写 CSS @import + 删追踪 → cd ${siteDir} && python3 -m http.server 8124`);
121
+ await browser.close();
@@ -0,0 +1,127 @@
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/network-capture.mjs --url <url> --out RECON/network [--label original] [--wait 5000] [--max-bytes 1000000]
10
+
11
+ Captures browser requests and saves JSON/text XHR/fetch responses as fixtures.
12
+ `);
13
+ }
14
+
15
+ function parseArgs(argv) {
16
+ const out = { url: "", outDir: "RECON/network", label: "site", waitMs: 5000, maxBytes: 1_000_000 };
17
+ for (let i = 0; i < argv.length; i += 1) {
18
+ const arg = argv[i];
19
+ if (arg === "--help" || arg === "-h") out.help = true;
20
+ else if (arg === "--url") out.url = argv[++i] || "";
21
+ else if (arg === "--out") out.outDir = argv[++i] || "RECON/network";
22
+ else if (arg === "--label") out.label = argv[++i] || "site";
23
+ else if (arg === "--wait") out.waitMs = Number(argv[++i] || "5000");
24
+ else if (arg === "--max-bytes") out.maxBytes = Number(argv[++i] || "1000000");
25
+ else throw new Error(`Unexpected argument: ${arg}`);
26
+ }
27
+ return out;
28
+ }
29
+
30
+ function safeFixtureName(url, contentType) {
31
+ const parsed = new URL(url);
32
+ const cleanPath = parsed.pathname.replace(/[^a-z0-9._-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "response";
33
+ const hash = crypto.createHash("sha1").update(url).digest("hex").slice(0, 10);
34
+ const ext = contentType.includes("json") ? ".json" : ".txt";
35
+ return `${cleanPath}-${hash}${ext}`;
36
+ }
37
+
38
+ function shouldSave(resourceType, contentType) {
39
+ if (!["xhr", "fetch"].includes(resourceType)) return false;
40
+ return /json|text|graphql|javascript/i.test(contentType || "");
41
+ }
42
+
43
+ try {
44
+ const args = parseArgs(process.argv.slice(2));
45
+ if (args.help || !args.url) {
46
+ usage();
47
+ process.exit(args.help ? 0 : 1);
48
+ }
49
+
50
+ const outDir = path.resolve(args.outDir);
51
+ const fixturesDir = path.join(outDir, "fixtures");
52
+ fs.mkdirSync(fixturesDir, { recursive: true });
53
+
54
+ const { chromium } = loadPlaywright();
55
+ const browser = await launchChromium(chromium);
56
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
57
+
58
+ const requests = [];
59
+ const responses = [];
60
+
61
+ page.on("request", (request) => {
62
+ requests.push({
63
+ url: request.url(),
64
+ method: request.method(),
65
+ resourceType: request.resourceType(),
66
+ headers: request.headers(),
67
+ postData: request.postData() || "",
68
+ });
69
+ });
70
+
71
+ page.on("response", async (response) => {
72
+ const request = response.request();
73
+ const headers = response.headers();
74
+ const contentType = headers["content-type"] || "";
75
+ const entry = {
76
+ url: response.url(),
77
+ status: response.status(),
78
+ ok: response.ok(),
79
+ method: request.method(),
80
+ resourceType: request.resourceType(),
81
+ contentType,
82
+ fixture: "",
83
+ error: "",
84
+ };
85
+
86
+ if (shouldSave(request.resourceType(), contentType)) {
87
+ try {
88
+ const body = await response.body();
89
+ if (body.length <= args.maxBytes) {
90
+ const file = path.join(fixturesDir, safeFixtureName(response.url(), contentType));
91
+ fs.writeFileSync(file, body);
92
+ entry.fixture = path.relative(outDir, file);
93
+ entry.bytes = body.length;
94
+ } else {
95
+ entry.error = `body too large: ${body.length}`;
96
+ }
97
+ } catch (error) {
98
+ entry.error = error.message;
99
+ }
100
+ }
101
+
102
+ responses.push(entry);
103
+ });
104
+
105
+ await page.goto(args.url, { waitUntil: "domcontentloaded", timeout: 45000 });
106
+ await page.waitForLoadState("networkidle", { timeout: args.waitMs }).catch(() => {});
107
+ if (args.waitMs > 0) await page.waitForTimeout(args.waitMs);
108
+ await browser.close();
109
+
110
+ const manifest = {
111
+ label: args.label,
112
+ url: args.url,
113
+ capturedAt: new Date().toISOString(),
114
+ requestCount: requests.length,
115
+ responseCount: responses.length,
116
+ fixtureCount: responses.filter((response) => response.fixture).length,
117
+ requests,
118
+ responses,
119
+ };
120
+
121
+ const manifestFile = path.join(outDir, `${args.label}-network.json`);
122
+ fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
123
+ console.log(manifestFile);
124
+ } catch (error) {
125
+ console.error(`network-capture failed: ${error.message}`);
126
+ process.exit(1);
127
+ }