@shiplens/cli 1.4.5 → 1.4.7
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/lib/assets/actions.json +2 -2
- package/lib/assets/skill.js +1 -1
- package/lib/cli.js +2 -2
- package/lib/commands/context.js +20 -2
- package/lib/commands/init.js +16 -2
- package/lib/context-generator.js +175 -0
- package/lib/extractor.js +609 -0
- package/lib/scanner.js +152 -0
- package/package.json +9 -4
- package/prompts/prompts_cli_en-US.md +2 -2
package/lib/assets/actions.json
CHANGED
|
@@ -693,7 +693,7 @@
|
|
|
693
693
|
},
|
|
694
694
|
{
|
|
695
695
|
"id": "uninstall_cli",
|
|
696
|
-
"title": "Delete Shiplens CLI",
|
|
696
|
+
"title": "Delete Shiplens CLI and Restore Project",
|
|
697
697
|
"suffix": "None",
|
|
698
698
|
"category": "Initial Setup",
|
|
699
699
|
"prompt": "Please prioritize calling Shiplens CLI (Action: uninstall_cli) to execute the above requirements. - Windows: npm.cmd uninstall -g @shiplens/cli - macOS/Linux: npm uninstall -g @shiplens/cli",
|
|
@@ -710,7 +710,7 @@
|
|
|
710
710
|
"prompt": "Use Shiplens CLI to map pages, features, and button layouts into `.shiplens/contexts/<app_id>.md` so AI analytics can understand the business context behind each metric:",
|
|
711
711
|
"steps": [
|
|
712
712
|
"1. Check `./.shiplens.json` for current app_id and project name.",
|
|
713
|
-
"2.
|
|
713
|
+
"2. Run AST static code scan across frontend code and routes to extract page purposes, feature copy, interactive buttons, test IDs, selectors, and exact source code locations.",
|
|
714
714
|
"3. Write structured details into `.shiplens/contexts/<app_id>.md`, binding app_id and project name in the header."
|
|
715
715
|
],
|
|
716
716
|
"commands": [],
|
package/lib/assets/skill.js
CHANGED
|
@@ -46,7 +46,7 @@ Backup mirror fallback: add \`--registry=https://registry.npmmirror.com\`
|
|
|
46
46
|
2. Install \`@shiplens/sdk\`
|
|
47
47
|
3. Inject tracking code into entry file
|
|
48
48
|
4. Register project via \`POST /api/connect\` → receive \`app_id\` + \`dashboard_url\`
|
|
49
|
-
5.
|
|
49
|
+
5. AST static source scan (pages, buttons, routes) → generate \`.shiplens/contexts/<app_id>.md\` UI business dictionary
|
|
50
50
|
6. Write local state machine \`.shiplens.json\`
|
|
51
51
|
7. Inject this AI Skill file and Agent rules
|
|
52
52
|
|
package/lib/cli.js
CHANGED
|
@@ -16,7 +16,7 @@ const { handleContext } = require('./commands/context');
|
|
|
16
16
|
const { handleMcp } = require('./commands/mcp');
|
|
17
17
|
const { handleAction } = require('./commands/action');
|
|
18
18
|
|
|
19
|
-
let VERSION = '1.4.
|
|
19
|
+
let VERSION = '1.4.7';
|
|
20
20
|
try {
|
|
21
21
|
const pkg = require('../package.json');
|
|
22
22
|
if (pkg.version) VERSION = pkg.version;
|
|
@@ -90,7 +90,7 @@ Core Commands:
|
|
|
90
90
|
heatmap Retrieve click heatmaps and skeleton wireframes
|
|
91
91
|
dashboards AI dashboard management and one-click creation (list, create [--ai])
|
|
92
92
|
doctor Run full end-to-end diagnostics on SDK, credentials, and network
|
|
93
|
-
context Manage business context dictionary (push, pull, show)
|
|
93
|
+
context Manage business context dictionary (generate, push, pull, show)
|
|
94
94
|
action Retrieve deterministic steps, commands, and theory for analysis actions (list, <action_id>)
|
|
95
95
|
mcp serve Run local MCP proxy server with device credentials
|
|
96
96
|
|
package/lib/commands/context.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const { getLocalConfig } = require('../config');
|
|
4
|
+
const { generateProjectContext } = require('../context-generator');
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Get standard storage path for business context: .shiplens/contexts/<app_id>.md
|
|
@@ -37,7 +38,7 @@ async function executeWithSafeFallback(fn, maxRetries = 2, baseMs = 500) {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
|
-
* Handle context command (push, pull, show)
|
|
41
|
+
* Handle context command (generate, push, pull, show)
|
|
41
42
|
*/
|
|
42
43
|
async function handleContext(subcommand, args, flags, ctx) {
|
|
43
44
|
const targetSub = subcommand || 'show';
|
|
@@ -47,6 +48,23 @@ async function handleContext(subcommand, args, flags, ctx) {
|
|
|
47
48
|
const projectName = flags.name || localCfg.project_name || '';
|
|
48
49
|
|
|
49
50
|
switch (targetSub) {
|
|
51
|
+
case 'generate':
|
|
52
|
+
case 'scan':
|
|
53
|
+
case 'extract': {
|
|
54
|
+
const genResult = generateProjectContext(process.cwd(), appId, projectName, flags.framework || localCfg.framework);
|
|
55
|
+
ctx.output({
|
|
56
|
+
ok: true,
|
|
57
|
+
action: 'generate',
|
|
58
|
+
app_id: appId,
|
|
59
|
+
file: genResult.filePath,
|
|
60
|
+
rel_file: genResult.relPath,
|
|
61
|
+
pages_count: genResult.totalPages,
|
|
62
|
+
buttons_count: genResult.totalButtons,
|
|
63
|
+
message: `Project business context scanned and saved to ${genResult.relPath} (${genResult.totalPages} pages, ${genResult.totalButtons} buttons).`,
|
|
64
|
+
});
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
case 'push': {
|
|
51
69
|
if (!fs.existsSync(filePath)) {
|
|
52
70
|
const displayPath = path.relative(process.cwd(), filePath) || filePath;
|
|
@@ -173,7 +191,7 @@ async function handleContext(subcommand, args, flags, ctx) {
|
|
|
173
191
|
}
|
|
174
192
|
|
|
175
193
|
default:
|
|
176
|
-
throw new Error(`Unknown context subcommand: ${targetSub}. Supported: push, pull, show`);
|
|
194
|
+
throw new Error(`Unknown context subcommand: ${targetSub}. Supported: generate, push, pull, show`);
|
|
177
195
|
}
|
|
178
196
|
}
|
|
179
197
|
|
package/lib/commands/init.js
CHANGED
|
@@ -3,6 +3,7 @@ const { detectProject, detectExistingApp, injectSDK, injectSkill, installSDKDepe
|
|
|
3
3
|
const { saveLocalConfig } = require('../config');
|
|
4
4
|
const { saveDeviceEnv } = require('../device-env');
|
|
5
5
|
const { resolveTaxonomyFromIDs, formatTaxonomySummary } = require('../taxonomy');
|
|
6
|
+
const { generateProjectContext } = require('../context-generator');
|
|
6
7
|
|
|
7
8
|
async function handleInit(args, flags, ctx) {
|
|
8
9
|
const start = Date.now();
|
|
@@ -124,7 +125,13 @@ async function handleInit(args, flags, ctx) {
|
|
|
124
125
|
last_synced_at: new Date().toISOString(),
|
|
125
126
|
});
|
|
126
127
|
|
|
127
|
-
// 6.
|
|
128
|
+
// 6. Automatically scan page structures & generate UI business context (.shiplens/contexts/<app_id>.md)
|
|
129
|
+
let contextResult = null;
|
|
130
|
+
try {
|
|
131
|
+
contextResult = generateProjectContext(wd, appId, projectName, framework);
|
|
132
|
+
} catch (e) {}
|
|
133
|
+
|
|
134
|
+
// 7. Install SDK dependency
|
|
128
135
|
let installResult = { success: true, manager: 'skipped' };
|
|
129
136
|
if (!flags['no-install'] && detected.package_manager) {
|
|
130
137
|
installResult = await installSDKDependency(wd, detected.package_manager);
|
|
@@ -152,7 +159,7 @@ async function handleInit(args, flags, ctx) {
|
|
|
152
159
|
}
|
|
153
160
|
}
|
|
154
161
|
|
|
155
|
-
//
|
|
162
|
+
// 8. Bind email if specified
|
|
156
163
|
let targetEmail = flags.email;
|
|
157
164
|
if (targetEmail === 'auto') {
|
|
158
165
|
targetEmail = getGitEmail();
|
|
@@ -195,6 +202,10 @@ async function handleInit(args, flags, ctx) {
|
|
|
195
202
|
sdk_manager: installResult.manager,
|
|
196
203
|
skill_injected: true,
|
|
197
204
|
skill_file: skillFile,
|
|
205
|
+
context_generated: !!contextResult,
|
|
206
|
+
context_file: contextResult ? contextResult.relPath : undefined,
|
|
207
|
+
context_pages_count: contextResult ? contextResult.totalPages : 0,
|
|
208
|
+
context_buttons_count: contextResult ? contextResult.totalButtons : 0,
|
|
198
209
|
package_manager: detected.package_manager,
|
|
199
210
|
auth_status: authStatus,
|
|
200
211
|
shiplens_env_written: authStatus === 'magic_link_sent',
|
|
@@ -227,6 +238,9 @@ async function handleInit(args, flags, ctx) {
|
|
|
227
238
|
console.log(`Live Dashboard / Activation URL: 🔗 ${dashboardUrl}`);
|
|
228
239
|
console.log(`User Account: ${accountStatusText}`);
|
|
229
240
|
console.log(`🧠 AI Skill Ready: ${skillFile}`);
|
|
241
|
+
if (contextResult && contextResult.relPath) {
|
|
242
|
+
console.log(`📑 Business Context: ${contextResult.relPath} (${contextResult.totalPages} pages, ${contextResult.totalButtons} buttons mapped)`);
|
|
243
|
+
}
|
|
230
244
|
console.log(`🔒 [SHIPLENS_ATOMIC_COMPLETED] Setup completed atomically. No extra commands needed.`);
|
|
231
245
|
console.log(`==================================================\n`);
|
|
232
246
|
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { detectProjectPages } = require('./scanner');
|
|
4
|
+
const { extractPageSkeleton } = require('./extractor');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Infer page purpose from headings, descriptions, and route name
|
|
8
|
+
*/
|
|
9
|
+
function inferPagePurpose(page, headings, descriptions, faqs) {
|
|
10
|
+
if (descriptions && descriptions.length > 0) {
|
|
11
|
+
return descriptions[0];
|
|
12
|
+
}
|
|
13
|
+
if (headings && headings.length > 0) {
|
|
14
|
+
return headings.join(' / ');
|
|
15
|
+
}
|
|
16
|
+
if (page.path === '/') {
|
|
17
|
+
return 'Primary landing / core product overview page';
|
|
18
|
+
}
|
|
19
|
+
const cleanName = page.name.replace(/[^a-zA-Z0-9_\- ]/g, '');
|
|
20
|
+
return `Core ${cleanName} feature page`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Infer intent and next step for interactive controls
|
|
25
|
+
*/
|
|
26
|
+
function inferButtonIntent(btn, page) {
|
|
27
|
+
if (btn.action_hint) {
|
|
28
|
+
return btn.action_hint;
|
|
29
|
+
}
|
|
30
|
+
const t = (btn.text || '').toLowerCase();
|
|
31
|
+
if (t.includes('start') || t.includes('free') || t.includes('try') || t.includes('get started') || t.includes('免费') || t.includes('试用')) {
|
|
32
|
+
return 'Primary conversion CTA / onboarding initiation';
|
|
33
|
+
}
|
|
34
|
+
if (t.includes('sign up') || t.includes('register') || t.includes('注册')) {
|
|
35
|
+
return 'User account registration';
|
|
36
|
+
}
|
|
37
|
+
if (t.includes('login') || t.includes('sign in') || t.includes('登录')) {
|
|
38
|
+
return 'User authentication login';
|
|
39
|
+
}
|
|
40
|
+
if (t.includes('upgrade') || t.includes('buy') || t.includes('pro') || t.includes('pricing') || t.includes('订阅') || t.includes('购买')) {
|
|
41
|
+
return 'Monetization checkout / subscription upgrade';
|
|
42
|
+
}
|
|
43
|
+
if (t.includes('save') || t.includes('submit') || t.includes('保存') || t.includes('提交')) {
|
|
44
|
+
return 'Save settings or submit form data';
|
|
45
|
+
}
|
|
46
|
+
if (t.includes('delete') || t.includes('remove') || t.includes('删除')) {
|
|
47
|
+
return 'Delete or purge data item';
|
|
48
|
+
}
|
|
49
|
+
if (t.includes('calculate') || t.includes('search') || t.includes('filter') || t.includes('计算') || t.includes('查询')) {
|
|
50
|
+
return 'Execute calculation / filter workflow';
|
|
51
|
+
}
|
|
52
|
+
return `Trigger ${btn.tag} interaction on ${page.path}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Generate standard business context markdown and write to .shiplens/contexts/<app_id>.md
|
|
57
|
+
* @param {string} dir Project root directory
|
|
58
|
+
* @param {string} appId Target App ID
|
|
59
|
+
* @param {string} projectName Project Name
|
|
60
|
+
* @param {string} framework Framework detected
|
|
61
|
+
* @returns {{ filePath: string, relPath: string, totalPages: number, totalButtons: number, markdown: string }}
|
|
62
|
+
*/
|
|
63
|
+
function generateProjectContext(dir = process.cwd(), appId, projectName = 'My App', framework = 'custom') {
|
|
64
|
+
const { framework: detectedFramework, pages } = detectProjectPages(dir);
|
|
65
|
+
const activeFramework = framework || detectedFramework;
|
|
66
|
+
|
|
67
|
+
const pageDetails = [];
|
|
68
|
+
let totalButtonsCount = 0;
|
|
69
|
+
|
|
70
|
+
for (const p of pages) {
|
|
71
|
+
const skeleton = extractPageSkeleton(p.filePath, p.path, p.name, dir);
|
|
72
|
+
const purpose = inferPagePurpose(p, skeleton.headings, skeleton.descriptions, skeleton.faqs);
|
|
73
|
+
totalButtonsCount += skeleton.raw_buttons.length;
|
|
74
|
+
pageDetails.push({
|
|
75
|
+
...p,
|
|
76
|
+
relFilePath: path.relative(dir, p.filePath).replace(/\\/g, '/'),
|
|
77
|
+
purpose,
|
|
78
|
+
headings: skeleton.headings,
|
|
79
|
+
faqs: skeleton.faqs,
|
|
80
|
+
feature_bullets: skeleton.feature_bullets,
|
|
81
|
+
descriptions: skeleton.descriptions,
|
|
82
|
+
buttons: skeleton.raw_buttons.map((b) => ({
|
|
83
|
+
...b,
|
|
84
|
+
intent: inferButtonIntent(b, p),
|
|
85
|
+
})),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
|
+
const mdLines = [
|
|
91
|
+
'# Shiplens Project Business Context & UI Dictionary',
|
|
92
|
+
'',
|
|
93
|
+
`> **Project Name**: ${projectName}`,
|
|
94
|
+
`> **App ID**: ${appId}`,
|
|
95
|
+
`> **Framework**: ${activeFramework}`,
|
|
96
|
+
`> **Generated At**: ${now}`,
|
|
97
|
+
'',
|
|
98
|
+
'---',
|
|
99
|
+
'',
|
|
100
|
+
'## 1. Page & Route Catalog',
|
|
101
|
+
'',
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
if (pageDetails.length === 0) {
|
|
105
|
+
mdLines.push('*No frontend template pages detected during initial static scanning.*');
|
|
106
|
+
mdLines.push('');
|
|
107
|
+
} else {
|
|
108
|
+
for (const p of pageDetails) {
|
|
109
|
+
mdLines.push(`### Page: ${p.name} (\`${p.path}\`)`);
|
|
110
|
+
mdLines.push(`- **Source File**: [\`${p.relFilePath}\`](file:///${path.resolve(dir, p.relFilePath).replace(/\\/g, '/')})`);
|
|
111
|
+
mdLines.push(`- **Page Purpose**: ${p.purpose}`);
|
|
112
|
+
|
|
113
|
+
if (p.headings.length > 0) {
|
|
114
|
+
mdLines.push(`- **Key Headings**: ${p.headings.map((h) => `\`${h}\``).join(', ')}`);
|
|
115
|
+
}
|
|
116
|
+
if (p.feature_bullets.length > 0) {
|
|
117
|
+
mdLines.push('- **Feature Highlights**:');
|
|
118
|
+
for (const f of p.feature_bullets.slice(0, 5)) {
|
|
119
|
+
mdLines.push(` - ${f}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (p.faqs.length > 0) {
|
|
123
|
+
mdLines.push('- **FAQ Context**:');
|
|
124
|
+
for (const f of p.faqs.slice(0, 3)) {
|
|
125
|
+
mdLines.push(` - Q: ${f.q}${f.a ? ` -> A: ${f.a}` : ''}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (p.buttons.length > 0) {
|
|
130
|
+
mdLines.push('');
|
|
131
|
+
mdLines.push(`#### Interactive Elements & Buttons (${p.buttons.length}):`);
|
|
132
|
+
mdLines.push('| Button ID | Text / Label | Selector | Source Location | Inferred Action / Intent |');
|
|
133
|
+
mdLines.push('| :--- | :--- | :--- | :--- | :--- |');
|
|
134
|
+
for (const b of p.buttons) {
|
|
135
|
+
const textDisplay = (b.text || '').replace(/\|/g, '\\|').trim() || '(icon/action)';
|
|
136
|
+
const locDisplay = b.source_loc ? `\`${b.source_loc}\`` : '-';
|
|
137
|
+
mdLines.push(`| \`${b.id}\` | ${textDisplay} | \`${b.selector}\` | ${locDisplay} | ${b.intent} |`);
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
mdLines.push('- **Interactive Elements**: *(No buttons or interactive controls found on this page)*');
|
|
141
|
+
}
|
|
142
|
+
mdLines.push('');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
mdLines.push('---');
|
|
147
|
+
mdLines.push('');
|
|
148
|
+
mdLines.push('## 2. Business Metric Grounding');
|
|
149
|
+
mdLines.push(`- **Total Pages Mapped**: ${pageDetails.length}`);
|
|
150
|
+
mdLines.push(`- **Total Interactive Controls**: ${totalButtonsCount}`);
|
|
151
|
+
mdLines.push('- **Usage**: When analyzing drop-offs, funnels, or heatmaps, reference the Button IDs and Page Purposes above to translate technical telemetry into actionable product insights.');
|
|
152
|
+
mdLines.push('');
|
|
153
|
+
|
|
154
|
+
const markdownContent = mdLines.join('\n');
|
|
155
|
+
|
|
156
|
+
// Standard storage path: .shiplens/contexts/<app_id>.md
|
|
157
|
+
const contextDir = path.join(dir, '.shiplens', 'contexts');
|
|
158
|
+
fs.mkdirSync(contextDir, { recursive: true });
|
|
159
|
+
const targetFilePath = path.join(contextDir, `${appId}.md`);
|
|
160
|
+
fs.writeFileSync(targetFilePath, markdownContent, 'utf8');
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
filePath: targetFilePath,
|
|
164
|
+
relPath: path.relative(dir, targetFilePath).replace(/\\/g, '/'),
|
|
165
|
+
totalPages: pageDetails.length,
|
|
166
|
+
totalButtons: totalButtonsCount,
|
|
167
|
+
markdown: markdownContent,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
generateProjectContext,
|
|
173
|
+
inferPagePurpose,
|
|
174
|
+
inferButtonIntent,
|
|
175
|
+
};
|
package/lib/extractor.js
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const parser = require('@babel/parser');
|
|
4
|
+
const traverse = require('@babel/traverse').default || require('@babel/traverse');
|
|
5
|
+
const t = require('@babel/types');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 清洗代码字符串,去除注释、import、CSS-in-JS 与复杂逻辑噪音
|
|
9
|
+
* @param {string} rawCode 原始文件代码
|
|
10
|
+
* @returns {string} 精简后的模板与文案字符串
|
|
11
|
+
*/
|
|
12
|
+
function cleanCode(rawCode) {
|
|
13
|
+
return rawCode
|
|
14
|
+
// 移除多行注释与 JSDoc
|
|
15
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
16
|
+
// 移除单行注释
|
|
17
|
+
.replace(/\/\/.*$/gm, '')
|
|
18
|
+
// 移除 import 语句
|
|
19
|
+
.replace(/^import\s+[\s\S]*?from\s+['"].*?['"];?/gm, '')
|
|
20
|
+
// 移除 require 语句
|
|
21
|
+
.replace(/const\s+.*?=\s*require\(.*?\);?/gm, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 深度提取页面内的多维富文本文案:
|
|
26
|
+
* 包含:H1~H6 标题、FAQ 折叠问答、套餐权益列表 (li)、卡片段落描述 (p)、Badge 标签
|
|
27
|
+
* @param {string} code 清洗后的代码
|
|
28
|
+
* @returns {{ headings: string[], faqs: Array<{ q: string, a?: string }>, feature_bullets: string[], descriptions: string[] }}
|
|
29
|
+
*/
|
|
30
|
+
function extractRichPageContent(code) {
|
|
31
|
+
const headings = [];
|
|
32
|
+
const faqs = [];
|
|
33
|
+
const featureBullets = [];
|
|
34
|
+
const descriptions = [];
|
|
35
|
+
|
|
36
|
+
// 1. 提取 H1 到 H6 标题
|
|
37
|
+
const headingRegex = /<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/gi;
|
|
38
|
+
let match;
|
|
39
|
+
while ((match = headingRegex.exec(code)) !== null) {
|
|
40
|
+
const text = cleanTagContent(match[1]);
|
|
41
|
+
if (text && text.length > 1 && text.length < 120 && !headings.includes(text)) {
|
|
42
|
+
headings.push(text);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 2. 提取 FAQ 问答结构 (<details><summary> 或常见 FAQ 容器)
|
|
47
|
+
const detailsRegex = /<details[^>]*>[\s\S]*?<summary[^>]*>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/gi;
|
|
48
|
+
while ((match = detailsRegex.exec(code)) !== null) {
|
|
49
|
+
const question = cleanTagContent(match[1]);
|
|
50
|
+
const answer = cleanTagContent(match[2]);
|
|
51
|
+
if (question && question.length > 2) {
|
|
52
|
+
faqs.push({
|
|
53
|
+
q: question,
|
|
54
|
+
...(answer ? { a: answer.slice(0, 150) } : {})
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 匹配 dt/dd 键值问答
|
|
60
|
+
const dtRegex = /<dt[^>]*>([\s\S]*?)<\/dt>[\s\S]*?<dd[^>]*>([\s\S]*?)<\/dd>/gi;
|
|
61
|
+
while ((match = dtRegex.exec(code)) !== null) {
|
|
62
|
+
const q = cleanTagContent(match[1]);
|
|
63
|
+
const a = cleanTagContent(match[2]);
|
|
64
|
+
if (q && q.length > 2 && !faqs.some(f => f.q === q)) {
|
|
65
|
+
faqs.push({ q, ...(a ? { a: a.slice(0, 150) } : {}) });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 3. 提取套餐权益与核心特性列表 (<li>)
|
|
70
|
+
const liRegex = /<li[^>]*>([\s\S]*?)<\/li>/gi;
|
|
71
|
+
while ((match = liRegex.exec(code)) !== null) {
|
|
72
|
+
const text = cleanTagContent(match[1]);
|
|
73
|
+
if (text && text.length > 2 && text.length < 100 && !featureBullets.includes(text)) {
|
|
74
|
+
featureBullets.push(text);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 4. 提取核心描述段落 (<p> 及含有 desc/title/text 类的元素)
|
|
79
|
+
const pRegex = /<p[^>]*>([\s\S]*?)<\/p>/gi;
|
|
80
|
+
while ((match = pRegex.exec(code)) !== null) {
|
|
81
|
+
const text = cleanTagContent(match[1]);
|
|
82
|
+
if (text && text.length > 5 && text.length < 200 && !descriptions.includes(text) && !headings.includes(text)) {
|
|
83
|
+
descriptions.push(text);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
headings: headings.slice(0, 8),
|
|
89
|
+
faqs: faqs.slice(0, 10),
|
|
90
|
+
feature_bullets: featureBullets.slice(0, 12),
|
|
91
|
+
descriptions: descriptions.slice(0, 6)
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const NATIVE_BUTTON_TAGS = new Set(['button', 'a', 'input']);
|
|
96
|
+
const CUSTOM_BUTTON_PATTERN = /^(?:.*Button|.*Btn|.*CTA|.*Submit|.*Link|Link|Button|CTA|Submit)$/i;
|
|
97
|
+
|
|
98
|
+
function getTagName(nameNode) {
|
|
99
|
+
if (!nameNode) return '';
|
|
100
|
+
if (t.isJSXIdentifier(nameNode)) return nameNode.name;
|
|
101
|
+
if (t.isJSXMemberExpression(nameNode)) {
|
|
102
|
+
return `${getTagName(nameNode.object)}.${nameNode.property.name}`;
|
|
103
|
+
}
|
|
104
|
+
if (t.isJSXNamespacedName(nameNode)) {
|
|
105
|
+
return `${nameNode.namespace.name}:${nameNode.name.name}`;
|
|
106
|
+
}
|
|
107
|
+
return '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function extractAttributeStringValue(valNode) {
|
|
111
|
+
if (!valNode) return null;
|
|
112
|
+
if (t.isStringLiteral(valNode)) return valNode.value;
|
|
113
|
+
if (t.isJSXExpressionContainer(valNode)) {
|
|
114
|
+
const expr = valNode.expression;
|
|
115
|
+
if (t.isStringLiteral(expr)) return expr.value;
|
|
116
|
+
if (t.isNumericLiteral(expr)) return String(expr.value);
|
|
117
|
+
if (t.isTemplateLiteral(expr)) {
|
|
118
|
+
return expr.quasis.map((q) => q.value.raw).join('');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function extractHandlerDescription(valNode) {
|
|
125
|
+
if (!valNode) return '';
|
|
126
|
+
if (t.isJSXExpressionContainer(valNode)) {
|
|
127
|
+
const expr = valNode.expression;
|
|
128
|
+
if (t.isIdentifier(expr)) return expr.name;
|
|
129
|
+
if (t.isCallExpression(expr)) {
|
|
130
|
+
if (t.isIdentifier(expr.callee)) return `${expr.callee.name}()`;
|
|
131
|
+
if (t.isMemberExpression(expr.callee) && t.isIdentifier(expr.callee.property)) {
|
|
132
|
+
return `${expr.callee.property.name}()`;
|
|
133
|
+
}
|
|
134
|
+
return 'function()';
|
|
135
|
+
}
|
|
136
|
+
if (t.isArrowFunctionExpression(expr) || t.isFunctionExpression(expr)) {
|
|
137
|
+
if (t.isCallExpression(expr.body)) {
|
|
138
|
+
if (t.isIdentifier(expr.body.callee)) return `${expr.body.callee.name}()`;
|
|
139
|
+
if (t.isMemberExpression(expr.body.callee) && t.isIdentifier(expr.body.callee.property)) {
|
|
140
|
+
return `${expr.body.callee.property.name}()`;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (t.isBlockStatement(expr.body) && expr.body.body.length > 0) {
|
|
144
|
+
const firstStmt = expr.body.body[0];
|
|
145
|
+
if (t.isExpressionStatement(firstStmt) && t.isCallExpression(firstStmt.expression)) {
|
|
146
|
+
const call = firstStmt.expression;
|
|
147
|
+
if (t.isIdentifier(call.callee)) return `${call.callee.name}()`;
|
|
148
|
+
if (t.isMemberExpression(call.callee) && t.isIdentifier(call.callee.property)) {
|
|
149
|
+
return `${call.callee.property.name}()`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return 'inline handler';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return '';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function extractAttrs(attributes) {
|
|
160
|
+
const result = {
|
|
161
|
+
id: null,
|
|
162
|
+
shiplensLabel: null,
|
|
163
|
+
testId: null,
|
|
164
|
+
href: null,
|
|
165
|
+
to: null,
|
|
166
|
+
type: null,
|
|
167
|
+
role: null,
|
|
168
|
+
className: null,
|
|
169
|
+
clickHandler: null,
|
|
170
|
+
hasClick: false,
|
|
171
|
+
value: null,
|
|
172
|
+
rel: null,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
for (const attr of attributes) {
|
|
176
|
+
if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name)) {
|
|
177
|
+
const name = attr.name.name;
|
|
178
|
+
const attrNameLower = name.toLowerCase();
|
|
179
|
+
const valNode = attr.value;
|
|
180
|
+
const strVal = extractAttributeStringValue(valNode);
|
|
181
|
+
|
|
182
|
+
if (attrNameLower === 'id') {
|
|
183
|
+
result.id = strVal;
|
|
184
|
+
} else if (attrNameLower === 'data-shiplens-label' || attrNameLower === 'data-label') {
|
|
185
|
+
result.shiplensLabel = strVal;
|
|
186
|
+
} else if (attrNameLower === 'data-testid' || attrNameLower === 'data-test-id' || attrNameLower === 'testid') {
|
|
187
|
+
result.testId = strVal;
|
|
188
|
+
} else if (attrNameLower === 'href') {
|
|
189
|
+
result.href = strVal;
|
|
190
|
+
} else if (attrNameLower === 'to') {
|
|
191
|
+
result.to = strVal;
|
|
192
|
+
} else if (attrNameLower === 'type') {
|
|
193
|
+
result.type = strVal;
|
|
194
|
+
} else if (attrNameLower === 'role') {
|
|
195
|
+
result.role = strVal;
|
|
196
|
+
} else if (attrNameLower === 'class' || attrNameLower === 'classname') {
|
|
197
|
+
result.className = strVal;
|
|
198
|
+
} else if (attrNameLower === 'value') {
|
|
199
|
+
result.value = strVal;
|
|
200
|
+
} else if (attrNameLower === 'rel') {
|
|
201
|
+
result.rel = strVal;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (['onclick', '@click', 'v-on:click', 'onpress', 'ontap', 'bindtap'].includes(attrNameLower)) {
|
|
205
|
+
result.hasClick = true;
|
|
206
|
+
result.clickHandler = extractHandlerDescription(valNode);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function extractChildText(children) {
|
|
215
|
+
const parts = [];
|
|
216
|
+
|
|
217
|
+
for (const child of children) {
|
|
218
|
+
if (t.isJSXText(child)) {
|
|
219
|
+
const text = child.value.replace(/\s+/g, ' ').trim();
|
|
220
|
+
if (text) parts.push(text);
|
|
221
|
+
} else if (t.isJSXExpressionContainer(child)) {
|
|
222
|
+
const expr = child.expression;
|
|
223
|
+
if (t.isStringLiteral(expr)) {
|
|
224
|
+
const text = expr.value.trim();
|
|
225
|
+
if (text) parts.push(text);
|
|
226
|
+
} else if (t.isNumericLiteral(expr)) {
|
|
227
|
+
parts.push(String(expr.value));
|
|
228
|
+
} else if (t.isTemplateLiteral(expr)) {
|
|
229
|
+
const text = expr.quasis.map((q) => q.value.raw).join('').trim();
|
|
230
|
+
if (text) parts.push(text);
|
|
231
|
+
} else if (t.isConditionalExpression(expr)) {
|
|
232
|
+
const consequent = t.isStringLiteral(expr.consequent) ? expr.consequent.value.trim() : '';
|
|
233
|
+
const alternate = t.isStringLiteral(expr.alternate) ? expr.alternate.value.trim() : '';
|
|
234
|
+
if (consequent && alternate) {
|
|
235
|
+
parts.push(`${consequent} / ${alternate}`);
|
|
236
|
+
} else if (consequent || alternate) {
|
|
237
|
+
parts.push(consequent || alternate);
|
|
238
|
+
} else {
|
|
239
|
+
parts.push('{conditional}');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} else if (t.isJSXElement(child)) {
|
|
243
|
+
const nested = extractChildText(child.children);
|
|
244
|
+
if (nested) parts.push(nested);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return parts.join(' ').trim();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function inferActionHint(attrs) {
|
|
252
|
+
const target = attrs.href || attrs.to;
|
|
253
|
+
if (target) return `navigate to ${target}`;
|
|
254
|
+
if (attrs.clickHandler) return `calls ${attrs.clickHandler}`;
|
|
255
|
+
if (attrs.type === 'submit') return 'submit form';
|
|
256
|
+
return '';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function cleanClassName(classStr) {
|
|
260
|
+
if (!classStr) return '';
|
|
261
|
+
return classStr
|
|
262
|
+
.split(/\s+/)
|
|
263
|
+
.filter((c) => c && !/^(css-|_[a-z0-9]{5,}|[a-z0-9]{8,})/i.test(c))
|
|
264
|
+
.slice(0, 2)
|
|
265
|
+
.join(' ');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function cleanTagContent(content) {
|
|
269
|
+
if (!content) return '';
|
|
270
|
+
return content
|
|
271
|
+
.replace(/<[^>]*>/g, ' ')
|
|
272
|
+
.replace(/\{[^}]*\}/g, '')
|
|
273
|
+
.replace(/\s+/g, ' ')
|
|
274
|
+
.trim();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function extractInputValue(attrs) {
|
|
278
|
+
const match = attrs.match(/value=["']([^"']+)["']/i);
|
|
279
|
+
return match ? match[1].trim() : '';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function generateIdFromTextOrClass(text, classStr, tag, index) {
|
|
283
|
+
if (text && typeof text === 'string') {
|
|
284
|
+
const cleanText = text.replace(/[\r\n\t]+/g, ' ').trim();
|
|
285
|
+
if (cleanText.length >= 1 && cleanText.length <= 50) {
|
|
286
|
+
if (/[\u4e00-\u9fa5]/.test(cleanText)) {
|
|
287
|
+
const hexToken = encodeURIComponent(cleanText)
|
|
288
|
+
.replace(/%/g, '')
|
|
289
|
+
.slice(0, 12)
|
|
290
|
+
.toLowerCase();
|
|
291
|
+
if (hexToken) return `btn_${hexToken}`;
|
|
292
|
+
} else {
|
|
293
|
+
const slug = cleanText
|
|
294
|
+
.toLowerCase()
|
|
295
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
296
|
+
.replace(/^_+|_+$/g, '')
|
|
297
|
+
.slice(0, 24);
|
|
298
|
+
if (slug) return `btn_${slug}`;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (classStr && typeof classStr === 'string') {
|
|
304
|
+
const firstClass = classStr.split(' ')[0].replace(/[^a-zA-Z0-9_-]/g, '');
|
|
305
|
+
if (firstClass) return `btn_${firstClass.replace(/^btn_?/, '') ? firstClass : `btn_${firstClass}`}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return `btn_${(tag || 'element').toLowerCase()}_${index || 1}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* 基于 Babel AST 遍历提取页面内所有的交互按钮、链接和提交控件
|
|
313
|
+
* @param {string} code 源代码
|
|
314
|
+
* @param {string} filePath 相对文件路径(用于 source_loc)
|
|
315
|
+
* @returns {Array<{ id: string, tag: string, text: string, selector: string, label?: string, action_hint?: string, source_loc?: string, is_custom_component?: boolean }> | null}
|
|
316
|
+
*/
|
|
317
|
+
function extractInteractiveElementsAST(code, filePath = '') {
|
|
318
|
+
let ast;
|
|
319
|
+
try {
|
|
320
|
+
ast = parser.parse(code, {
|
|
321
|
+
sourceType: 'unambiguous',
|
|
322
|
+
plugins: [
|
|
323
|
+
'jsx',
|
|
324
|
+
'typescript',
|
|
325
|
+
'decorators-legacy',
|
|
326
|
+
'classProperties',
|
|
327
|
+
'classPrivateProperties',
|
|
328
|
+
'classPrivateMethods',
|
|
329
|
+
'exportDefaultFrom',
|
|
330
|
+
'exportNamespaceFrom',
|
|
331
|
+
'asyncGenerators',
|
|
332
|
+
'dynamicImport',
|
|
333
|
+
'objectRestSpread',
|
|
334
|
+
'optionalCatchBinding',
|
|
335
|
+
'optionalChaining',
|
|
336
|
+
'nullishCoalescingOperator',
|
|
337
|
+
],
|
|
338
|
+
errorRecovery: true,
|
|
339
|
+
});
|
|
340
|
+
} catch (e) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const elements = [];
|
|
345
|
+
let elementIndex = 1;
|
|
346
|
+
|
|
347
|
+
traverse(ast, {
|
|
348
|
+
JSXElement(pathNode) {
|
|
349
|
+
const opening = pathNode.node.openingElement;
|
|
350
|
+
const rawTagName = getTagName(opening.name);
|
|
351
|
+
if (!rawTagName) return;
|
|
352
|
+
|
|
353
|
+
const lowerTagName = rawTagName.toLowerCase();
|
|
354
|
+
const isNative = NATIVE_BUTTON_TAGS.has(lowerTagName);
|
|
355
|
+
const isCustom = CUSTOM_BUTTON_PATTERN.test(rawTagName);
|
|
356
|
+
|
|
357
|
+
// 过滤 HTML head 中的 link 标签 (如 <link rel="stylesheet" />)
|
|
358
|
+
const attrs = extractAttrs(opening.attributes);
|
|
359
|
+
if (lowerTagName === 'link' && attrs.rel && !attrs.href && !attrs.to && !attrs.hasClick) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const isSubmit = attrs.type === 'submit';
|
|
364
|
+
const isButtonRole = attrs.role === 'button' || attrs.role === 'link';
|
|
365
|
+
|
|
366
|
+
if (!isNative && !isCustom && !attrs.hasClick && !isSubmit && !isButtonRole) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const childText = extractChildText(pathNode.node.children);
|
|
371
|
+
const text = childText || (attrs.value ? attrs.value : '');
|
|
372
|
+
|
|
373
|
+
const stableId =
|
|
374
|
+
attrs.shiplensLabel ||
|
|
375
|
+
attrs.id ||
|
|
376
|
+
attrs.testId ||
|
|
377
|
+
generateIdFromTextOrClass(text, attrs.className || '', rawTagName, elementIndex);
|
|
378
|
+
|
|
379
|
+
const cleanClass = cleanClassName(attrs.className);
|
|
380
|
+
const firstClass = cleanClass ? cleanClass.split(' ')[0] : '';
|
|
381
|
+
const selector = attrs.id
|
|
382
|
+
? `#${attrs.id}`
|
|
383
|
+
: firstClass
|
|
384
|
+
? `${lowerTagName}.${firstClass}`
|
|
385
|
+
: `${lowerTagName}`;
|
|
386
|
+
|
|
387
|
+
const actionHint = inferActionHint(attrs);
|
|
388
|
+
|
|
389
|
+
let sourceLoc = undefined;
|
|
390
|
+
if (opening.loc) {
|
|
391
|
+
const line = opening.loc.start.line;
|
|
392
|
+
sourceLoc = filePath ? `${filePath}:${line}` : `line ${line}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
elements.push({
|
|
396
|
+
id: stableId,
|
|
397
|
+
tag: lowerTagName,
|
|
398
|
+
text: text || '(图标/无文案)',
|
|
399
|
+
selector: selector,
|
|
400
|
+
...(attrs.shiplensLabel ? { label: attrs.shiplensLabel } : {}),
|
|
401
|
+
...(actionHint ? { action_hint: actionHint } : {}),
|
|
402
|
+
...(sourceLoc ? { source_loc: sourceLoc } : {}),
|
|
403
|
+
...(isCustom && !isNative ? { is_custom_component: true } : {}),
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
elementIndex += 1;
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// 去重保底(相同 id 保留第一个)
|
|
411
|
+
const seenIds = new Set();
|
|
412
|
+
return elements.filter((el) => {
|
|
413
|
+
if (seenIds.has(el.id)) return false;
|
|
414
|
+
seenIds.add(el.id);
|
|
415
|
+
return true;
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 正则状态机提取按钮(AST 解析异常时的平滑降级保底)
|
|
421
|
+
* @param {string} code 清洗后的代码
|
|
422
|
+
* @param {string} filePath 相对文件路径
|
|
423
|
+
* @returns {Array<{ id: string, tag: string, text: string, selector: string, label?: string, action_hint?: string, source_loc?: string }>}
|
|
424
|
+
*/
|
|
425
|
+
function extractInteractiveElementsRegex(code, filePath = '') {
|
|
426
|
+
const elements = [];
|
|
427
|
+
let elementIndex = 1;
|
|
428
|
+
|
|
429
|
+
// 匹配所有 <button, <a, <Link, <input, <div, <span 开头的标签
|
|
430
|
+
const tagStartRegex = /<([a-zA-Z0-9_\-]+)(?:\s+|>)/g;
|
|
431
|
+
let match;
|
|
432
|
+
|
|
433
|
+
while ((match = tagStartRegex.exec(code)) !== null) {
|
|
434
|
+
const tag = match[1];
|
|
435
|
+
const targetTags = ['button', 'a', 'link', 'input', 'div', 'span'];
|
|
436
|
+
const isTargetTag = targetTags.includes(tag.toLowerCase()) || CUSTOM_BUTTON_PATTERN.test(tag);
|
|
437
|
+
if (!isTargetTag) {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const startIndex = match.index;
|
|
442
|
+
let index = startIndex + tag.length + 1;
|
|
443
|
+
let inQuotes = null;
|
|
444
|
+
let braceDepth = 0;
|
|
445
|
+
let isSelfClosing = false;
|
|
446
|
+
let tagEndIndex = -1;
|
|
447
|
+
|
|
448
|
+
// 解析属性直到遇到真正的 '>'
|
|
449
|
+
while (index < code.length) {
|
|
450
|
+
const char = code[index];
|
|
451
|
+
const prevChar = code[index - 1];
|
|
452
|
+
|
|
453
|
+
if (inQuotes) {
|
|
454
|
+
if (char === inQuotes && prevChar !== '\\') {
|
|
455
|
+
inQuotes = null;
|
|
456
|
+
}
|
|
457
|
+
} else if (char === '"' || char === "'" || char === '`') {
|
|
458
|
+
inQuotes = char;
|
|
459
|
+
} else if (char === '{') {
|
|
460
|
+
braceDepth += 1;
|
|
461
|
+
} else if (char === '}') {
|
|
462
|
+
braceDepth = Math.max(0, braceDepth - 1);
|
|
463
|
+
} else if (char === '>' && braceDepth === 0) {
|
|
464
|
+
tagEndIndex = index;
|
|
465
|
+
if (code[index - 1] === '/') {
|
|
466
|
+
isSelfClosing = true;
|
|
467
|
+
}
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
index += 1;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (tagEndIndex === -1) continue;
|
|
474
|
+
|
|
475
|
+
const attrs = code.slice(startIndex + tag.length + 1, tagEndIndex);
|
|
476
|
+
let innerContent = '';
|
|
477
|
+
|
|
478
|
+
if (!isSelfClosing) {
|
|
479
|
+
const closeTagStr = `</${tag}>`;
|
|
480
|
+
const closeTagIndex = code.indexOf(closeTagStr, tagEndIndex + 1);
|
|
481
|
+
if (closeTagIndex !== -1 && closeTagIndex - tagEndIndex < 2000) {
|
|
482
|
+
innerContent = code.slice(tagEndIndex + 1, closeTagIndex);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const isExplicitButton = /^(button|a|link)$/i.test(tag) || CUSTOM_BUTTON_PATTERN.test(tag);
|
|
487
|
+
const hasClick = /onClick|@click|v-on:click|onPress|role=["']button["']/i.test(attrs);
|
|
488
|
+
const isSubmit = /type=["']submit["']/i.test(attrs);
|
|
489
|
+
|
|
490
|
+
if (!isExplicitButton && !hasClick && !isSubmit) {
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// 过滤 HTML head 中的 link 标签
|
|
495
|
+
if (tag.toLowerCase() === 'link' && /rel=["'](?:stylesheet|icon|preload|manifest)["']/i.test(attrs)) {
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const idMatch = attrs.match(/\bid=["']([^"']+)["']/i);
|
|
500
|
+
const rawId = idMatch ? idMatch[1] : null;
|
|
501
|
+
|
|
502
|
+
const labelMatch = attrs.match(/data-shiplens-label=["']([^"']+)["']/i);
|
|
503
|
+
const explicitLabel = labelMatch ? labelMatch[1] : null;
|
|
504
|
+
|
|
505
|
+
const testIdMatch = attrs.match(/data-testid=["']([^"']+)["']/i);
|
|
506
|
+
const testId = testIdMatch ? testIdMatch[1] : null;
|
|
507
|
+
|
|
508
|
+
const classMatch = attrs.match(/(?:class|className)=["']([^"']+)["']/i);
|
|
509
|
+
const classStr = classMatch ? cleanClassName(classMatch[1]) : '';
|
|
510
|
+
|
|
511
|
+
const text = cleanTagContent(innerContent) || extractInputValue(attrs);
|
|
512
|
+
|
|
513
|
+
const hrefMatch = attrs.match(/(?:href|to)=["']([^"']+)["']/i);
|
|
514
|
+
const href = hrefMatch ? hrefMatch[1] : '';
|
|
515
|
+
|
|
516
|
+
const clickMatch = attrs.match(/(?:onClick|@click|onPress)=["']?\{?([^"'>}]+)\}?["']?/i);
|
|
517
|
+
const clickHandler = clickMatch ? clickMatch[1].trim() : '';
|
|
518
|
+
|
|
519
|
+
const stableId = explicitLabel
|
|
520
|
+
|| rawId
|
|
521
|
+
|| testId
|
|
522
|
+
|| generateIdFromTextOrClass(text, classStr, tag, elementIndex);
|
|
523
|
+
|
|
524
|
+
const selector = rawId
|
|
525
|
+
? `#${rawId}`
|
|
526
|
+
: classStr
|
|
527
|
+
? `${tag.toLowerCase()}.${classStr.split(' ')[0]}`
|
|
528
|
+
: `${tag.toLowerCase()}`;
|
|
529
|
+
|
|
530
|
+
let actionHint = '';
|
|
531
|
+
if (href) actionHint = `navigate to ${href}`;
|
|
532
|
+
else if (clickHandler) actionHint = `calls ${clickHandler}`;
|
|
533
|
+
else if (isSubmit) actionHint = 'submit form';
|
|
534
|
+
|
|
535
|
+
elements.push({
|
|
536
|
+
id: stableId,
|
|
537
|
+
tag: tag.toLowerCase(),
|
|
538
|
+
text: text || '(图标/无文案)',
|
|
539
|
+
selector: selector,
|
|
540
|
+
...(explicitLabel ? { label: explicitLabel } : {}),
|
|
541
|
+
...(actionHint ? { action_hint: actionHint } : {})
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
elementIndex += 1;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const seenIds = new Set();
|
|
548
|
+
return elements.filter((el) => {
|
|
549
|
+
if (seenIds.has(el.id)) return false;
|
|
550
|
+
seenIds.add(el.id);
|
|
551
|
+
return true;
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* 提取页面内所有的交互按钮、链接和提交控件(入口函数:优先 AST,降级正则)
|
|
557
|
+
* @param {string} code 代码
|
|
558
|
+
* @param {string} filePath 相对文件路径
|
|
559
|
+
* @returns {Array<{ id: string, tag: string, text: string, selector: string, label?: string, action_hint?: string, source_loc?: string, is_custom_component?: boolean }>}
|
|
560
|
+
*/
|
|
561
|
+
function extractInteractiveElements(code, filePath = '') {
|
|
562
|
+
try {
|
|
563
|
+
const astResult = extractInteractiveElementsAST(code, filePath);
|
|
564
|
+
if (astResult !== null && astResult.length >= 0) {
|
|
565
|
+
return astResult;
|
|
566
|
+
}
|
|
567
|
+
} catch (e) {
|
|
568
|
+
// 降级兜底
|
|
569
|
+
}
|
|
570
|
+
return extractInteractiveElementsRegex(code, filePath);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* 完整解析一个页面的结构与富文本骨架
|
|
575
|
+
* @param {string} filePath 文件绝对路径
|
|
576
|
+
* @param {string} routePath 对应路由
|
|
577
|
+
* @param {string} pageName 页面候选名称
|
|
578
|
+
* @param {string} rootDir 项目根目录(用于计算相对 source_loc)
|
|
579
|
+
* @returns {{ path: string, name: string, headings: string[], faqs: Array, feature_bullets: string[], descriptions: string[], raw_buttons: Array }}
|
|
580
|
+
*/
|
|
581
|
+
function extractPageSkeleton(filePath, routePath, pageName, rootDir = '') {
|
|
582
|
+
if (!fs.existsSync(filePath)) {
|
|
583
|
+
return { path: routePath, name: pageName, headings: [], faqs: [], feature_bullets: [], descriptions: [], raw_buttons: [] };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const rawCode = fs.readFileSync(filePath, 'utf-8');
|
|
587
|
+
const code = cleanCode(rawCode);
|
|
588
|
+
const richContent = extractRichPageContent(code);
|
|
589
|
+
const relFilePath = rootDir ? path.relative(rootDir, filePath).replace(/\\/g, '/') : filePath.replace(/\\/g, '/');
|
|
590
|
+
const rawButtons = extractInteractiveElements(rawCode, relFilePath);
|
|
591
|
+
|
|
592
|
+
return {
|
|
593
|
+
path: routePath,
|
|
594
|
+
name: pageName,
|
|
595
|
+
...richContent,
|
|
596
|
+
raw_buttons: rawButtons
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
module.exports = {
|
|
601
|
+
cleanCode,
|
|
602
|
+
extractRichPageContent,
|
|
603
|
+
extractInteractiveElementsAST,
|
|
604
|
+
extractInteractiveElementsRegex,
|
|
605
|
+
extractInteractiveElements,
|
|
606
|
+
extractPageSkeleton,
|
|
607
|
+
generateIdFromTextOrClass
|
|
608
|
+
};
|
|
609
|
+
|
package/lib/scanner.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 递归扫描目录中的代码文件
|
|
6
|
+
* @param {string} dir 目标目录
|
|
7
|
+
* @param {string[]} extensions 支持的扩展名
|
|
8
|
+
* @param {string[]} ignoreDirs 忽略的目录
|
|
9
|
+
* @returns {string[]} 文件绝对路径列表
|
|
10
|
+
*/
|
|
11
|
+
function scanFiles(dir, extensions = ['.tsx', '.jsx', '.vue', '.html', '.svelte', '.js', '.ts'], ignoreDirs = ['node_modules', '.git', '.next', 'dist', 'build', 'out', '.agents']) {
|
|
12
|
+
const results = [];
|
|
13
|
+
if (!fs.existsSync(dir)) return results;
|
|
14
|
+
|
|
15
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
const fullPath = path.join(dir, entry.name);
|
|
18
|
+
if (entry.isDirectory()) {
|
|
19
|
+
if (!ignoreDirs.includes(entry.name)) {
|
|
20
|
+
results.push(...scanFiles(fullPath, extensions, ignoreDirs));
|
|
21
|
+
}
|
|
22
|
+
} else if (entry.isFile()) {
|
|
23
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
24
|
+
if (extensions.includes(ext)) {
|
|
25
|
+
results.push(fullPath);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return results;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 自动探测项目类型并提取页面路由列表
|
|
34
|
+
* @param {string} rootDir 项目根目录
|
|
35
|
+
* @returns {{ framework: string, pages: Array<{ path: string, filePath: string, name: string }> }}
|
|
36
|
+
*/
|
|
37
|
+
function detectProjectPages(rootDir) {
|
|
38
|
+
const pages = [];
|
|
39
|
+
let framework = 'custom';
|
|
40
|
+
|
|
41
|
+
// 1. 检测 Next.js App Router (src/app 或 app)
|
|
42
|
+
const appRouterDir = fs.existsSync(path.join(rootDir, 'src', 'app'))
|
|
43
|
+
? path.join(rootDir, 'src', 'app')
|
|
44
|
+
: fs.existsSync(path.join(rootDir, 'app'))
|
|
45
|
+
? path.join(rootDir, 'app')
|
|
46
|
+
: null;
|
|
47
|
+
|
|
48
|
+
if (appRouterDir) {
|
|
49
|
+
framework = 'nextjs-app-router';
|
|
50
|
+
const allFiles = scanFiles(appRouterDir, ['.tsx', '.jsx', '.js', '.ts']);
|
|
51
|
+
for (const file of allFiles) {
|
|
52
|
+
const baseName = path.basename(file);
|
|
53
|
+
if (/^page\.(tsx|jsx|js|ts)$/.test(baseName)) {
|
|
54
|
+
const relDir = path.relative(appRouterDir, path.dirname(file));
|
|
55
|
+
const routePath = relDir === '' ? '/' : `/${relDir.replace(/\\/g, '/').replace(/\(.*?\)\/?/g, '')}`;
|
|
56
|
+
pages.push({
|
|
57
|
+
path: routePath || '/',
|
|
58
|
+
filePath: file,
|
|
59
|
+
name: routePath === '/' ? 'Home (首页)' : routePath
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 2. 检测 Next.js / Nuxt Pages Router (src/pages 或 pages)
|
|
66
|
+
if (pages.length === 0) {
|
|
67
|
+
const pagesRouterDir = fs.existsSync(path.join(rootDir, 'src', 'pages'))
|
|
68
|
+
? path.join(rootDir, 'src', 'pages')
|
|
69
|
+
: fs.existsSync(path.join(rootDir, 'pages'))
|
|
70
|
+
? path.join(rootDir, 'pages')
|
|
71
|
+
: null;
|
|
72
|
+
|
|
73
|
+
if (pagesRouterDir) {
|
|
74
|
+
framework = 'pages-router';
|
|
75
|
+
const allFiles = scanFiles(pagesRouterDir, ['.tsx', '.jsx', '.vue', '.js', '.ts']);
|
|
76
|
+
for (const file of allFiles) {
|
|
77
|
+
const rel = path.relative(pagesRouterDir, file);
|
|
78
|
+
const nameWithoutExt = rel.replace(/\.[^/.]+$/, '').replace(/\\/g, '/');
|
|
79
|
+
if (nameWithoutExt.startsWith('_') || nameWithoutExt.startsWith('api/')) continue;
|
|
80
|
+
|
|
81
|
+
const routePath = nameWithoutExt === 'index' ? '/' : `/${nameWithoutExt.replace(/\/index$/, '')}`;
|
|
82
|
+
pages.push({
|
|
83
|
+
path: routePath,
|
|
84
|
+
filePath: file,
|
|
85
|
+
name: routePath === '/' ? 'Home (首页)' : routePath
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 3. 检测通用 Vite / React / Vue (src/views, src/routes, src/pages, 或扫描全部 .vue/.tsx 组件)
|
|
92
|
+
if (pages.length === 0) {
|
|
93
|
+
const candidateDirs = ['src/views', 'src/pages', 'src/routes', 'src/components', 'src'].map(d => path.join(rootDir, d));
|
|
94
|
+
for (const cDir of candidateDirs) {
|
|
95
|
+
if (fs.existsSync(cDir)) {
|
|
96
|
+
const files = scanFiles(cDir, ['.tsx', '.jsx', '.vue', '.html', '.svelte']);
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
const base = path.basename(file, path.extname(file));
|
|
99
|
+
// 过滤通用辅助或原子组件 (如 Button, Icon, Header)
|
|
100
|
+
if (/^(Page|View|Screen|Home|Pricing|Login|Dashboard|Checkout|Register|Setting|About)/i.test(base)) {
|
|
101
|
+
const cleanName = base.toLowerCase().replace(/(page|view|screen)$/i, '');
|
|
102
|
+
const routePath = cleanName === 'home' || cleanName === 'index' ? '/' : `/${cleanName}`;
|
|
103
|
+
pages.push({
|
|
104
|
+
path: routePath,
|
|
105
|
+
filePath: file,
|
|
106
|
+
name: base
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (pages.length > 0) {
|
|
111
|
+
framework = 'vite-react-vue';
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 4. 静态 HTML 项目保底
|
|
119
|
+
if (pages.length === 0) {
|
|
120
|
+
const htmlFiles = scanFiles(rootDir, ['.html'], ['node_modules', '.git', 'dist']);
|
|
121
|
+
if (htmlFiles.length > 0) {
|
|
122
|
+
framework = 'static-html';
|
|
123
|
+
for (const file of htmlFiles) {
|
|
124
|
+
const rel = path.relative(rootDir, file).replace(/\\/g, '/');
|
|
125
|
+
const routePath = rel === 'index.html' ? '/' : `/${rel.replace(/\.html$/, '')}`;
|
|
126
|
+
pages.push({
|
|
127
|
+
path: routePath,
|
|
128
|
+
filePath: file,
|
|
129
|
+
name: path.basename(file)
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 去重保底
|
|
136
|
+
const uniqueMap = new Map();
|
|
137
|
+
for (const p of pages) {
|
|
138
|
+
if (!uniqueMap.has(p.path)) {
|
|
139
|
+
uniqueMap.set(p.path, p);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
framework,
|
|
145
|
+
pages: Array.from(uniqueMap.values())
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
scanFiles,
|
|
151
|
+
detectProjectPages
|
|
152
|
+
};
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shiplens/cli",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.7",
|
|
4
4
|
"description": "Shiplens CLI — Automated Web User Analytics & AI Agent Analysis Engine",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"bin": {
|
|
7
|
-
"cli": "
|
|
8
|
-
"shiplens": "
|
|
9
|
-
"shiplens-cli": "
|
|
7
|
+
"cli": "bin/shiplens.js",
|
|
8
|
+
"shiplens": "bin/shiplens.js",
|
|
9
|
+
"shiplens-cli": "bin/shiplens.js"
|
|
10
10
|
},
|
|
11
11
|
"keywords": [
|
|
12
12
|
"shiplens",
|
|
@@ -46,5 +46,10 @@
|
|
|
46
46
|
},
|
|
47
47
|
"publishConfig": {
|
|
48
48
|
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@babel/parser": "^8.0.4",
|
|
52
|
+
"@babel/traverse": "^8.0.4",
|
|
53
|
+
"@babel/types": "^8.0.4"
|
|
49
54
|
}
|
|
50
55
|
}
|
|
@@ -781,7 +781,7 @@ Delete Shiplens project analytics data and SDK integration (analytics no longer
|
|
|
781
781
|
Please prioritize calling Shiplens CLI (Action: remove_sdk) to execute the above requirements.
|
|
782
782
|
```
|
|
783
783
|
|
|
784
|
-
#### [uninstall_cli] Delete Shiplens CLI (Suffix: None)
|
|
784
|
+
#### [uninstall_cli] Delete Shiplens CLI and Restore Project (Suffix: None)
|
|
785
785
|
```text
|
|
786
786
|
Please prioritize calling Shiplens CLI (Action: uninstall_cli) to execute the above requirements.
|
|
787
787
|
- Windows: npm.cmd uninstall -g @shiplens/cli
|
|
@@ -792,7 +792,7 @@ Please prioritize calling Shiplens CLI (Action: uninstall_cli) to execute the ab
|
|
|
792
792
|
```text
|
|
793
793
|
Use Shiplens CLI to map pages, features, and button layouts into `.shiplens/contexts/<app_id>.md` so AI analytics can understand the business context behind each metric:
|
|
794
794
|
1. Check `./.shiplens.json` for current app_id and project name.
|
|
795
|
-
2.
|
|
795
|
+
2. Run AST static code scan across frontend code and routes to extract page purposes, feature copy, interactive buttons, test IDs, selectors, and exact source code locations.
|
|
796
796
|
3. Write structured details into `.shiplens/contexts/<app_id>.md`, binding app_id and project name in the header.
|
|
797
797
|
Please prioritize calling Shiplens CLI (Action: generate_context) to execute the above requirements.
|
|
798
798
|
```
|