pi-to-chrome 0.1.2

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.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * tool-registry - Data-driven tool registration
3
+ *
4
+ * Replaces the ~120-line registerChromeTools() function with a declarative loop.
5
+ * New tools only need: add to ALL_TOOLS array.
6
+ */
7
+
8
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
9
+ import type { ToolDefinition } from './core/types';
10
+ import type { ConsoleBuffer } from './console-buffer';
11
+ import { ensureConnection, getActivePage } from './core/browser';
12
+
13
+ import { findElementsTool } from './tools/find-elements';
14
+ import { inspectStylesTool } from './tools/inspect-styles';
15
+ // import { takeScreenshotTool } from './tools/take-screenshot';
16
+ import { readConsoleTool } from './tools/read-console';
17
+ import { executeJsTool } from './tools/execute-js';
18
+
19
+ const ALL_TOOLS: ToolDefinition[] = [
20
+ findElementsTool,
21
+ inspectStylesTool,
22
+ // takeScreenshotTool,
23
+ readConsoleTool,
24
+ executeJsTool,
25
+ ];
26
+
27
+ export function registerTools(pi: ExtensionAPI, consoleBuffer: ConsoleBuffer): string[] {
28
+ const names: string[] = [];
29
+
30
+ for (const tool of ALL_TOOLS) {
31
+ pi.registerTool({
32
+ name: tool.name,
33
+ label: tool.label,
34
+ description: tool.description,
35
+ promptSnippet: tool.promptSnippet,
36
+ promptGuidelines: tool.promptGuidelines,
37
+ parameters: tool.parameters,
38
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
39
+ await ensureConnection();
40
+ const page = await getActivePage();
41
+ return tool.execute(page, params, { consoleBuffer });
42
+ }
43
+ });
44
+ names.push(tool.name);
45
+ }
46
+
47
+ return names;
48
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * chrome_execute_js - Execute JavaScript in the page context
3
+ *
4
+ * Uses IIFE wrapper to support both expressions and statements.
5
+ */
6
+
7
+ import type { ToolDefinition } from '../core/types';
8
+ import { Type } from '@sinclair/typebox';
9
+
10
+ export const executeJsTool: ToolDefinition<{ expression: string }> = {
11
+ name: 'chrome_execute_js',
12
+ label: 'Chrome Execute JS',
13
+ description: '在页面上下文中执行 JavaScript 表达式,用于查询 DOM 状态、获取计算样式、测量元素尺寸等。',
14
+ promptSnippet: '在页面上下文中执行 JavaScript',
15
+ promptGuidelines: [
16
+ '【获取精确数据的工具】当你需要元素的精确数值(offsetHeight、scrollHeight、getBoundingClientRect、scrollTop 等)时,用 chrome_execute_js 获取,不要猜测。',
17
+ '调试布局问题时的常用查询:检查 scrollHeight vs clientHeight(判断是否溢出)、遍历父容器链的 overflow/display/height(定位高度约束断裂点)。',
18
+ '可以用 JSON.stringify() 包裹返回值来获取可读输出。',
19
+ '注意:代码中不能使用 const/let 等块级声明语句,请用 var 或 IIFE 包裹。例:JSON.stringify((function(){ var x = 1; return x })())'
20
+ ],
21
+ parameters: Type.Object({
22
+ expression: Type.String({ description: 'JavaScript code to execute' })
23
+ }),
24
+ async execute(page, params) {
25
+ // Wrap in IIFE: expression-body for simple values, block-body for statements
26
+ // `(() => location.href)()` → expression, returns value directly
27
+ // `(() => { const x = 1; return x; })()` → block, supports return/multi-line
28
+ const trimmed = params.expression.trim();
29
+ // If it starts with { or a statement keyword, use block form
30
+ // 语句: return, const, let, var, if, for, while, try, switch, throw, function, class
31
+ const statementKeywords = ['return ', 'const ', 'let ', 'var ', 'if ', 'for ', 'while ', 'try ', 'switch ', 'throw ', 'function ', 'class ', 'async '];
32
+ const isBlock = trimmed.startsWith('{') || statementKeywords.some(kw => trimmed.startsWith(kw));
33
+ const wrappedCode = isBlock
34
+ ? `(() => { ${params.expression} })()`
35
+ : `(() => ${params.expression})()`;
36
+
37
+ try {
38
+ const result = await page.evaluate(wrappedCode);
39
+
40
+ // Serialize result
41
+ let serializedResult: string;
42
+ let rawResult = result;
43
+
44
+ if (result === undefined) {
45
+ serializedResult = 'undefined';
46
+ } else if (result === null) {
47
+ serializedResult = 'null';
48
+ } else if (typeof result === 'string') {
49
+ serializedResult = result.length > 5000 ? result.slice(0, 5000) + '...' : result;
50
+ } else if (typeof result === 'number' || typeof result === 'boolean') {
51
+ serializedResult = String(result);
52
+ } else if (Array.isArray(result)) {
53
+ serializedResult = `Array(${result.length})`;
54
+ } else if (typeof result === 'object') {
55
+ serializedResult = 'Object';
56
+ } else {
57
+ serializedResult = JSON.stringify(result)?.slice(0, 5000) || String(result);
58
+ }
59
+
60
+ return {
61
+ content: [{ type: 'text', text: serializedResult }],
62
+ details: { raw: rawResult }
63
+ };
64
+
65
+ } catch (error: any) {
66
+ throw new Error(error.message);
67
+ }
68
+ }
69
+ };
@@ -0,0 +1,337 @@
1
+ /**
2
+ * chrome_find_elements v2 - Search for elements by text keywords
3
+ *
4
+ * New matching: Tier 1 (id/text-exact) > Tier 2 (class/attr) > Tier 3 (text-substr/tag)
5
+ * Deduplication keeps deepest descendants, discards ancestors.
6
+ * Selector is guaranteed unique via CSS path + nth-child fallback.
7
+ */
8
+
9
+ import type { ToolDefinition } from '../core/types';
10
+ import { Type } from '@sinclair/typebox';
11
+
12
+ const DEBUG = false;
13
+
14
+ export const findElementsTool: ToolDefinition<{ text: string }> = {
15
+ name: 'chrome_find_elements',
16
+ label: 'Chrome Find Elements',
17
+ description: '搜索当前页面上的元素。关键词同时匹配文本、class、id、标签名等,智能排序返回最相关的结果。',
18
+ promptSnippet: '按关键词搜索页面元素',
19
+ promptGuidelines: [
20
+ '【定位元素的第一步】当你需要调试页面问题时,先用 chrome_find_elements 找到目标元素的 CSS selector,再用 chrome_inspect_styles 查看它的样式层叠链。',
21
+ 'text 参数用 / 分隔中英文关键词,尽量多给变体。例:「灯泡」→ "灯泡/lamp/bulb/light"',
22
+ '拆成小词提高命中:「命令卡片列表」→ "命令卡片/命令/卡片/list/card/command"',
23
+ '返回的 selector 可直接用于 chrome_inspect_styles 和 chrome_execute_js。'
24
+ ],
25
+ parameters: Type.Object({
26
+ text: Type.String({
27
+ description: '搜索关键词,/ 分隔多关键词(OR)。同时匹配文本、class、id、标签名等。'
28
+ })
29
+ }),
30
+
31
+ async execute(page, params) {
32
+ if (!params.text || params.text.trim().length === 0) {
33
+ throw new Error('请提供搜索关键词');
34
+ }
35
+
36
+ const keywords = params.text
37
+ .split('/')
38
+ .map(k => k.trim().toLowerCase())
39
+ .filter(k => k.length > 0);
40
+
41
+ if (keywords.length === 0) {
42
+ throw new Error('请提供搜索关键词');
43
+ }
44
+
45
+ const results = await page.evaluate(
46
+ (keywords: string[], debug: boolean) => {
47
+ // ╔══════════════════════════════════════════════╗
48
+ // ║ Browser-side: no external vars, no imports ║
49
+ // ╚══════════════════════════════════════════════╝
50
+
51
+ // ─── Types (inline) ───────────────────────────
52
+ interface MatchResult {
53
+ tier: 1 | 2 | 3;
54
+ matchedBy: string;
55
+ matchedKeyword: string;
56
+ }
57
+
58
+ interface MatchWithEl {
59
+ el: Element;
60
+ match: MatchResult;
61
+ area: number;
62
+ y: number;
63
+ }
64
+
65
+ interface FindElementResult {
66
+ selector: string;
67
+ tag: string;
68
+ id: string | null;
69
+ classes: string[];
70
+ text: string;
71
+ ancestors: string;
72
+ rect: { x: number; y: number; w: number; h: number } | null;
73
+ _debug?: {
74
+ tier: number;
75
+ matchedBy: string;
76
+ matchedKeyword: string;
77
+ area: number;
78
+ };
79
+ }
80
+
81
+ // ─── isVisible ─────────────────────────────────
82
+ function isVisible(el: Element): boolean {
83
+ if (el.tagName === 'BODY') return true;
84
+ if ((el as HTMLElement).offsetParent === null) return false;
85
+ const rect = el.getBoundingClientRect();
86
+ if (rect.width === 0 || rect.height === 0) return false;
87
+ return true;
88
+ }
89
+
90
+ // ─── matchElement ──────────────────────────────
91
+ function matchElement(el: Element, keywords: string[]): MatchResult | null {
92
+ let bestTier = 99;
93
+ let matchedBy = '';
94
+ let matchedKeyword = '';
95
+
96
+ const textContent = (el.textContent || '').trim().toLowerCase();
97
+ const idLower = el.id.toLowerCase();
98
+ const tagLower = el.tagName.toLowerCase();
99
+ const classes = Array.from(el.classList);
100
+ const SKIP_ATTRS = ['aria-label', 'title', 'placeholder', 'alt'];
101
+
102
+ for (const keyword of keywords) {
103
+ // ── Tier 1: 精准 ──
104
+ if (idLower.includes(keyword)) {
105
+ if (1 < bestTier) {
106
+ bestTier = 1;
107
+ matchedBy = 'id:' + el.id;
108
+ matchedKeyword = keyword;
109
+ }
110
+ }
111
+ if (textContent === keyword) {
112
+ if (1 < bestTier) {
113
+ bestTier = 1;
114
+ matchedBy = 'text-exact';
115
+ matchedKeyword = keyword;
116
+ }
117
+ }
118
+
119
+ // ── Tier 2: 语义 ──
120
+ if (bestTier > 2) {
121
+ for (const cls of classes) {
122
+ if (cls.toLowerCase().includes(keyword)) {
123
+ if (2 < bestTier) {
124
+ bestTier = 2;
125
+ matchedBy = 'class:' + cls;
126
+ matchedKeyword = keyword;
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ if (bestTier > 2) {
133
+ for (const attr of SKIP_ATTRS) {
134
+ const val = el.getAttribute(attr);
135
+ if (val && val.toLowerCase().includes(keyword)) {
136
+ if (2 < bestTier) {
137
+ bestTier = 2;
138
+ matchedBy = 'attr:' + attr;
139
+ matchedKeyword = keyword;
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ // ── Tier 3: 模糊 ──
146
+ if (textContent.includes(keyword)) {
147
+ if (3 < bestTier) {
148
+ bestTier = 3;
149
+ matchedBy = 'text-substr';
150
+ matchedKeyword = keyword;
151
+ }
152
+ }
153
+ if (tagLower === keyword) {
154
+ if (3 < bestTier) {
155
+ bestTier = 3;
156
+ matchedBy = 'tag:' + tagLower;
157
+ matchedKeyword = keyword;
158
+ }
159
+ }
160
+ }
161
+
162
+ return bestTier === 99 ? null : { tier: bestTier as 1 | 2 | 3, matchedBy, matchedKeyword };
163
+ }
164
+
165
+ // ─── deduplicate ───────────────────────────────
166
+ function deduplicate(matches: MatchWithEl[]): MatchWithEl[] {
167
+ return matches.filter(m =>
168
+ !matches.some(n => n !== m && m.el.contains(n.el))
169
+ );
170
+ }
171
+
172
+ // ─── rank ──────────────────────────────────────
173
+ function rank(matches: MatchWithEl[]): void {
174
+ matches.sort((a, b) => {
175
+ const tierDiff = a.match.tier - b.match.tier;
176
+ if (tierDiff !== 0) return tierDiff;
177
+ if (a.area !== b.area) return a.area - b.area;
178
+ return a.y - b.y;
179
+ });
180
+ }
181
+
182
+ // ─── buildAncestorPath ──────────────────────────
183
+ function buildAncestorPath(el: Element): string {
184
+ const parts: string[] = [];
185
+ let current = el.parentElement;
186
+ let count = 0;
187
+ while (current && current !== document.body && count < 5) {
188
+ let s = current.tagName.toLowerCase();
189
+ if (current.id) {
190
+ s += '#' + current.id;
191
+ }
192
+ const cls = Array.from(current.classList).slice(0, 3);
193
+ if (cls.length > 0) {
194
+ s += '.' + cls.join('.');
195
+ }
196
+ parts.push(s);
197
+ current = current.parentElement;
198
+ count++;
199
+ }
200
+ return parts.join(' > ');
201
+ }
202
+
203
+ // ─── buildSelector ──────────────────────────────
204
+ function buildSelector(el: Element): string {
205
+ const tag = el.tagName.toLowerCase();
206
+ const allClasses = Array.from(el.classList);
207
+
208
+ // Strategy 1: id
209
+ if (el.id) {
210
+ const candidate = '#' + el.id;
211
+ if (document.querySelectorAll(candidate).length === 1) {
212
+ return candidate;
213
+ }
214
+ }
215
+
216
+ // Strategy 2: tag + all classes
217
+ if (allClasses.length > 0) {
218
+ const candidate = tag + '.' + allClasses.join('.');
219
+ if (document.querySelectorAll(candidate).length === 1) {
220
+ return candidate;
221
+ }
222
+ }
223
+
224
+ // Strategy 3: ancestor path
225
+ const path: string[] = [
226
+ tag + (allClasses.length > 0 ? '.' + allClasses.join('.') : '')
227
+ ];
228
+ let current = el.parentElement;
229
+ while (current && current !== document.body) {
230
+ let level = current.tagName.toLowerCase();
231
+ if (current.id) {
232
+ level += '#' + current.id;
233
+ }
234
+ const cls = Array.from(current.classList);
235
+ if (cls.length > 0) {
236
+ level += '.' + cls.join('.');
237
+ }
238
+ path.unshift(level);
239
+ const candidate = path.join(' > ');
240
+ if (document.querySelectorAll(candidate).length === 1) {
241
+ return candidate;
242
+ }
243
+ current = current.parentElement;
244
+ }
245
+
246
+ // Strategy 4: nth-child fallback
247
+ if (!el.parentElement) {
248
+ return tag;
249
+ }
250
+ const parentChildren = el.parentElement.children;
251
+ const index = Array.from(parentChildren).indexOf(el) + 1;
252
+ return path.join(' > ') + ':nth-child(' + index + ')';
253
+ }
254
+
255
+ // ─── buildResult ───────────────────────────────
256
+ function buildResult(el: Element, matchInfo: MatchResult, debug: boolean): FindElementResult {
257
+ const rect = el.getBoundingClientRect();
258
+ const result: FindElementResult = {
259
+ selector: buildSelector(el),
260
+ tag: el.tagName.toLowerCase(),
261
+ id: el.id || null,
262
+ classes: Array.from(el.classList).slice(0, 10),
263
+ text: (el.textContent || '').trim().slice(0, 80),
264
+ ancestors: buildAncestorPath(el),
265
+ rect: {
266
+ x: Math.round(rect.x),
267
+ y: Math.round(rect.y),
268
+ w: Math.round(rect.width),
269
+ h: Math.round(rect.height)
270
+ }
271
+ };
272
+
273
+ if (debug) {
274
+ result._debug = {
275
+ tier: matchInfo.tier,
276
+ matchedBy: matchInfo.matchedBy,
277
+ matchedKeyword: matchInfo.matchedKeyword,
278
+ area: Math.round(rect.width * rect.height)
279
+ };
280
+ }
281
+
282
+ return result;
283
+ }
284
+
285
+ // ─── searchElements (main entry) ───────────────
286
+ function searchElements(keywords: string[]): FindElementResult[] {
287
+ const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'LINK', 'META', 'HEAD', 'NOSCRIPT']);
288
+ const matches: MatchWithEl[] = [];
289
+
290
+ const walker = document.createTreeWalker(
291
+ document.body,
292
+ NodeFilter.SHOW_ELEMENT,
293
+ null
294
+ );
295
+
296
+ let node: Node | null = walker.currentNode;
297
+ while (node) {
298
+ const el = node as Element;
299
+ if (!SKIP_TAGS.has(el.tagName)) {
300
+ const match = matchElement(el, keywords);
301
+ if (match !== null && isVisible(el)) {
302
+ const rect = el.getBoundingClientRect();
303
+ matches.push({ el, match, area: rect.width * rect.height, y: rect.y });
304
+ // 截断保护:大页面避免后续 deduplicate O(n²) 过慢
305
+ if (matches.length > 500) break;
306
+ }
307
+ }
308
+ node = walker.nextNode();
309
+ }
310
+
311
+ // 截断保护触发时,先粗排再截断,保留高质量结果
312
+ if (matches.length > 500) {
313
+ rank(matches);
314
+ matches.length = 500;
315
+ }
316
+
317
+ const deduped = deduplicate(matches);
318
+ rank(deduped);
319
+
320
+ const top15 = deduped.slice(0, 15);
321
+ return top15.map(m => buildResult(m.el, m.match, debug));
322
+ }
323
+
324
+ return searchElements(keywords);
325
+ },
326
+ keywords,
327
+ DEBUG
328
+ );
329
+
330
+ const summary = `找到 ${results.length} 个匹配「${params.text}」的元素`;
331
+
332
+ return {
333
+ content: [{ type: 'text', text: summary }],
334
+ details: { total: results.length, results }
335
+ };
336
+ }
337
+ };
@@ -0,0 +1,204 @@
1
+ /**
2
+ * chrome_inspect_styles - Inspect CSS cascade for an element
3
+ *
4
+ * Gets matched CSS rules through CDP's CSS.getMatchedStylesForNode API.
5
+ */
6
+
7
+ import type { ToolDefinition } from '../core/types';
8
+ import { Type } from '@sinclair/typebox';
9
+
10
+ export const inspectStylesTool: ToolDefinition<{
11
+ selector: string;
12
+ includeChildren?: boolean;
13
+ }> = {
14
+ name: 'chrome_inspect_styles',
15
+ label: 'Chrome Inspect Styles',
16
+ description: '查看元素的完整 CSS 样式层叠链:包括 inline style、class 样式、user-agent 样式,以及每条规则的来源文件和优先级。',
17
+ promptSnippet: '查看元素的 CSS 样式层叠链',
18
+ promptGuidelines: [
19
+ '【调试布局问题的核心工具】当元素显示异常(溢出、滚动条不出现、尺寸不对、布局错乱)时,立即用 chrome_inspect_styles 检查该元素及其父容器的样式层叠链,确认 inline style 是否覆盖了 CSS 规则、overflow 是否被意外设为 hidden 等。',
20
+ '调试滚动条问题时:依次检查 滚动容器 → 父容器 → 祖先容器的 overflow、display、height、flex 属性,定位哪一层断了高度约束链。',
21
+ '返回结果会按优先级排列(inline > CSS class > user-agent),注意 inline style 会覆盖 CSS 文件中的规则。',
22
+ '指定 CSS selector 来定位元素,例如 "#dashboardContainer"、".command-list"、"div > .card"。'
23
+ ],
24
+ parameters: Type.Object({
25
+ selector: Type.String({ description: 'CSS selector of the element to inspect' }),
26
+ includeChildren: Type.Optional(Type.Boolean({ description: 'Include direct children list', default: false }))
27
+ }),
28
+ async execute(page, params) {
29
+ let cdpSession: any = null;
30
+
31
+ try {
32
+ // Create CDP session for this page
33
+ cdpSession = await page.createCDPSession();
34
+
35
+ // Enable DOM and CSS agents
36
+ await cdpSession.send('DOM.enable');
37
+ await cdpSession.send('CSS.enable');
38
+
39
+ // Get document and find node
40
+ const { root } = await cdpSession.send('DOM.getDocument', { depth: 0 });
41
+ const { nodeId } = await cdpSession.send('DOM.querySelector', {
42
+ selector: params.selector,
43
+ nodeId: root.nodeId
44
+ });
45
+
46
+ if (!nodeId) {
47
+ throw new Error(`未找到匹配 "${params.selector}" 的元素`);
48
+ }
49
+
50
+ // Get element info
51
+ const { node: nodeInfo } = await cdpSession.send('DOM.describeNode', { nodeId });
52
+
53
+ // Get bounding rect
54
+ const boundingRect = await page.evaluate((sel: string) => {
55
+ const el = document.querySelector(sel);
56
+ if (!el) return null;
57
+ const r = el.getBoundingClientRect();
58
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
59
+ }, params.selector);
60
+
61
+ // Get text content (truncated)
62
+ const textContent = await page.evaluate((sel: string) => {
63
+ const el = document.querySelector(sel);
64
+ return (el?.textContent || '').trim().slice(0, 200);
65
+ }, params.selector);
66
+
67
+ // Build element info
68
+ const elementInfo = {
69
+ tagName: nodeInfo.localName?.toLowerCase() || 'unknown',
70
+ text: textContent,
71
+ classList: nodeInfo.attributes
72
+ ? extractClasses(nodeInfo.attributes)
73
+ : [],
74
+ attributes: nodeInfo.attributes || [],
75
+ boundingRect
76
+ };
77
+
78
+ // Get matched styles
79
+ const matchedStyles = await cdpSession.send('CSS.getMatchedStylesForNode', { nodeId });
80
+
81
+ // Process CSS rules
82
+ const cssRules: any[] = [];
83
+
84
+ // Inline style
85
+ if (matchedStyles.inlineStyle) {
86
+ const inlineProps = processStyleProperties(matchedStyles.inlineStyle.cssProperties, matchedStyles.inlineStyle.shorthandEntries);
87
+ if (Object.keys(inlineProps).length > 0) {
88
+ cssRules.push({
89
+ type: 'inline',
90
+ selector: '<inline style>',
91
+ source: 'inline style',
92
+ properties: inlineProps
93
+ });
94
+ }
95
+ }
96
+
97
+ // Regular matched rules
98
+ if (matchedStyles.matchedCSSRules) {
99
+ for (const rule of matchedStyles.matchedCSSRules) {
100
+ const props = processStyleProperties(rule.rule.style.cssProperties, rule.rule.style.shorthandEntries);
101
+
102
+ // Build source string
103
+ const source = rule.rule.origin === 'user-agent'
104
+ ? 'user-agent'
105
+ : rule.rule.selectorList?.selectors?.map((s: any) => s.value).join(', ') || 'unknown';
106
+
107
+ const sourceLocation = rule.rule.sourceURL
108
+ ? `${rule.rule.sourceURL}:${rule.rule.sourceLine || '?'}`
109
+ : 'inline';
110
+
111
+ cssRules.push({
112
+ type: rule.rule.origin === 'user-agent' ? 'user-agent' : 'regular',
113
+ selector: source,
114
+ source: sourceLocation,
115
+ properties: props
116
+ });
117
+ }
118
+ }
119
+
120
+ // Build summary
121
+ const summary = `元素 <${elementInfo.tagName}> 的 CSS 层叠链:\n` +
122
+ cssRules.map((rule, i) =>
123
+ ` ${i + 1}. [${rule.type}] ${rule.selector}\n 来源: ${rule.source}\n` +
124
+ (rule.properties.length > 0
125
+ ? ` 属性: ${rule.properties.slice(0, 5).map((p: any) => `${p.name}: ${p.value}${p.important ? ' !important' : ''}`).join(', ')}${rule.properties.length > 5 ? '...' : ''}\n`
126
+ : '')
127
+ ).join('');
128
+
129
+ return {
130
+ content: [{ type: 'text', text: summary }],
131
+ details: { element: elementInfo, cssRules }
132
+ };
133
+
134
+ } finally {
135
+ // Clean up CDP session
136
+ if (cdpSession) {
137
+ try {
138
+ await cdpSession.detach();
139
+ } catch (error) {
140
+ console.error('[pi-to-chrome] inspect-styles: cdpSession.detach() 失败', error);
141
+ }
142
+ }
143
+ }
144
+ }
145
+ };
146
+
147
+ function extractClasses(attributes: string[]): string[] {
148
+ const classes: string[] = [];
149
+ for (let i = 0; i < attributes.length - 1; i += 2) {
150
+ if (attributes[i] === 'class') {
151
+ return attributes[i + 1].split(/\s+/).filter(c => c).slice(0, 10);
152
+ }
153
+ }
154
+ return classes;
155
+ }
156
+
157
+ function processStyleProperties(
158
+ cssProperties: any[],
159
+ shorthandEntries: any[]
160
+ ): any[] {
161
+ if (!cssProperties) return [];
162
+
163
+ const result: any[] = [];
164
+ const seenShorthands = new Set<string>();
165
+
166
+ // Track which shorthands are explicitly set
167
+ for (const entry of shorthandEntries || []) {
168
+ if (entry.value > 0) {
169
+ seenShorthands.add(cssProperties[entry.value]?.name || '');
170
+ }
171
+ }
172
+
173
+ for (const prop of cssProperties) {
174
+ // Skip empty values
175
+ if (!prop.value || prop.value === '') continue;
176
+
177
+ // Skip inherit/initial/unset unless it's the only value (shorthand)
178
+ const isInheritOrInitial = ['inherit', 'initial', 'unset', 'revert'].includes(prop.value);
179
+
180
+ // Keep the property if:
181
+ // 1. It's a shorthand property (entry in shorthandEntries)
182
+ // 2. It's not a longhand of an explicitly set shorthand
183
+ // 3. It's inherit/initial/unset with no shorthand entry
184
+ const isShorthand = shorthandEntries?.some((e: any) =>
185
+ e.value === cssProperties.indexOf(prop) && e.value >= 0
186
+ );
187
+
188
+ if (isShorthand) {
189
+ result.push({
190
+ name: prop.name,
191
+ value: prop.value,
192
+ important: prop.important || false
193
+ });
194
+ } else if (!isInheritOrInitial) {
195
+ result.push({
196
+ name: prop.name,
197
+ value: prop.value,
198
+ important: prop.important || false
199
+ });
200
+ }
201
+ }
202
+
203
+ return result;
204
+ }