howone 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/bin/index.mjs +1 -1
  2. package/package.json +1 -1
  3. package/templates/vite/.howone/skills/howone/01-architect/01-app-generation.md +19 -16
  4. package/templates/vite/.howone/skills/howone/03-ai-capabilities/01-ai-capability-architecture.md +14 -24
  5. package/templates/vite/.howone/skills/howone/03-ai-capabilities/02-workflow-contract-rules.md +0 -107
  6. package/templates/vite/.howone/skills/howone/03-ai-capabilities/03-service-capability-catalog.md +26 -101
  7. package/templates/vite/.howone/skills/howone/03-ai-capabilities/04-workflow-operations.md +23 -17
  8. package/templates/vite/.howone/skills/howone/03-ai-capabilities/05-ai-feature-playbooks.md +1 -66
  9. package/templates/vite/.howone/skills/howone/SKILL.md +5 -4
  10. package/templates/vite/.howone/skills/web-clone/LICENSE +0 -21
  11. package/templates/vite/.howone/skills/web-clone/README.md +0 -179
  12. package/templates/vite/.howone/skills/web-clone/SKILL.md +0 -243
  13. package/templates/vite/.howone/skills/web-clone/references/assessment.md +0 -77
  14. package/templates/vite/.howone/skills/web-clone/references/complex-playbooks.md +0 -46
  15. package/templates/vite/.howone/skills/web-clone/references/deliverables.md +0 -144
  16. package/templates/vite/.howone/skills/web-clone/references/design-dna.md +0 -125
  17. package/templates/vite/.howone/skills/web-clone/references/effect-extraction.md +0 -73
  18. package/templates/vite/.howone/skills/web-clone/references/marbles-case.md +0 -31
  19. package/templates/vite/.howone/skills/web-clone/references/reverse-engineering.md +0 -34
  20. package/templates/vite/.howone/skills/web-clone/references/static-mirror.md +0 -72
  21. package/templates/vite/.howone/skills/web-clone/scripts/asset-harvest.mjs +0 -101
  22. package/templates/vite/.howone/skills/web-clone/scripts/audit-clone.mjs +0 -151
  23. package/templates/vite/.howone/skills/web-clone/scripts/compare-recon.mjs +0 -265
  24. package/templates/vite/.howone/skills/web-clone/scripts/dna-scaffold.mjs +0 -214
  25. package/templates/vite/.howone/skills/web-clone/scripts/init-clone.mjs +0 -136
  26. package/templates/vite/.howone/skills/web-clone/scripts/interaction-probe.mjs +0 -314
  27. package/templates/vite/.howone/skills/web-clone/scripts/lib/playwright-loader.mjs +0 -39
  28. package/templates/vite/.howone/skills/web-clone/scripts/mirror-site.mjs +0 -121
  29. package/templates/vite/.howone/skills/web-clone/scripts/network-capture.mjs +0 -127
  30. package/templates/vite/.howone/skills/web-clone/scripts/recon-site.mjs +0 -235
  31. package/templates/vite/.howone/skills/web-clone/scripts/route-crawl.mjs +0 -228
  32. package/templates/vite/.howone/skills/web-clone/scripts/sourcemap-hunt.mjs +0 -112
  33. package/templates/vite/.howone/skills/web-clone/scripts/visual-diff.mjs +0 -161
@@ -1,214 +0,0 @@
1
- #!/usr/bin/env node
2
- // dna-scaffold.mjs — 生成 design-dna.json 骨架,best-effort 从 recon-site.mjs 的输出预填。
3
- // 用法:
4
- // node scripts/dna-scaffold.mjs --out <design-dna.json> [--recon <label-recon.json>] [--name <站名>]
5
- // 产物:
6
- // <out> 完整 DNA 骨架;有 --recon 时预填字体/色候选/框架特效信号,其余留 "" 待人工 Analyze。
7
- // 纪律: 只搬侦察里"真实抓到"的信号,绝不编造。拿不准角色(primary/accent)的色值统一丢进 _recon_signals 供人工指派。
8
-
9
- import fs from "node:fs";
10
- import path from "node:path";
11
-
12
- function parseArgs(argv) {
13
- const out = { recon: "", out: "", name: "", help: false };
14
- for (let i = 0; i < argv.length; i++) {
15
- const a = argv[i];
16
- if (a === "--help" || a === "-h") out.help = true;
17
- else if (a === "--recon") out.recon = argv[++i] || "";
18
- else if (a === "--out") out.out = argv[++i] || "";
19
- else if (a === "--name") out.name = argv[++i] || "";
20
- }
21
- return out;
22
- }
23
-
24
- function usage() {
25
- console.log(`dna-scaffold.mjs — 生成 design-dna.json 骨架并 best-effort 预填
26
-
27
- node scripts/dna-scaffold.mjs --out <design-dna.json> [--recon <label-recon.json>] [--name <站名>]
28
-
29
- 只用在「视觉复刻 / 内容爆改」模式。忠实复刻分支不需要 DNA(真源码即真相)。
30
- schema 与字段含义见 references/design-dna.md。`);
31
- }
32
-
33
- // 完整 DNA 骨架(与 references/design-dna.md 对齐)
34
- function skeleton(name) {
35
- const ts = () => ({ size: "", weight: "", line_height: "", tracking: "" });
36
- return {
37
- meta: { name: name || "", description: "", source_references: "", created_at: "" },
38
- design_system: {
39
- color: {
40
- palette_type: "",
41
- primary: { hex: "", role: "" },
42
- secondary: { hex: "", role: "" },
43
- accent: { hex: "", role: "" },
44
- neutral: { scale: "", usage: "" },
45
- semantic: { success: "", warning: "", error: "", info: "" },
46
- surface: { background: "", card: "", elevated: "" },
47
- contrast_strategy: "",
48
- },
49
- typography: {
50
- type_scale: {
51
- display: ts(), heading_1: ts(), heading_2: ts(), heading_3: ts(),
52
- body: ts(), body_small: ts(), caption: ts(), overline: ts(),
53
- },
54
- font_families: { heading: "", body: "", mono: "" },
55
- font_style_notes: "",
56
- },
57
- spacing: { base_unit: "", scale: "", content_density: "", section_rhythm: "" },
58
- layout: { grid_system: "", max_content_width: "", columns: "", gutter: "", breakpoints: "", alignment_tendency: "" },
59
- shape: { border_radius: { small: "", medium: "", large: "", pill: "" }, border_usage: "", divider_style: "" },
60
- elevation: { shadow_style: "", levels: { low: "", medium: "", high: "" }, depth_cues: "" },
61
- iconography: { style: "", stroke_weight: "", size_scale: "", preferred_set: "" },
62
- motion: { easing: "", duration_scale: { micro: "", normal: "", macro: "" }, entrance_pattern: "", exit_pattern: "", philosophy: "" },
63
- components: { button_style: "", input_style: "", card_style: "", navigation_pattern: "", modal_style: "", list_style: "", component_notes: "" },
64
- },
65
- design_style: {
66
- aesthetic: { mood: [], visual_metaphor: "", era_influence: "", genre: "", personality_traits: [], adjectives: [] },
67
- visual_language: { complexity: "", ornamentation: "", whitespace_usage: "", visual_weight_distribution: "", focal_strategy: "", contrast_level: "", texture_usage: "" },
68
- composition: { hierarchy_method: "", balance_type: "", flow_direction: "", grouping_strategy: "", negative_space_role: "" },
69
- imagery: { photo_treatment: "", illustration_style: "", graphic_elements: "", pattern_usage: "", image_shape: "" },
70
- interaction_feel: { feedback_style: "", hover_behavior: "", transition_personality: "", loading_style: "", microinteraction_density: "" },
71
- brand_voice_in_ui: { tone: "", formality: "", cta_style: "", empty_state_approach: "", error_tone: "" },
72
- },
73
- visual_effects: {
74
- overview: { effect_intensity: "", performance_tier: "", fallback_strategy: "", primary_technology: "" },
75
- background_effects: { type: "", description: "", technology: "", params: { color_palette: "", speed: "", density: "", opacity: "", blend_mode: "" } },
76
- particle_systems: { enabled: false, type: "", description: "", technology: "", params: { count: "", shape: "", size_range: "", movement_pattern: "", color_behavior: "", interaction: "", spawn_area: "" } },
77
- "3d_elements": { enabled: false, type: "", description: "", technology: "", params: { renderer: "", lighting: "", camera: "", materials: "", geometry: "", post_processing: [], interaction_model: "" } },
78
- shader_effects: { enabled: false, type: "", description: "", technology: "", params: { uniforms: "", vertex_manipulation: "", fragment_output: "", noise_type: "", distortion: "" } },
79
- scroll_effects: { parallax: { enabled: false, layers: "", depth_range: "", speed_curve: "" }, scroll_triggered_animations: { enabled: false, trigger_points: "", animation_type: "", scrub_behavior: "" }, scroll_morphing: { enabled: false, description: "" } },
80
- text_effects: { type: "", description: "", technology: "", params: { split_strategy: "", animation_per_unit: "", stagger: "", effect_style: "" } },
81
- cursor_effects: { enabled: false, type: "", description: "", params: { shape: "", size: "", blend_mode: "", trail: "", interaction_zone: "" } },
82
- image_effects: { type: "", description: "", technology: "", params: { filter_pipeline: "", hover_transform: "", reveal_animation: "", distortion_type: "" } },
83
- glassmorphism_neumorphism: { enabled: false, style: "", params: { blur_radius: "", transparency: "", border_treatment: "", shadow_type: "", light_source_angle: "" } },
84
- canvas_drawings: { enabled: false, type: "", description: "", technology: "", params: { draw_method: "", animation_loop: "", color_scheme: "", responsiveness: "", interaction: "" } },
85
- svg_animations: { enabled: false, type: "", description: "", params: { animation_method: "", path_morphing: "", stroke_animation: "", filter_effects: "" } },
86
- composite_notes: "",
87
- },
88
- };
89
- }
90
-
91
- const COLOR_RE = /(#[0-9a-fA-F]{3,8}\b|\brgba?\([^)]*\)|\bhsla?\([^)]*\))/;
92
-
93
- // recon-site.mjs 把信号按视口嵌在 captures[].signals 下;取最宽视口的 signals 摊平,
94
- // 兼容已是扁平结构的 recon 输入。摊平失败则原样返回(enrich 对缺字段已容错)。
95
- function flattenRecon(recon) {
96
- if (recon && Array.isArray(recon.captures) && recon.captures.length) {
97
- const widest = recon.captures
98
- .filter((c) => c && c.signals)
99
- .sort((a, b) => (b?.viewport?.width || 0) - (a?.viewport?.width || 0))[0];
100
- if (widest && widest.signals) {
101
- // 顶层 url 兜底进 href,便于 meta.source_references 预填
102
- return { href: recon.url, ...widest.signals };
103
- }
104
- }
105
- return recon;
106
- }
107
-
108
- function uniq(arr) {
109
- return Array.from(new Set(arr.filter(Boolean)));
110
- }
111
-
112
- // 从 recon JSON best-effort 抽信号,并预填 skeleton 的明确字段
113
- function enrich(dna, recon) {
114
- const signals = { fonts: [], color_candidates: [], frameworks: {}, canvas_count: 0, css_color_vars: [] };
115
-
116
- // 字体: fonts[] + sections[].style.fontFamily
117
- const fontList = uniq([
118
- ...(Array.isArray(recon.fonts) ? recon.fonts : []),
119
- ...((recon.sections || []).map((s) => s?.style?.fontFamily).filter(Boolean)),
120
- ]).map((f) => String(f).replace(/^["']|["']$/g, "").split(",")[0].trim()).filter(Boolean);
121
- signals.fonts = uniq(fontList);
122
- if (signals.fonts.length) {
123
- const mono = signals.fonts.find((f) => /mono|code|consol|courier/i.test(f)) || "";
124
- const nonMono = signals.fonts.filter((f) => f !== mono);
125
- dna.design_system.typography.font_families.heading = nonMono[0] || "";
126
- dna.design_system.typography.font_families.body = nonMono[1] || nonMono[0] || "";
127
- dna.design_system.typography.font_families.mono = mono;
128
- }
129
-
130
- // 颜色: CSS 变量里像颜色的 + sections 的 bg/color
131
- const cssVars = Array.isArray(recon.cssVariables) ? recon.cssVariables : [];
132
- for (const pair of cssVars) {
133
- const [name, val] = Array.isArray(pair) ? pair : [pair?.name, pair?.value];
134
- if (val && COLOR_RE.test(String(val))) signals.css_color_vars.push(`${name}: ${String(val).trim()}`);
135
- }
136
- const sectionColors = [];
137
- for (const s of recon.sections || []) {
138
- const bg = s?.style?.backgroundColor;
139
- const fg = s?.style?.color;
140
- if (bg && !/rgba?\(0, 0, 0, 0\)|transparent/i.test(bg)) sectionColors.push(bg);
141
- if (fg) sectionColors.push(fg);
142
- }
143
- signals.color_candidates = uniq([
144
- ...signals.css_color_vars.map((v) => v.split(":").slice(1).join(":").trim()),
145
- ...sectionColors,
146
- ]).slice(0, 24);
147
- // body/header 背景作为 surface.background 候选(第一个非透明的 section bg)
148
- const firstBg = (recon.sections || []).map((s) => s?.style?.backgroundColor)
149
- .find((c) => c && !/rgba?\(0, 0, 0, 0\)|transparent/i.test(c));
150
- if (firstBg) dna.design_system.color.surface.background = firstBg;
151
-
152
- // 框架/特效信号
153
- const fw = recon.frameworks || {};
154
- signals.frameworks = fw;
155
- signals.canvas_count = (recon.canvases && recon.canvases.length) || recon?.counts?.canvas || 0;
156
-
157
- if (fw.three) {
158
- dna.visual_effects.overview.primary_technology = "WebGL/Three.js";
159
- dna.visual_effects.overview.performance_tier = "heavy";
160
- dna.visual_effects["3d_elements"].enabled = true;
161
- dna.visual_effects["3d_elements"].technology = "Three.js";
162
- } else if (signals.canvas_count > 0) {
163
- dna.visual_effects.overview.primary_technology = "Canvas 2D";
164
- dna.visual_effects.canvas_drawings.enabled = true;
165
- } else if (fw.gsap) {
166
- dna.visual_effects.overview.primary_technology = "GSAP";
167
- }
168
- if (fw.gsap || fw.lenis) {
169
- dna.visual_effects.scroll_effects.scroll_triggered_animations.enabled = true;
170
- dna.visual_effects.scroll_effects.scroll_triggered_animations.scrub_behavior =
171
- fw.lenis ? "lenis smooth-scroll detected" : "gsap detected";
172
- }
173
-
174
- // meta 预填
175
- if (recon.href) dna.meta.source_references = recon.href;
176
- if (!dna.meta.name && recon.title) dna.meta.name = recon.title;
177
-
178
- // 把原始信号留在顶层供人工指派角色(不编造 primary/accent)
179
- dna._recon_signals = signals;
180
- dna._scaffold_note =
181
- "best-effort 预填来自 recon。font_families/surface.background/visual_effects 已据真实信号填写;" +
182
- "color 的 primary/secondary/accent 角色需人工从 _recon_signals.color_candidates 指派;" +
183
- "所有 \"\" 字段需人工 Analyze 补全(见 references/design-dna.md)。确认无误后可删除 _recon_signals 与本说明。";
184
- return dna;
185
- }
186
-
187
- try {
188
- const args = parseArgs(process.argv.slice(2));
189
- if (args.help || !args.out) {
190
- usage();
191
- process.exit(args.help ? 0 : 1);
192
- }
193
- let dna = skeleton(args.name);
194
- if (args.recon) {
195
- try {
196
- const recon = JSON.parse(fs.readFileSync(path.resolve(args.recon), "utf8"));
197
- dna = enrich(dna, flattenRecon(recon));
198
- } catch (e) {
199
- console.warn(`⚠️ 读 recon 失败(${e.message}),只输出空骨架。`);
200
- }
201
- }
202
- const outPath = path.resolve(args.out);
203
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
204
- fs.writeFileSync(outPath, `${JSON.stringify(dna, null, 2)}\n`);
205
- console.log(`✅ design-dna 骨架已写入: ${outPath}`);
206
- if (dna._recon_signals) {
207
- const s = dna._recon_signals;
208
- console.log(` 预填: 字体 ${s.fonts.length} 个 / 色候选 ${s.color_candidates.length} 个 / canvas ${s.canvas_count} / three=${!!s.frameworks.three} gsap=${!!s.frameworks.gsap} lenis=${!!s.frameworks.lenis}`);
209
- }
210
- console.log(` 下一步: 人工 Analyze 补全 "",并从 _recon_signals 指派颜色角色。schema → references/design-dna.md`);
211
- } catch (e) {
212
- console.error(`dna-scaffold 失败: ${e.message}`);
213
- process.exit(1);
214
- }
@@ -1,136 +0,0 @@
1
- #!/usr/bin/env node
2
- import fs from "node:fs";
3
- import os from "node:os";
4
- import path from "node:path";
5
-
6
- function usage() {
7
- console.log(`Usage:
8
- node scripts/init-clone.mjs <slug> [--url <url>] [--mode <mode>] [--level <L1-L6>]
9
-
10
- Creates:
11
- ~/projects/website-clones/<slug>-clone/
12
- ~/projects/website-clones/<slug>-clone/NOTES.md
13
- ~/projects/website-clones/<slug>-clone/RECON/screenshots/
14
- `);
15
- }
16
-
17
- function parseArgs(argv) {
18
- const out = { slug: null, url: "", mode: "", level: "" };
19
- for (let i = 0; i < argv.length; i += 1) {
20
- const arg = argv[i];
21
- if (arg === "--help" || arg === "-h") out.help = true;
22
- else if (arg === "--url") out.url = argv[++i] || "";
23
- else if (arg === "--mode") out.mode = argv[++i] || "";
24
- else if (arg === "--level") out.level = argv[++i] || "";
25
- else if (!out.slug) out.slug = arg;
26
- else throw new Error(`Unexpected argument: ${arg}`);
27
- }
28
- return out;
29
- }
30
-
31
- function cleanSlug(input) {
32
- return input
33
- .trim()
34
- .toLowerCase()
35
- .replace(/https?:\/\//g, "")
36
- .replace(/[^a-z0-9]+/g, "-")
37
- .replace(/^-+|-+$/g, "");
38
- }
39
-
40
- function notesTemplate({ name, url, mode, level }) {
41
- return `# ${name} · 克隆笔记
42
-
43
- ## 源信息
44
- - 原站 URL: ${url}
45
- - 源码仓库:
46
- - 原作者:
47
- - 许可证:
48
- - 致谢要求:
49
-
50
- ## 技术栈
51
- - 框架 / 关键库 / Node 版本:
52
-
53
- ## 复刻前预判
54
- - 复杂度等级: ${level}
55
- - 推荐模式: ${mode}
56
- - 可高保真的部分:
57
- - 需要近似或替代的部分:
58
- - 不克隆的部分:
59
- - 主要风险:
60
-
61
- ## 跑起来
62
- \`\`\`bash
63
- cd ~/projects/website-clones/${name}
64
- python3 -m http.server 8123
65
- \`\`\`
66
-
67
- ## 改了什么(对照原版)
68
- -
69
-
70
- ## 原站 vs 克隆站
71
- | 模块 | 原站表现 | 克隆实现 | 差异 / 取舍 | 证据 |
72
- |---|---|---|---|---|
73
- | 首屏 | | | | |
74
- | 导航 | | | | |
75
- | 核心动效 | | | | |
76
- | 内容区块 | | | | |
77
- | 移动端 | | | | |
78
-
79
- ## 复刻评分
80
- - 源证据: /5
81
- - 结构保真: /5
82
- - 视觉保真: /5
83
- - 动效/交互: /5
84
- - 响应式: /5
85
- - 功能完整: /5
86
- - 内容替换: /5
87
- - 法务/部署风险: /5
88
- - 总评:
89
-
90
- ## 替换地图(要换什么改哪)
91
- - 文字 -> 文件 行
92
- - 图片/媒体 -> 目录
93
- - 配色 -> CSS 变量 / theme
94
- - 3D 模型 / 字体 ->
95
-
96
- ## 验证
97
- - [ ] 本地跑通、console 0 error
98
- - [ ] 截图对照原站(RECON/screenshots/)
99
- - 验证不了的点(如实记,别伪造):
100
- `;
101
- }
102
-
103
- try {
104
- const args = parseArgs(process.argv.slice(2));
105
- if (args.help || !args.slug) {
106
- usage();
107
- process.exit(args.help ? 0 : 1);
108
- }
109
-
110
- const slug = cleanSlug(args.slug);
111
- if (!slug) throw new Error("Slug is empty after normalization.");
112
- const name = slug.endsWith("-clone") ? slug : `${slug}-clone`;
113
- const root = path.join(os.homedir(), "projects", "website-clones");
114
- const project = path.join(root, name);
115
-
116
- if (fs.existsSync(project)) {
117
- throw new Error(`Project already exists: ${project}`);
118
- }
119
-
120
- fs.mkdirSync(path.join(project, "RECON", "screenshots"), { recursive: true });
121
- fs.writeFileSync(
122
- path.join(project, "NOTES.md"),
123
- notesTemplate({
124
- name,
125
- url: args.url,
126
- mode: args.mode,
127
- level: args.level,
128
- })
129
- );
130
- fs.writeFileSync(path.join(project, ".gitignore"), "node_modules/\n.DS_Store\n");
131
-
132
- console.log(project);
133
- } catch (error) {
134
- console.error(`init-clone failed: ${error.message}`);
135
- process.exit(1);
136
- }
@@ -1,314 +0,0 @@
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
- }