chrome-devtools-mcp-for-extension 0.18.0 → 0.18.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.
- package/README.md +232 -496
- package/build/node_modules/chrome-devtools-frontend/front_end/models/trace/lantern/testing/MetricTestUtils.js +46 -0
- package/build/node_modules/chrome-devtools-frontend/front_end/models/trace/lantern/testing/testing.js +4 -0
- package/build/src/browser.js +21 -0
- package/build/src/config.js +13 -0
- package/build/src/graceful.js +125 -0
- package/build/src/login-helper.js +127 -19
- package/build/src/main.js +59 -0
- package/build/src/profile-resolver.js +78 -3
- package/build/src/project-root-state.js +13 -0
- package/build/src/roots-manager.js +16 -9
- package/build/src/selectors/gemini.json +24 -0
- package/build/src/selectors/loader.js +32 -0
- package/build/src/tools/diagnose-ui.js +9 -0
- package/build/src/tools/gemini-web.js +357 -0
- package/package.json +7 -5
- package/scripts/cli.mjs +44 -0
|
@@ -32,8 +32,9 @@ export async function fetchRootsFromClient(server) {
|
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
34
|
* Generate stable profile key from roots and client info
|
|
35
|
+
* Format: projectName_hash (e.g., "my-app_2ca5dbf5")
|
|
35
36
|
*/
|
|
36
|
-
export function generateProfileKey(rootsUris, clientName, clientVersion) {
|
|
37
|
+
export function generateProfileKey(rootsUris, clientName, clientVersion, projectName) {
|
|
37
38
|
// Sort URIs for consistent hashing across multi-root workspaces
|
|
38
39
|
const sortedUris = [...rootsUris].sort();
|
|
39
40
|
const keyMaterial = JSON.stringify({
|
|
@@ -41,8 +42,13 @@ export function generateProfileKey(rootsUris, clientName, clientVersion) {
|
|
|
41
42
|
client: clientName,
|
|
42
43
|
version: clientVersion,
|
|
43
44
|
});
|
|
44
|
-
// Use first
|
|
45
|
-
const hash = createHash('sha256').update(keyMaterial).digest('hex').slice(0,
|
|
45
|
+
// Use first 8 chars of SHA-256 for stable, collision-resistant key
|
|
46
|
+
const hash = createHash('sha256').update(keyMaterial).digest('hex').slice(0, 8);
|
|
47
|
+
// Include project name for clarity (if available)
|
|
48
|
+
if (projectName) {
|
|
49
|
+
const sanitized = projectName.replace(/[^a-z0-9_-]/gi, '-').toLowerCase();
|
|
50
|
+
return `${sanitized}_${hash}`;
|
|
51
|
+
}
|
|
46
52
|
return hash;
|
|
47
53
|
}
|
|
48
54
|
/**
|
|
@@ -88,8 +94,8 @@ export async function resolveRoots(server, fallbackOptions) {
|
|
|
88
94
|
const rootsResult = await fetchRootsFromClient(server);
|
|
89
95
|
if (rootsResult && rootsResult.roots.length > 0) {
|
|
90
96
|
const rootsUris = rootsResult.roots.map(r => r.uri);
|
|
91
|
-
const profileKey = generateProfileKey(rootsUris, clientName, clientVersion);
|
|
92
97
|
const projectName = extractProjectName(rootsResult.roots);
|
|
98
|
+
const profileKey = generateProfileKey(rootsUris, clientName, clientVersion, projectName);
|
|
93
99
|
console.error(`[roots] Resolved via roots/list: key=${profileKey}, project=${projectName}, client=${clientName}`);
|
|
94
100
|
return {
|
|
95
101
|
profileKey,
|
|
@@ -103,8 +109,8 @@ export async function resolveRoots(server, fallbackOptions) {
|
|
|
103
109
|
// 2) Fallback: CLI argument --project-root
|
|
104
110
|
if (fallbackOptions.cliProjectRoot) {
|
|
105
111
|
const uri = pathToFileUri(fallbackOptions.cliProjectRoot);
|
|
106
|
-
const profileKey = generateProfileKey([uri], clientName, clientVersion);
|
|
107
112
|
const projectName = extractProjectName([{ uri }]);
|
|
113
|
+
const profileKey = generateProfileKey([uri], clientName, clientVersion, projectName);
|
|
108
114
|
console.error(`[roots] Resolved via --project-root: key=${profileKey}, project=${projectName}`);
|
|
109
115
|
return {
|
|
110
116
|
profileKey,
|
|
@@ -118,8 +124,8 @@ export async function resolveRoots(server, fallbackOptions) {
|
|
|
118
124
|
// 3) Fallback: Environment variable MCP_PROJECT_ROOT
|
|
119
125
|
if (fallbackOptions.envProjectRoot) {
|
|
120
126
|
const uri = pathToFileUri(fallbackOptions.envProjectRoot);
|
|
121
|
-
const profileKey = generateProfileKey([uri], clientName, clientVersion);
|
|
122
127
|
const projectName = extractProjectName([{ uri }]);
|
|
128
|
+
const profileKey = generateProfileKey([uri], clientName, clientVersion, projectName);
|
|
123
129
|
console.error(`[roots] Resolved via MCP_PROJECT_ROOT: key=${profileKey}, project=${projectName}`);
|
|
124
130
|
return {
|
|
125
131
|
profileKey,
|
|
@@ -133,8 +139,8 @@ export async function resolveRoots(server, fallbackOptions) {
|
|
|
133
139
|
// 4) Fallback: AUTO (cwd-based, last resort)
|
|
134
140
|
if (fallbackOptions.autoCwd) {
|
|
135
141
|
const uri = pathToFileUri(fallbackOptions.autoCwd);
|
|
136
|
-
const profileKey = generateProfileKey([uri], clientName, clientVersion);
|
|
137
142
|
const projectName = extractProjectName([{ uri }]);
|
|
143
|
+
const profileKey = generateProfileKey([uri], clientName, clientVersion, projectName);
|
|
138
144
|
console.error(`[roots] Resolved via AUTO (cwd): key=${profileKey}, project=${projectName}`);
|
|
139
145
|
return {
|
|
140
146
|
profileKey,
|
|
@@ -147,11 +153,12 @@ export async function resolveRoots(server, fallbackOptions) {
|
|
|
147
153
|
}
|
|
148
154
|
// Absolute fallback
|
|
149
155
|
const fallbackUri = 'file:///unknown';
|
|
150
|
-
const
|
|
156
|
+
const fallbackProjectName = 'unknown';
|
|
157
|
+
const profileKey = generateProfileKey([fallbackUri], clientName, clientVersion, fallbackProjectName);
|
|
151
158
|
console.error('[roots] WARNING: No roots available, using fallback');
|
|
152
159
|
return {
|
|
153
160
|
profileKey,
|
|
154
|
-
projectName:
|
|
161
|
+
projectName: fallbackProjectName,
|
|
155
162
|
rootsUris: [fallbackUri],
|
|
156
163
|
clientName,
|
|
157
164
|
clientVersion,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.0.0",
|
|
3
|
+
"lastUpdated": "2025-11-27",
|
|
4
|
+
"description": "Selectors for Gemini web interface",
|
|
5
|
+
"elements": {
|
|
6
|
+
"editor": {
|
|
7
|
+
"css": "div[contenteditable='true']",
|
|
8
|
+
"description": "Main input editor"
|
|
9
|
+
},
|
|
10
|
+
"sendButton": {
|
|
11
|
+
"ax": {
|
|
12
|
+
"ariaLabel": "送信"
|
|
13
|
+
},
|
|
14
|
+
"description": "Send message button"
|
|
15
|
+
},
|
|
16
|
+
"response": {
|
|
17
|
+
"css": "model-response",
|
|
18
|
+
"description": "Response container"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"placeholders": {
|
|
22
|
+
"normalMode": "プロンプトを入力"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -99,4 +99,36 @@ export function checkPlaceholder(placeholder, mode) {
|
|
|
99
99
|
*/
|
|
100
100
|
export function clearCache() {
|
|
101
101
|
cachedSelectors = null;
|
|
102
|
+
cachedGeminiSelectors = null;
|
|
103
|
+
}
|
|
104
|
+
let cachedGeminiSelectors = null;
|
|
105
|
+
/**
|
|
106
|
+
* Load Gemini selectors from JSON file
|
|
107
|
+
*/
|
|
108
|
+
export function loadGeminiSelectors() {
|
|
109
|
+
if (cachedGeminiSelectors) {
|
|
110
|
+
return cachedGeminiSelectors;
|
|
111
|
+
}
|
|
112
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
113
|
+
const __dirname = path.dirname(__filename);
|
|
114
|
+
const selectorsPath = path.join(__dirname, 'gemini.json');
|
|
115
|
+
try {
|
|
116
|
+
const data = fs.readFileSync(selectorsPath, 'utf-8');
|
|
117
|
+
cachedGeminiSelectors = JSON.parse(data);
|
|
118
|
+
return cachedGeminiSelectors;
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
throw new Error(`Failed to load selectors from ${selectorsPath}: ${error}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Get a specific Gemini selector definition
|
|
126
|
+
*/
|
|
127
|
+
export function getGeminiSelector(elementName) {
|
|
128
|
+
const selectors = loadGeminiSelectors();
|
|
129
|
+
const selector = selectors.elements[elementName];
|
|
130
|
+
if (!selector) {
|
|
131
|
+
throw new Error(`Selector not found: ${elementName}`);
|
|
132
|
+
}
|
|
133
|
+
return selector;
|
|
102
134
|
}
|
|
@@ -9,6 +9,7 @@ import z from 'zod';
|
|
|
9
9
|
import { ToolCategories } from './categories.js';
|
|
10
10
|
import { defineTool } from './ToolDefinition.js';
|
|
11
11
|
import { CHATGPT_CONFIG } from '../config.js';
|
|
12
|
+
import { isLoginRequired } from '../login-helper.js';
|
|
12
13
|
/**
|
|
13
14
|
* Known important elements in ChatGPT UI
|
|
14
15
|
*/
|
|
@@ -216,6 +217,14 @@ export const diagnoseChatgptUi = defineTool({
|
|
|
216
217
|
// Navigate to ChatGPT
|
|
217
218
|
response.appendResponseLine(`📡 Navigating to ${url}...`);
|
|
218
219
|
await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 });
|
|
220
|
+
// Check if login is required
|
|
221
|
+
const needsLogin = await isLoginRequired(page);
|
|
222
|
+
if (needsLogin) {
|
|
223
|
+
response.appendResponseLine('\n❌ ChatGPTへのログインが必要です');
|
|
224
|
+
response.appendResponseLine('📱 ブラウザウィンドウでログインしてから再実行してください');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
response.appendResponseLine('✅ Login check passed');
|
|
219
228
|
// Wait for page to stabilize
|
|
220
229
|
response.appendResponseLine(`⏳ Waiting ${waitForLoad}ms for page to stabilize...`);
|
|
221
230
|
await new Promise(resolve => setTimeout(resolve, waitForLoad));
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025 Google LLC
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import z from 'zod';
|
|
9
|
+
import { ToolCategories } from './categories.js';
|
|
10
|
+
import { defineTool } from './ToolDefinition.js';
|
|
11
|
+
import { GEMINI_CONFIG } from '../config.js';
|
|
12
|
+
import { isLoginRequired } from '../login-helper.js';
|
|
13
|
+
/**
|
|
14
|
+
* Path to store chat session data
|
|
15
|
+
*/
|
|
16
|
+
const CHAT_SESSIONS_FILE = path.join(process.cwd(), 'docs/ask/gemini/.chat-sessions.json');
|
|
17
|
+
/**
|
|
18
|
+
* Load chat sessions from JSON file
|
|
19
|
+
*/
|
|
20
|
+
async function loadChatSessions() {
|
|
21
|
+
try {
|
|
22
|
+
const data = await fs.promises.readFile(CHAT_SESSIONS_FILE, 'utf-8');
|
|
23
|
+
return JSON.parse(data);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Save a chat session for a project
|
|
31
|
+
*/
|
|
32
|
+
async function saveChatSession(projectName, session) {
|
|
33
|
+
const sessions = await loadChatSessions();
|
|
34
|
+
if (!sessions[projectName]) {
|
|
35
|
+
sessions[projectName] = [];
|
|
36
|
+
}
|
|
37
|
+
const existingIndex = sessions[projectName].findIndex(s => s.chatId === session.chatId);
|
|
38
|
+
if (existingIndex >= 0) {
|
|
39
|
+
sessions[projectName][existingIndex] = {
|
|
40
|
+
...sessions[projectName][existingIndex],
|
|
41
|
+
...session,
|
|
42
|
+
lastUsed: new Date().toISOString(),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
sessions[projectName].push({
|
|
47
|
+
...session,
|
|
48
|
+
createdAt: session.createdAt || new Date().toISOString(),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const dir = path.dirname(CHAT_SESSIONS_FILE);
|
|
52
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
53
|
+
await fs.promises.writeFile(CHAT_SESSIONS_FILE, JSON.stringify(sessions, null, 2), 'utf-8');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Sanitize question
|
|
57
|
+
*/
|
|
58
|
+
function sanitizeQuestion(text) {
|
|
59
|
+
const passwordPatterns = [
|
|
60
|
+
/password\s*[:=]\s*\S+/gi,
|
|
61
|
+
/パスワード\s*[::=]\s*\S+/gi,
|
|
62
|
+
/pwd\s*[:=]\s*\S+/gi,
|
|
63
|
+
/secret\s*[:=]\s*\S+/gi,
|
|
64
|
+
];
|
|
65
|
+
let sanitized = text;
|
|
66
|
+
for (const pattern of passwordPatterns) {
|
|
67
|
+
sanitized = sanitized.replace(pattern, '[パスワードは除外されました]');
|
|
68
|
+
}
|
|
69
|
+
return sanitized;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Save conversation log
|
|
73
|
+
*/
|
|
74
|
+
async function saveConversationLog(projectName, question, response, metadata) {
|
|
75
|
+
const now = new Date();
|
|
76
|
+
const timestamp = [
|
|
77
|
+
String(now.getFullYear()).slice(2).padStart(2, '0'),
|
|
78
|
+
String(now.getMonth() + 1).padStart(2, '0'),
|
|
79
|
+
String(now.getDate()).padStart(2, '0'),
|
|
80
|
+
'_',
|
|
81
|
+
String(now.getHours()).padStart(2, '0'),
|
|
82
|
+
String(now.getMinutes()).padStart(2, '0'),
|
|
83
|
+
String(now.getSeconds()).padStart(2, '0'),
|
|
84
|
+
].join('');
|
|
85
|
+
const topicSlug = question
|
|
86
|
+
.substring(0, 50)
|
|
87
|
+
.replace(/[^a-z0-9\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf]+/gi, '-')
|
|
88
|
+
.toLowerCase()
|
|
89
|
+
.slice(0, 30);
|
|
90
|
+
let logPath;
|
|
91
|
+
if (metadata.chatId) {
|
|
92
|
+
const conversationNum = String(metadata.conversationNumber || 1).padStart(3, '0');
|
|
93
|
+
const filename = `${conversationNum}-${timestamp}-${topicSlug}.md`;
|
|
94
|
+
const logDir = path.join('docs/ask/gemini', metadata.chatId);
|
|
95
|
+
logPath = path.join(process.cwd(), logDir, filename);
|
|
96
|
+
await fs.promises.mkdir(path.join(process.cwd(), logDir), { recursive: true });
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const filename = `${timestamp}-${projectName}-${topicSlug}.md`;
|
|
100
|
+
const logDir = 'docs/ask/gemini';
|
|
101
|
+
logPath = path.join(process.cwd(), logDir, filename);
|
|
102
|
+
await fs.promises.mkdir(path.dirname(logPath), { recursive: true });
|
|
103
|
+
}
|
|
104
|
+
const content = `# ${topicSlug}
|
|
105
|
+
|
|
106
|
+
## 📅 メタ情報
|
|
107
|
+
- **日時**: ${now.toLocaleString('ja-JP')}
|
|
108
|
+
- **プロジェクト**: ${projectName}
|
|
109
|
+
- **AIモデル**: ${metadata.model || 'Gemini'}
|
|
110
|
+
${metadata.chatId ? `- **チャットID**: ${metadata.chatId}\n` : ''}${metadata.conversationNumber ? `- **会話番号**: ${metadata.conversationNumber}\n` : ''}${metadata.chatUrl ? `- **チャットURL**: ${metadata.chatUrl}\n` : ''}
|
|
111
|
+
## ❓ 質問
|
|
112
|
+
|
|
113
|
+
${question}
|
|
114
|
+
|
|
115
|
+
## 💬 回答
|
|
116
|
+
|
|
117
|
+
${response}
|
|
118
|
+
`;
|
|
119
|
+
await fs.promises.writeFile(logPath, content, 'utf-8');
|
|
120
|
+
return path.relative(process.cwd(), logPath);
|
|
121
|
+
}
|
|
122
|
+
export const askGeminiWeb = defineTool({
|
|
123
|
+
name: 'ask_gemini_web',
|
|
124
|
+
description: `Ask Gemini a question via web browser automation. Conversations are organized by project name and logged to docs/ask/gemini/.`,
|
|
125
|
+
annotations: {
|
|
126
|
+
category: ToolCategories.NAVIGATION_AUTOMATION,
|
|
127
|
+
readOnlyHint: false,
|
|
128
|
+
},
|
|
129
|
+
schema: {
|
|
130
|
+
question: z
|
|
131
|
+
.string()
|
|
132
|
+
.describe('The question to ask Gemini.'),
|
|
133
|
+
projectName: z
|
|
134
|
+
.string()
|
|
135
|
+
.optional()
|
|
136
|
+
.describe('Project name for organizing conversations. Defaults to current working directory name.'),
|
|
137
|
+
createNewChat: z
|
|
138
|
+
.boolean()
|
|
139
|
+
.optional()
|
|
140
|
+
.describe('Force creation of a new chat. Default: false'),
|
|
141
|
+
},
|
|
142
|
+
handler: async (request, response, context) => {
|
|
143
|
+
const { question, projectName, createNewChat = false } = request.params;
|
|
144
|
+
const sanitizedQuestion = sanitizeQuestion(question);
|
|
145
|
+
const project = projectName || path.basename(process.cwd()) || 'unknown-project';
|
|
146
|
+
const page = context.getSelectedPage();
|
|
147
|
+
try {
|
|
148
|
+
response.appendResponseLine('Geminiに接続中...');
|
|
149
|
+
await page.goto(GEMINI_CONFIG.DEFAULT_URL, { waitUntil: 'networkidle2' });
|
|
150
|
+
const needsLogin = await isLoginRequired(page);
|
|
151
|
+
if (needsLogin) {
|
|
152
|
+
response.appendResponseLine('\n❌ Geminiへのログインが必要です');
|
|
153
|
+
response.appendResponseLine('ブラウザでログインしてください。');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
response.appendResponseLine('✅ ログイン確認完了');
|
|
157
|
+
let isNewChat = false;
|
|
158
|
+
let sessionChatId;
|
|
159
|
+
if (!createNewChat) {
|
|
160
|
+
const sessions = await loadChatSessions();
|
|
161
|
+
const projectSessions = sessions[project] || [];
|
|
162
|
+
if (projectSessions.length > 0) {
|
|
163
|
+
const sortedSessions = [...projectSessions].sort((a, b) => new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime());
|
|
164
|
+
const latestSession = sortedSessions[0];
|
|
165
|
+
response.appendResponseLine(`既存のチャットを使用: ${latestSession.url}`);
|
|
166
|
+
await page.goto(latestSession.url, { waitUntil: 'networkidle2' });
|
|
167
|
+
sessionChatId = latestSession.chatId;
|
|
168
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
isNewChat = true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
isNewChat = true;
|
|
176
|
+
}
|
|
177
|
+
if (isNewChat) {
|
|
178
|
+
response.appendResponseLine('新規チャットを作成中...');
|
|
179
|
+
await page.goto(GEMINI_CONFIG.BASE_URL + 'app', { waitUntil: 'networkidle2' });
|
|
180
|
+
}
|
|
181
|
+
response.appendResponseLine('質問を送信中...');
|
|
182
|
+
// Input text using the textbox element
|
|
183
|
+
// Gemini uses a textbox with role="textbox" or a div with contenteditable
|
|
184
|
+
const questionSent = await page.evaluate((questionText) => {
|
|
185
|
+
// Try textbox first (Gemini's current implementation)
|
|
186
|
+
const textbox = document.querySelector('[role="textbox"]');
|
|
187
|
+
if (textbox) {
|
|
188
|
+
textbox.focus();
|
|
189
|
+
// Clear existing content
|
|
190
|
+
textbox.innerHTML = '';
|
|
191
|
+
// Insert text
|
|
192
|
+
const p = document.createElement('p');
|
|
193
|
+
p.textContent = questionText;
|
|
194
|
+
textbox.appendChild(p);
|
|
195
|
+
textbox.dispatchEvent(new Event('input', { bubbles: true }));
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
// Fallback to contenteditable
|
|
199
|
+
const editor = document.querySelector('div[contenteditable="true"]');
|
|
200
|
+
if (editor) {
|
|
201
|
+
editor.innerHTML = '';
|
|
202
|
+
const p = document.createElement('p');
|
|
203
|
+
p.textContent = questionText;
|
|
204
|
+
editor.appendChild(p);
|
|
205
|
+
editor.dispatchEvent(new Event('input', { bubbles: true }));
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}, sanitizedQuestion);
|
|
210
|
+
if (!questionSent) {
|
|
211
|
+
response.appendResponseLine('❌ 入力欄が見つかりません');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
215
|
+
// Click send button - look for "プロンプトを送信" or similar
|
|
216
|
+
const sent = await page.evaluate(() => {
|
|
217
|
+
const buttons = Array.from(document.querySelectorAll('button'));
|
|
218
|
+
// Primary: look for "プロンプトを送信" button
|
|
219
|
+
let sendButton = buttons.find(b => b.textContent?.includes('プロンプトを送信') ||
|
|
220
|
+
b.textContent?.includes('送信') ||
|
|
221
|
+
b.getAttribute('aria-label')?.includes('送信') ||
|
|
222
|
+
b.getAttribute('aria-label')?.includes('Send'));
|
|
223
|
+
// Fallback: look for send icon
|
|
224
|
+
if (!sendButton) {
|
|
225
|
+
sendButton = buttons.find(b => b.querySelector('mat-icon[data-mat-icon-name="send"]') ||
|
|
226
|
+
b.querySelector('[data-icon="send"]'));
|
|
227
|
+
}
|
|
228
|
+
if (sendButton && !sendButton.disabled) {
|
|
229
|
+
sendButton.click();
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
return false;
|
|
233
|
+
});
|
|
234
|
+
if (!sent) {
|
|
235
|
+
// Fallback: try Enter key
|
|
236
|
+
await page.keyboard.press('Enter');
|
|
237
|
+
response.appendResponseLine('⚠️ 送信ボタンが見つかりません (Enterキーを試行)');
|
|
238
|
+
}
|
|
239
|
+
response.appendResponseLine('回答を待機中...');
|
|
240
|
+
// Wait for response using actual Gemini UI indicators:
|
|
241
|
+
// - Generating: "回答を停止" button appears, "Gemini が入力中です" text
|
|
242
|
+
// - Complete: "Gemini が回答しました" text appears
|
|
243
|
+
const startTime = Date.now();
|
|
244
|
+
let stableCount = 0;
|
|
245
|
+
let lastResponseText = '';
|
|
246
|
+
while (true) {
|
|
247
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
248
|
+
const status = await page.evaluate(() => {
|
|
249
|
+
// Check for generating indicators
|
|
250
|
+
const buttons = Array.from(document.querySelectorAll('button'));
|
|
251
|
+
const stopButton = buttons.find(b => b.textContent?.includes('回答を停止') ||
|
|
252
|
+
b.textContent?.includes('Stop') ||
|
|
253
|
+
b.getAttribute('aria-label')?.includes('Stop'));
|
|
254
|
+
// Check for status text
|
|
255
|
+
const bodyText = document.body.innerText;
|
|
256
|
+
const isTyping = bodyText.includes('Gemini が入力中です') ||
|
|
257
|
+
bodyText.includes('Gemini is typing');
|
|
258
|
+
const isComplete = bodyText.includes('Gemini が回答しました') ||
|
|
259
|
+
bodyText.includes('Gemini has responded');
|
|
260
|
+
const isGenerating = !!stopButton || isTyping;
|
|
261
|
+
// Get the response content from model-response elements
|
|
262
|
+
const modelResponses = Array.from(document.querySelectorAll('model-response'));
|
|
263
|
+
let responseContent = '';
|
|
264
|
+
if (modelResponses.length > 0) {
|
|
265
|
+
// Get the last model response
|
|
266
|
+
const lastResponse = modelResponses[modelResponses.length - 1];
|
|
267
|
+
responseContent = lastResponse.textContent || '';
|
|
268
|
+
}
|
|
269
|
+
// Fallback: get text from main area
|
|
270
|
+
if (!responseContent) {
|
|
271
|
+
const main = document.querySelector('main');
|
|
272
|
+
responseContent = main?.innerText || '';
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
isGenerating,
|
|
276
|
+
isComplete,
|
|
277
|
+
responseContent
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
// If explicitly marked as complete, we're done
|
|
281
|
+
if (status.isComplete && !status.isGenerating) {
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
// If not generating and response text is stable for 2 iterations, we're done
|
|
285
|
+
if (!status.isGenerating && status.responseContent === lastResponseText && status.responseContent.length > 0) {
|
|
286
|
+
stableCount++;
|
|
287
|
+
if (stableCount >= 2) {
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
stableCount = 0;
|
|
293
|
+
}
|
|
294
|
+
lastResponseText = status.responseContent;
|
|
295
|
+
if (Date.now() - startTime > 180000) { // 3 mins timeout
|
|
296
|
+
response.appendResponseLine('⚠️ タイムアウト(3分)');
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Get the final response content
|
|
301
|
+
const responseText = await page.evaluate(() => {
|
|
302
|
+
// Get content from model-response elements
|
|
303
|
+
const modelResponses = Array.from(document.querySelectorAll('model-response'));
|
|
304
|
+
if (modelResponses.length > 0) {
|
|
305
|
+
// Get the last model response
|
|
306
|
+
const lastResponse = modelResponses[modelResponses.length - 1];
|
|
307
|
+
return lastResponse.textContent?.trim() || '';
|
|
308
|
+
}
|
|
309
|
+
// Fallback: get text from main area
|
|
310
|
+
const main = document.querySelector('main');
|
|
311
|
+
return main?.innerText.slice(-5000) || '';
|
|
312
|
+
});
|
|
313
|
+
response.appendResponseLine('✅ 回答完了');
|
|
314
|
+
// Save session
|
|
315
|
+
if (isNewChat) {
|
|
316
|
+
const chatUrl = page.url();
|
|
317
|
+
const chatIdMatch = chatUrl.match(/\/app\/([a-f0-9]+)/);
|
|
318
|
+
const chatId = chatIdMatch ? chatIdMatch[1] : 'unknown-' + Date.now();
|
|
319
|
+
await saveChatSession(project, {
|
|
320
|
+
chatId,
|
|
321
|
+
url: chatUrl,
|
|
322
|
+
lastUsed: new Date().toISOString(),
|
|
323
|
+
createdAt: new Date().toISOString(),
|
|
324
|
+
title: `[Project: ${project}]`,
|
|
325
|
+
conversationCount: 1,
|
|
326
|
+
});
|
|
327
|
+
sessionChatId = chatId;
|
|
328
|
+
}
|
|
329
|
+
// Save log
|
|
330
|
+
const logPath = await saveConversationLog(project, sanitizedQuestion, responseText, {
|
|
331
|
+
chatUrl: page.url(),
|
|
332
|
+
chatId: sessionChatId,
|
|
333
|
+
});
|
|
334
|
+
response.appendResponseLine(`📝 会話ログ保存: ${logPath}`);
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
338
|
+
response.appendResponseLine(`❌ エラー: ${errorMessage}`);
|
|
339
|
+
// Error snapshot
|
|
340
|
+
try {
|
|
341
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
342
|
+
const debugDir = path.join(process.cwd(), 'docs/ask/gemini/debug');
|
|
343
|
+
await fs.promises.mkdir(debugDir, { recursive: true });
|
|
344
|
+
const screenshotPath = path.join(debugDir, `error-${timestamp}.png`);
|
|
345
|
+
await page.screenshot({ path: screenshotPath });
|
|
346
|
+
response.appendResponseLine(`📸 エラー時のスクリーンショット: ${screenshotPath}`);
|
|
347
|
+
const htmlPath = path.join(debugDir, `error-${timestamp}.html`);
|
|
348
|
+
const html = await page.content();
|
|
349
|
+
await fs.promises.writeFile(htmlPath, html, 'utf-8');
|
|
350
|
+
response.appendResponseLine(`📄 エラー時のHTML: ${htmlPath}`);
|
|
351
|
+
}
|
|
352
|
+
catch (snapshotError) {
|
|
353
|
+
console.error('Failed to capture error snapshot:', snapshotError);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
});
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrome-devtools-mcp-for-extension",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.2",
|
|
4
4
|
"description": "MCP server for Chrome extension development with Web Store automation. Fork of chrome-devtools-mcp with extension-specific tools.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin": "./
|
|
6
|
+
"bin": "./scripts/cli.mjs",
|
|
7
7
|
"main": "index.js",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"build": "tsc && node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/post-build.ts",
|
|
10
|
+
"dev": "MCP_ENV=development node scripts/mcp-wrapper.mjs",
|
|
10
11
|
"typecheck": "tsc --noEmit",
|
|
11
12
|
"format": "eslint --cache --fix . && prettier --write --cache .",
|
|
12
13
|
"check-format": "eslint --cache . && prettier --check --cache .;",
|
|
@@ -68,15 +69,16 @@
|
|
|
68
69
|
"@types/yargs": "^17.0.33",
|
|
69
70
|
"@typescript-eslint/eslint-plugin": "^8.43.0",
|
|
70
71
|
"@typescript-eslint/parser": "^8.43.0",
|
|
72
|
+
"chokidar": "^4.0.3",
|
|
71
73
|
"chrome-devtools-frontend": "1.0.1520535",
|
|
72
74
|
"eslint": "^9.35.0",
|
|
73
|
-
"eslint-plugin-import": "^2.32.0",
|
|
74
75
|
"eslint-import-resolver-typescript": "^4.4.4",
|
|
76
|
+
"eslint-plugin-import": "^2.32.0",
|
|
75
77
|
"globals": "^16.4.0",
|
|
76
78
|
"prettier": "^3.6.2",
|
|
77
79
|
"sinon": "^21.0.0",
|
|
78
|
-
"typescript
|
|
79
|
-
"typescript": "^
|
|
80
|
+
"typescript": "^5.9.2",
|
|
81
|
+
"typescript-eslint": "^8.43.0"
|
|
80
82
|
},
|
|
81
83
|
"engines": {
|
|
82
84
|
"node": ">=22.12.0"
|
package/scripts/cli.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI Entry Point for chrome-devtools-mcp-for-extension
|
|
4
|
+
*
|
|
5
|
+
* This is the entry point when users run:
|
|
6
|
+
* npx chrome-devtools-mcp-for-extension
|
|
7
|
+
* chrome-devtools-mcp-for-extension (if globally installed)
|
|
8
|
+
*
|
|
9
|
+
* Launches the MCP server with browser globals mock:
|
|
10
|
+
* - Loads browser-globals-mock.mjs BEFORE main.js
|
|
11
|
+
* - Ensures chrome-devtools-frontend modules work in Node.js
|
|
12
|
+
* - Simple execution: no wrapper, no hot-reload
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import process from "node:process";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
// Resolve paths
|
|
21
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const mockPath = path.join(__dirname, "browser-globals-mock.mjs");
|
|
23
|
+
const mainPath = path.join(__dirname, "..", "build", "src", "main.js");
|
|
24
|
+
|
|
25
|
+
// Launch MCP server with --import flag
|
|
26
|
+
const child = spawn(process.execPath, [
|
|
27
|
+
"--import", mockPath,
|
|
28
|
+
mainPath,
|
|
29
|
+
...process.argv.slice(2) // Forward CLI arguments
|
|
30
|
+
], {
|
|
31
|
+
stdio: "inherit",
|
|
32
|
+
env: process.env,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
child.on("exit", (code, signal) => {
|
|
36
|
+
if (signal) {
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
process.exit(code ?? 0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Forward signals
|
|
43
|
+
process.on("SIGTERM", () => child?.kill("SIGTERM"));
|
|
44
|
+
process.on("SIGINT", () => child?.kill("SIGINT"));
|