synabun 2026.4.27 → 2026.5.2-beta
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/CHANGELOG.md +189 -9
- package/README.md +67 -7
- package/hooks/claude-code/post-plan.mjs +56 -3
- package/hooks/claude-code/session-start.mjs +28 -1
- package/hooks/claude-code/shared.mjs +13 -1
- package/hooks/claude-code/stop.mjs +23 -0
- package/mcp-server/src/index.ts +4 -0
- package/mcp-server/src/services/profiles.ts +113 -7
- package/mcp-server/src/tools/gsc/experience.ts +440 -0
- package/mcp-server/src/tools/gsc/helpers.ts +288 -0
- package/mcp-server/src/tools/gsc/indexing.ts +495 -0
- package/mcp-server/src/tools/gsc/inspect.ts +391 -0
- package/mcp-server/src/tools/gsc/navigate.ts +236 -0
- package/mcp-server/src/tools/gsc/performance.ts +352 -0
- package/mcp-server/src/tools/gsc/settings.ts +564 -0
- package/mcp-server/src/tools/gsc.ts +95 -0
- package/mcp-server/src/tools/loop.ts +62 -15
- package/mcp-server/src/tools/profile.ts +1 -0
- package/neural-interface/public/shared/cdx/cdx-icons.js +1 -0
- package/neural-interface/public/shared/cdx/cdx-panel.js +24 -5
- package/neural-interface/public/shared/cdx/cdx-tabs.js +42 -0
- package/neural-interface/public/shared/html-shell.js +35 -35
- package/neural-interface/public/shared/ocp/ocp-panel.js +255 -25
- package/neural-interface/public/shared/ocp/ocp-render.js +152 -3
- package/neural-interface/public/shared/ocp/ocp-styles.js +520 -31
- package/neural-interface/public/shared/ocp/ocp-tabs.js +786 -128
- package/neural-interface/public/shared/provider-icons.js +2 -0
- package/neural-interface/public/shared/storage.js +5 -0
- package/neural-interface/public/shared/styles.css +538 -141
- package/neural-interface/public/shared/ui-claude-panel.js +35 -17
- package/neural-interface/public/shared/ui-file-explorer.js +65 -41
- package/neural-interface/public/shared/ui-menubar.js +17 -3
- package/neural-interface/public/shared/ui-navbar.js +33 -9
- package/neural-interface/public/shared/ui-sessions.js +613 -23
- package/neural-interface/public/shared/ui-settings.js +1 -2
- package/neural-interface/public/shared/ui-sidepanel-tray.js +173 -22
- package/neural-interface/public/shared/ui-sync.js +11 -5
- package/neural-interface/public/shared/ui-tool-updates.js +35 -16
- package/neural-interface/public/shared/ui-tutorial-steps.js +3 -3
- package/neural-interface/public/shared/ui-update.js +17 -2
- package/neural-interface/server.js +791 -21
- package/neural-interface/templates/CLAUDE-template.md +19 -6
- package/neural-interface/templates/ruleset-versions.json +31 -0
- package/package.json +1 -1
- package/setup.js +96 -37
package/mcp-server/src/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { registerDiscordTools } from './tools/discord.js';
|
|
|
23
23
|
import { registerGitTools } from './tools/git.js';
|
|
24
24
|
import { registerLeonardoTools } from './tools/leonardo.js';
|
|
25
25
|
import { registerImageTools } from './tools/image.js';
|
|
26
|
+
import { registerGscTools } from './tools/gsc.js';
|
|
26
27
|
import { profileSchema, profileDescription, handleProfile } from './tools/profile.js';
|
|
27
28
|
import {
|
|
28
29
|
PROFILE_PRESETS, VALID_GROUPS, PROFILE_PATH,
|
|
@@ -56,6 +57,7 @@ const TOOL_GROUP_INSTRUCTIONS: Record<string, string> = {
|
|
|
56
57
|
git: '- Git: git (action: status/diff/commit/log/branches)',
|
|
57
58
|
image: '- Images: image_staged (action: list/clear/remove)',
|
|
58
59
|
leonardo: '- Leonardo (browser-based): leonardo_browser_navigate, leonardo_browser_generate, leonardo_browser_library, leonardo_browser_download, leonardo_browser_reference',
|
|
60
|
+
gsc: '- Google Search Console (browser-based): gsc_navigate, gsc_property, gsc_inspect_url, gsc_inspect_test_live, gsc_inspect_request_indexing, gsc_inspect_view_crawled, gsc_performance_query, gsc_performance_export, gsc_performance_chart_screenshot, gsc_pages_report, gsc_pages_validate_fix, gsc_videos_report, gsc_sitemap, gsc_removals, gsc_removals_cancel, gsc_cwv_report, gsc_https_report, gsc_security_issues, gsc_manual_actions, gsc_enhancements, gsc_links_report, gsc_links_export, gsc_settings, gsc_crawl_stats, gsc_users, gsc_associations, gsc_disavow, gsc_shopping, gsc_extract_table, gsc_screenshot',
|
|
59
61
|
};
|
|
60
62
|
|
|
61
63
|
function buildServerInstructions(): string {
|
|
@@ -132,6 +134,7 @@ export function registerTools(server: McpServer) {
|
|
|
132
134
|
setToolGroup('git', registerGitTools(server));
|
|
133
135
|
setToolGroup('leonardo', registerLeonardoTools(server));
|
|
134
136
|
setToolGroup('image', registerImageTools(server));
|
|
137
|
+
setToolGroup('gsc', registerGscTools(server));
|
|
135
138
|
|
|
136
139
|
// Apply initial profile (disables groups not in the active profile)
|
|
137
140
|
applyProfile(getActiveProfileName());
|
|
@@ -198,6 +201,7 @@ export function getToolUsageSummary(): { counts: Record<string, number>; profile
|
|
|
198
201
|
git: ['git'],
|
|
199
202
|
leonardo: ['leonardo_'],
|
|
200
203
|
image: ['image_staged'],
|
|
204
|
+
gsc: ['gsc_'],
|
|
201
205
|
};
|
|
202
206
|
const usedGroups = new Set<string>();
|
|
203
207
|
for (const tool of Object.keys(_usageCounts)) {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import type { RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
6
|
import { config } from '../config.js';
|
|
7
7
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
8
|
-
import { join } from 'path';
|
|
8
|
+
import { dirname, join } from 'path';
|
|
9
9
|
|
|
10
10
|
// ── Presets and constants ──
|
|
11
11
|
|
|
@@ -21,14 +21,15 @@ export const PROFILE_PRESETS: Record<string, string[]> = {
|
|
|
21
21
|
whatsapp: ['git', 'image', 'browser', 'browser_whatsapp'],
|
|
22
22
|
instagram: ['git', 'image', 'browser', 'browser_instagram'],
|
|
23
23
|
linkedin: ['git', 'image', 'browser', 'browser_linkedin'],
|
|
24
|
-
browser: ['git', 'image', 'whiteboard', 'card', 'tictactoe', ...ALL_BROWSER_GROUPS, 'leonardo'],
|
|
25
|
-
full: ['git', 'image', 'whiteboard', 'card', 'tictactoe', ...ALL_BROWSER_GROUPS, 'leonardo', 'discord'],
|
|
24
|
+
browser: ['git', 'image', 'whiteboard', 'card', 'tictactoe', ...ALL_BROWSER_GROUPS, 'leonardo', 'gsc'],
|
|
25
|
+
full: ['git', 'image', 'whiteboard', 'card', 'tictactoe', ...ALL_BROWSER_GROUPS, 'leonardo', 'discord', 'gsc'],
|
|
26
26
|
leonardoai: ['leonardo'],
|
|
27
|
+
gsc: ['git', 'image', 'browser', 'gsc'],
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
export const VALID_GROUPS = new Set([
|
|
30
31
|
...ALL_BROWSER_GROUPS,
|
|
31
|
-
'whiteboard', 'card', 'tictactoe', 'discord', 'git', 'leonardo', 'image',
|
|
32
|
+
'whiteboard', 'card', 'tictactoe', 'discord', 'git', 'leonardo', 'image', 'gsc',
|
|
32
33
|
]);
|
|
33
34
|
|
|
34
35
|
export const PROFILE_PATH = join(config.dataDir, 'active-profile.json');
|
|
@@ -136,11 +137,116 @@ export function applyProfile(profileName: string): { profile: string; enabled: s
|
|
|
136
137
|
}
|
|
137
138
|
|
|
138
139
|
export function persistProfile(profileName: string) {
|
|
140
|
+
const payload = JSON.stringify({ profile: profileName }, null, 2) + '\n';
|
|
141
|
+
const paths = new Set<string>([PROFILE_PATH]);
|
|
142
|
+
|
|
143
|
+
if (process.env.SYNABUN_DATA_HOME) {
|
|
144
|
+
paths.add(join(process.env.SYNABUN_DATA_HOME, 'mcp-data', 'active-profile.json'));
|
|
145
|
+
paths.add(join(process.env.SYNABUN_DATA_HOME, 'data', 'active-profile.json'));
|
|
146
|
+
paths.add(join(process.env.SYNABUN_DATA_HOME, 'mcp-server', 'data', 'active-profile.json'));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const profilePath of paths) {
|
|
150
|
+
writeProfileFile(profilePath, payload);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
syncCodexProfileEnv(profileName);
|
|
154
|
+
syncOpenCodeProfileEnv(profileName);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function writeProfileFile(profilePath: string, payload: string) {
|
|
139
158
|
try {
|
|
140
|
-
const dir =
|
|
159
|
+
const dir = dirname(profilePath);
|
|
141
160
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
142
|
-
writeFileSync(
|
|
161
|
+
writeFileSync(profilePath, payload, 'utf-8');
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error(`[SynaBun] Failed to persist profile at ${profilePath}:`, err);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function homeDir(): string {
|
|
168
|
+
return process.env.USERPROFILE || process.env.HOME || '';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function escapeRegExp(value: string): string {
|
|
172
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function tomlSectionRange(content: string, sectionName: string): { start: number; end: number } | null {
|
|
176
|
+
const startRe = new RegExp(`^\\[${escapeRegExp(sectionName)}\\]\\s*$`, 'm');
|
|
177
|
+
const startMatch = startRe.exec(content);
|
|
178
|
+
if (!startMatch) return null;
|
|
179
|
+
|
|
180
|
+
const afterHeader = startMatch.index + startMatch[0].length;
|
|
181
|
+
const rest = content.slice(afterHeader);
|
|
182
|
+
const nextMatch = /^\[[^\]]+\]\s*$/m.exec(rest);
|
|
183
|
+
return {
|
|
184
|
+
start: startMatch.index,
|
|
185
|
+
end: nextMatch ? afterHeader + nextMatch.index : content.length,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function upsertInlineTomlString(body: string, key: string, value: string): string {
|
|
190
|
+
const pair = `${key} = "${value}"`;
|
|
191
|
+
const keyRe = new RegExp(`${escapeRegExp(key)}\\s*=\\s*"[^"]*"`);
|
|
192
|
+
if (keyRe.test(body)) return body.replace(keyRe, pair);
|
|
193
|
+
const trimmed = body.trim().replace(/,$/, '');
|
|
194
|
+
return trimmed ? `${trimmed}, ${pair}` : pair;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function syncCodexProfileEnv(profileName: string): boolean {
|
|
198
|
+
try {
|
|
199
|
+
const home = homeDir();
|
|
200
|
+
if (!home) return false;
|
|
201
|
+
const configPath = join(home, '.codex', 'config.toml');
|
|
202
|
+
if (!existsSync(configPath)) return false;
|
|
203
|
+
|
|
204
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
205
|
+
const range = tomlSectionRange(content, 'mcp_servers.SynaBun');
|
|
206
|
+
if (!range) return false;
|
|
207
|
+
|
|
208
|
+
const section = content.slice(range.start, range.end);
|
|
209
|
+
const envRe = /^env\s*=\s*\{([^}]*)\}/m;
|
|
210
|
+
const envMatch = section.match(envRe);
|
|
211
|
+
let nextSection: string;
|
|
212
|
+
|
|
213
|
+
if (envMatch) {
|
|
214
|
+
let body = envMatch[1].trim();
|
|
215
|
+
body = upsertInlineTomlString(body, 'SYNABUN_PROFILE', profileName);
|
|
216
|
+
body = upsertInlineTomlString(body, 'SYNABUN_BROWSER_FAST', '1');
|
|
217
|
+
body = upsertInlineTomlString(body, 'SYNABUN_BROWSER_COMPACT', '1');
|
|
218
|
+
nextSection = section.replace(envRe, `env = { ${body} }`);
|
|
219
|
+
} else {
|
|
220
|
+
nextSection = `${section.trimEnd()}\nenv = { SYNABUN_PROFILE = "${profileName}", SYNABUN_BROWSER_FAST = "1", SYNABUN_BROWSER_COMPACT = "1" }\n`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (nextSection === section) return false;
|
|
224
|
+
writeFileSync(configPath, content.slice(0, range.start) + nextSection + content.slice(range.end), 'utf-8');
|
|
225
|
+
return true;
|
|
226
|
+
} catch (err) {
|
|
227
|
+
console.error('[SynaBun] Failed to sync Codex profile env:', err);
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function syncOpenCodeProfileEnv(profileName: string): boolean {
|
|
233
|
+
try {
|
|
234
|
+
const home = homeDir();
|
|
235
|
+
if (!home) return false;
|
|
236
|
+
const configPath = process.env.XDG_CONFIG_HOME
|
|
237
|
+
? join(process.env.XDG_CONFIG_HOME, 'opencode', 'config.json')
|
|
238
|
+
: join(home, '.config', 'opencode', 'config.json');
|
|
239
|
+
if (!existsSync(configPath)) return false;
|
|
240
|
+
|
|
241
|
+
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
242
|
+
if (!config?.mcp?.SynaBun) return false;
|
|
243
|
+
if (!config.mcp.SynaBun.environment) config.mcp.SynaBun.environment = {};
|
|
244
|
+
if (config.mcp.SynaBun.environment.SYNABUN_PROFILE === profileName) return false;
|
|
245
|
+
config.mcp.SynaBun.environment.SYNABUN_PROFILE = profileName;
|
|
246
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
247
|
+
return true;
|
|
143
248
|
} catch (err) {
|
|
144
|
-
console.error('[SynaBun] Failed to
|
|
249
|
+
console.error('[SynaBun] Failed to sync OpenCode profile env:', err);
|
|
250
|
+
return false;
|
|
145
251
|
}
|
|
146
252
|
}
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSC Experience + Enhancements + Links.
|
|
3
|
+
* Tools: gsc_cwv_report, gsc_https_report, gsc_security_issues,
|
|
4
|
+
* gsc_manual_actions, gsc_enhancements, gsc_links_report, gsc_links_export
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import * as ni from '../../services/neural-interface.js';
|
|
9
|
+
import { text } from '../response.js';
|
|
10
|
+
import { GSC_BASE, encodeProperty, currentProperty, ensureAuth, resolve, wait, pollFor, safeNavigate, TABLE_EXTRACTOR_SCRIPT, readMetricTileScript } from './helpers.js';
|
|
11
|
+
|
|
12
|
+
const tabId = z.string().optional();
|
|
13
|
+
|
|
14
|
+
async function loadAndExtract(
|
|
15
|
+
sessionId: string | undefined,
|
|
16
|
+
tabIdArg: string | undefined,
|
|
17
|
+
property: string | undefined,
|
|
18
|
+
path: string,
|
|
19
|
+
) {
|
|
20
|
+
const r = await resolve(sessionId, tabIdArg);
|
|
21
|
+
if ('error' in r) return { err: r.error, r: null as never };
|
|
22
|
+
const prop = property || (await currentProperty(r));
|
|
23
|
+
if (!prop) return { err: 'No active property.', r };
|
|
24
|
+
const sep = path.includes('?') ? '&' : '?';
|
|
25
|
+
const nav = await safeNavigate(r, `${GSC_BASE}${path}${sep}resource_id=${encodeProperty(prop)}`);
|
|
26
|
+
if (nav.error) return { err: `Navigation failed: ${nav.error}`, r };
|
|
27
|
+
const authErr = await ensureAuth(r);
|
|
28
|
+
if (authErr) return { err: authErr, r };
|
|
29
|
+
await pollFor(r, `(() => document.body.innerText.length > 200 ? true : null)()`, 20000, 500);
|
|
30
|
+
await wait(700);
|
|
31
|
+
return { err: null, r };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── gsc_cwv_report ────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export const gscCwvReportSchema = {
|
|
37
|
+
device: z.enum(['mobile', 'desktop'] as const),
|
|
38
|
+
property: z.string().optional(),
|
|
39
|
+
sessionId: z.string().optional(),
|
|
40
|
+
tabId,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const gscCwvReportDescription =
|
|
44
|
+
'Read Core Web Vitals report for mobile or desktop. Returns counts of poor / needs-improvement / good URLs and per-issue URL group examples.';
|
|
45
|
+
|
|
46
|
+
export async function handleGscCwvReport(args: { device: string; property?: string; sessionId?: string; tabId?: string }) {
|
|
47
|
+
const path = args.device === 'desktop' ? '/core-web-vitals?device=DESKTOP' : '/core-web-vitals?device=MOBILE';
|
|
48
|
+
const { err, r } = await loadAndExtract(args.sessionId, args.tabId, args.property, path);
|
|
49
|
+
if (err) return text(err);
|
|
50
|
+
|
|
51
|
+
const noData = await ni.evaluate(
|
|
52
|
+
r.sessionId,
|
|
53
|
+
`(() => /Nenhum dado|No data|No CWV data/i.test(document.body.innerText) && !document.querySelector('[role="grid"], [role="table"]'))()`,
|
|
54
|
+
r.tabId,
|
|
55
|
+
);
|
|
56
|
+
if (noData.result) {
|
|
57
|
+
return text(JSON.stringify({ device: args.device, summary: { poor: '0', needsImprovement: '0', good: '0' }, issues: { headers: [], rows: [] }, status: 'no_data' }, null, 2));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const [poorRes, niRes, goodRes, tableRes] = await Promise.all([
|
|
61
|
+
ni.evaluate(r.sessionId, readMetricTileScript('poor'), r.tabId),
|
|
62
|
+
ni.evaluate(r.sessionId, readMetricTileScript('needsImprovement'), r.tabId),
|
|
63
|
+
ni.evaluate(r.sessionId, readMetricTileScript('good'), r.tabId),
|
|
64
|
+
ni.evaluate(r.sessionId, TABLE_EXTRACTOR_SCRIPT('[role="grid"], [role="table"], table'), r.tabId),
|
|
65
|
+
]);
|
|
66
|
+
const summary = {
|
|
67
|
+
poor: (poorRes.result as string | null) ?? null,
|
|
68
|
+
needsImprovement: (niRes.result as string | null) ?? null,
|
|
69
|
+
good: (goodRes.result as string | null) ?? null,
|
|
70
|
+
};
|
|
71
|
+
// Distinguish "no data" (caught above) from "parse failed" — when the
|
|
72
|
+
// tile reader missed every metric AND no grid rendered, surface that.
|
|
73
|
+
if (summary.poor === null && summary.needsImprovement === null && summary.good === null && !tableRes.result) {
|
|
74
|
+
return text(JSON.stringify({ device: args.device, summary, issues: { headers: [], rows: [] }, status: 'parse_failed' }, null, 2));
|
|
75
|
+
}
|
|
76
|
+
return text(JSON.stringify({ device: args.device, summary, issues: tableRes.result || { headers: [], rows: [] } }, null, 2));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── gsc_https_report ──────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
export const gscHttpsReportSchema = {
|
|
82
|
+
property: z.string().optional(),
|
|
83
|
+
sessionId: z.string().optional(),
|
|
84
|
+
tabId,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const gscHttpsReportDescription =
|
|
88
|
+
'Read the HTTPS report — counts of HTTPS vs non-HTTPS URLs and reasons for non-HTTPS URLs with examples.';
|
|
89
|
+
|
|
90
|
+
export async function handleGscHttpsReport(args: { property?: string; sessionId?: string; tabId?: string }) {
|
|
91
|
+
const { err, r } = await loadAndExtract(args.sessionId, args.tabId, args.property, '/https');
|
|
92
|
+
if (err) return text(err);
|
|
93
|
+
// The HTTPS report often shows a tiny per-row table with HTTPS / Não HTTPS
|
|
94
|
+
// headers — read counts directly from the row instead of grepping body text.
|
|
95
|
+
const summary = await ni.evaluate(
|
|
96
|
+
r.sessionId,
|
|
97
|
+
`(() => {
|
|
98
|
+
const cells = Array.from(document.querySelectorAll('th, td, [role="columnheader"], [role="gridcell"], [role="cell"]'));
|
|
99
|
+
let httpsUrls = null, nonHttpsUrls = null;
|
|
100
|
+
const norm = (el) => (el.innerText || el.textContent || '').trim();
|
|
101
|
+
for (let i = 0; i < cells.length; i++) {
|
|
102
|
+
const t = norm(cells[i]);
|
|
103
|
+
if (/^HTTPS$/i.test(t)) {
|
|
104
|
+
// The number is usually in the next sibling column or row.
|
|
105
|
+
const sib = cells[i + 1] || cells[i].parentElement?.nextElementSibling?.querySelector('td, [role="gridcell"]');
|
|
106
|
+
if (sib) {
|
|
107
|
+
const m = norm(sib).match(/-?\\d[\\d.,]*/);
|
|
108
|
+
if (m) httpsUrls = m[0];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (/^(Non[- ]?HTTPS|N[aã]o\\s*HTTPS|HTTP)$/i.test(t)) {
|
|
112
|
+
const sib = cells[i + 1] || cells[i].parentElement?.nextElementSibling?.querySelector('td, [role="gridcell"]');
|
|
113
|
+
if (sib) {
|
|
114
|
+
const m = norm(sib).match(/-?\\d[\\d.,]*/);
|
|
115
|
+
if (m) nonHttpsUrls = m[0];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { httpsUrls, nonHttpsUrls };
|
|
120
|
+
})()`,
|
|
121
|
+
r.tabId,
|
|
122
|
+
);
|
|
123
|
+
let s = (summary.result as { httpsUrls: string | null; nonHttpsUrls: string | null } | null) || { httpsUrls: null, nonHttpsUrls: null };
|
|
124
|
+
if (s.httpsUrls === null && s.nonHttpsUrls === null) {
|
|
125
|
+
const [hRes, nRes] = await Promise.all([
|
|
126
|
+
ni.evaluate(r.sessionId, readMetricTileScript('httpsUrls'), r.tabId),
|
|
127
|
+
ni.evaluate(r.sessionId, readMetricTileScript('nonHttpsUrls'), r.tabId),
|
|
128
|
+
]);
|
|
129
|
+
s = {
|
|
130
|
+
httpsUrls: (hRes.result as string | null) ?? null,
|
|
131
|
+
nonHttpsUrls: (nRes.result as string | null) ?? null,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const tableRes = await ni.evaluate(r.sessionId, TABLE_EXTRACTOR_SCRIPT('[role="grid"], [role="table"], table'), r.tabId);
|
|
135
|
+
return text(JSON.stringify({ summary: s, reasons: tableRes.result || { headers: [], rows: [] } }, null, 2));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── gsc_security_issues ───────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
export const gscSecurityIssuesSchema = {
|
|
141
|
+
action: z.enum(['list', 'request_review'] as const).optional().describe('list (default) returns active issues; request_review submits a reconsideration request.'),
|
|
142
|
+
property: z.string().optional(),
|
|
143
|
+
sessionId: z.string().optional(),
|
|
144
|
+
tabId,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const gscSecurityIssuesDescription =
|
|
148
|
+
'Read Security Issues. action=list returns active + history. action=request_review clicks "Request review" (mutating, requires fixed issues).';
|
|
149
|
+
|
|
150
|
+
export async function handleGscSecurityIssues(args: { action?: string; property?: string; sessionId?: string; tabId?: string }) {
|
|
151
|
+
const { err, r } = await loadAndExtract(args.sessionId, args.tabId, args.property, '/security-issues');
|
|
152
|
+
if (err) return text(err);
|
|
153
|
+
const action = args.action || 'list';
|
|
154
|
+
if (action === 'request_review') {
|
|
155
|
+
const click = await ni.evaluate(
|
|
156
|
+
r.sessionId,
|
|
157
|
+
`(() => { const btns = Array.from(document.querySelectorAll('button, [role="button"]')); const req = btns.find(b => /request\\s*review|solicitar\\s*revisão/i.test(b.textContent || '')); if (req) { req.click(); return true; } return false; })()`,
|
|
158
|
+
r.tabId,
|
|
159
|
+
);
|
|
160
|
+
if (!click.result) return text('Request review button not found — no fixable issues, or review already pending.');
|
|
161
|
+
return text('Review request flow opened. Use browser_snapshot to inspect form, then submit via browser_click.');
|
|
162
|
+
}
|
|
163
|
+
const issues = await ni.evaluate(
|
|
164
|
+
r.sessionId,
|
|
165
|
+
`(() => {
|
|
166
|
+
const cards = Array.from(document.querySelectorAll('section, [role="region"], .issue-card'));
|
|
167
|
+
return cards.map(c => c.innerText.trim().replace(/\\s+/g, ' ').slice(0, 600)).filter(t => t.length > 20);
|
|
168
|
+
})()`,
|
|
169
|
+
r.tabId,
|
|
170
|
+
);
|
|
171
|
+
return text(JSON.stringify({ issues: issues.result || [] }, null, 2));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── gsc_manual_actions ────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
export const gscManualActionsSchema = {
|
|
177
|
+
action: z.enum(['list', 'reconsideration'] as const).optional(),
|
|
178
|
+
body: z.string().optional().describe('Reconsideration request body text. Required for action=reconsideration.'),
|
|
179
|
+
property: z.string().optional(),
|
|
180
|
+
sessionId: z.string().optional(),
|
|
181
|
+
tabId,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export const gscManualActionsDescription =
|
|
185
|
+
'Read Manual Actions. action=list returns penalties (or "No issues detected"). action=reconsideration submits a reconsideration request with `body` (mutating).';
|
|
186
|
+
|
|
187
|
+
export async function handleGscManualActions(args: { action?: string; body?: string; property?: string; sessionId?: string; tabId?: string }) {
|
|
188
|
+
const { err, r } = await loadAndExtract(args.sessionId, args.tabId, args.property, '/manual-actions');
|
|
189
|
+
if (err) return text(err);
|
|
190
|
+
const action = args.action || 'list';
|
|
191
|
+
if (action === 'reconsideration') {
|
|
192
|
+
if (!args.body) return text('body required for reconsideration.');
|
|
193
|
+
await ni.evaluate(
|
|
194
|
+
r.sessionId,
|
|
195
|
+
`(() => { const btns = Array.from(document.querySelectorAll('button, [role="button"]')); const req = btns.find(b => /request\\s*review|solicitar\\s*reconsider/i.test(b.textContent || '')); if (req) req.click(); })()`,
|
|
196
|
+
r.tabId,
|
|
197
|
+
);
|
|
198
|
+
await wait(1500);
|
|
199
|
+
await ni.evaluate(
|
|
200
|
+
r.sessionId,
|
|
201
|
+
`(() => { const ta = document.querySelector('textarea'); if (ta) { ta.focus(); ta.value = ${JSON.stringify(args.body)}; ta.dispatchEvent(new Event('input', { bubbles: true })); } })()`,
|
|
202
|
+
r.tabId,
|
|
203
|
+
);
|
|
204
|
+
await wait(400);
|
|
205
|
+
await ni.evaluate(
|
|
206
|
+
r.sessionId,
|
|
207
|
+
`(() => { const btns = Array.from(document.querySelectorAll('button, [role="button"]')); const sub = btns.find(b => /^(submit|enviar)$/i.test((b.textContent || '').trim())); if (sub) sub.click(); })()`,
|
|
208
|
+
r.tabId,
|
|
209
|
+
);
|
|
210
|
+
return text('Reconsideration request submitted (verify with browser_snapshot).');
|
|
211
|
+
}
|
|
212
|
+
const issues = await ni.evaluate(
|
|
213
|
+
r.sessionId,
|
|
214
|
+
`(() => {
|
|
215
|
+
const main = document.querySelector('[role="main"], main') || document.body;
|
|
216
|
+
const txt = (main.innerText || '');
|
|
217
|
+
return {
|
|
218
|
+
status: /no\\s*issues|nenhum\\s*problema/i.test(txt) ? 'clean' : 'issues_present',
|
|
219
|
+
text: txt.slice(0, 4000),
|
|
220
|
+
};
|
|
221
|
+
})()`,
|
|
222
|
+
r.tabId,
|
|
223
|
+
);
|
|
224
|
+
return text(JSON.stringify(issues.result, null, 2));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── gsc_enhancements ──────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
const ENHANCE_PATHS: Record<string, string> = {
|
|
230
|
+
breadcrumbs: '/r/breadcrumbs',
|
|
231
|
+
faq: '/r/faq',
|
|
232
|
+
sitelinks: '/r/sitelinks-search-box',
|
|
233
|
+
videos: '/r/videos',
|
|
234
|
+
products: '/r/product-snippets',
|
|
235
|
+
recipes: '/r/recipes',
|
|
236
|
+
review: '/r/review-snippets',
|
|
237
|
+
events: '/r/events',
|
|
238
|
+
jobposting: '/r/job-postings',
|
|
239
|
+
speakable: '/r/speakable',
|
|
240
|
+
qa: '/r/q-a',
|
|
241
|
+
logos: '/r/logos',
|
|
242
|
+
sitenames: '/r/sitenames',
|
|
243
|
+
dataset: '/r/dataset',
|
|
244
|
+
practice: '/r/practice-problems',
|
|
245
|
+
math: '/r/math-solvers',
|
|
246
|
+
merchant: '/r/merchant-listings',
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
export const gscEnhancementsSchema = {
|
|
250
|
+
report: z.enum(Object.keys(ENHANCE_PATHS) as [string, ...string[]]),
|
|
251
|
+
property: z.string().optional(),
|
|
252
|
+
sessionId: z.string().optional(),
|
|
253
|
+
tabId,
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
export const gscEnhancementsDescription =
|
|
257
|
+
`Read a structured-data Enhancements report. Returns valid / warnings / errors counts and per-issue URL examples. Reports: ${Object.keys(ENHANCE_PATHS).join(', ')}.`;
|
|
258
|
+
|
|
259
|
+
export async function handleGscEnhancements(args: { report: string; property?: string; sessionId?: string; tabId?: string }) {
|
|
260
|
+
const path = ENHANCE_PATHS[args.report];
|
|
261
|
+
if (!path) return text(`Unknown enhancement report: ${args.report}`);
|
|
262
|
+
const { err, r } = await loadAndExtract(args.sessionId, args.tabId, args.property, path);
|
|
263
|
+
if (err) return text(err);
|
|
264
|
+
// Scope summary tile reads to the top-of-report metric region so we don't
|
|
265
|
+
// catch numbers from issue rows (which include "5xx" / "500", etc).
|
|
266
|
+
const summaryRes = await ni.evaluate(
|
|
267
|
+
r.sessionId,
|
|
268
|
+
`(() => {
|
|
269
|
+
const main = document.querySelector('[role="main"], main') || document.body;
|
|
270
|
+
// Find a region above the issues grid that contains the chart + tiles.
|
|
271
|
+
const grid = main.querySelector('[role="grid"], [role="table"], table');
|
|
272
|
+
const allRegions = Array.from(main.querySelectorAll('section, [role="region"], [role="group"], div'));
|
|
273
|
+
// Prefer a region that contains a chart (canvas/svg) AND comes before the grid.
|
|
274
|
+
let scope = null;
|
|
275
|
+
for (const region of allRegions) {
|
|
276
|
+
if (!region.querySelector('canvas, svg')) continue;
|
|
277
|
+
if (grid && (region.contains(grid) || region.compareDocumentPosition(grid) & Node.DOCUMENT_POSITION_FOLLOWING)) {
|
|
278
|
+
scope = region;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Fallback: the first heading group that mentions valid/warning/error.
|
|
283
|
+
if (!scope) {
|
|
284
|
+
const headings = Array.from(main.querySelectorAll('h2, h3, [role="heading"]'));
|
|
285
|
+
const h = headings.find(x => /valid|aviso|warning|erro|error/i.test(x.textContent || ''));
|
|
286
|
+
if (h) scope = h.closest('section, [role="region"], div') || h.parentElement;
|
|
287
|
+
}
|
|
288
|
+
if (!scope) scope = main;
|
|
289
|
+
const txt = (scope.innerText || '').replace(/\\s+/g, ' ');
|
|
290
|
+
// Inline label-to-number readers — replicate readMetricTileScript scope-bound.
|
|
291
|
+
const labels = {
|
|
292
|
+
valid: ${JSON.stringify(['Valid', 'Válido', 'Válidos', 'Válidas', 'Itens válidos', 'Entidades válidas'])},
|
|
293
|
+
warnings: ${JSON.stringify(['Warning', 'Warnings', 'Aviso', 'Avisos'])},
|
|
294
|
+
errors: ${JSON.stringify(['Error', 'Errors', 'Erro', 'Erros', 'Entidades inválidas'])},
|
|
295
|
+
};
|
|
296
|
+
const numRe = /-?\\d[\\d.,]*/;
|
|
297
|
+
const findFor = (alts) => {
|
|
298
|
+
const alt = alts.slice().sort((a,b) => b.length - a.length).map(s => s.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&')).join('|');
|
|
299
|
+
const exact = new RegExp('^\\\\s*(?:' + alt + ')\\\\s*[::]?\\\\s*$', 'i');
|
|
300
|
+
const re = new RegExp(alt, 'i');
|
|
301
|
+
const candidates = Array.from(scope.querySelectorAll('div, span, p, dt, label, h2, h3, td, th'));
|
|
302
|
+
const exacts = candidates.filter(n => exact.test((n.textContent || '').trim()));
|
|
303
|
+
const fallback = candidates.filter(n => re.test(n.textContent || ''));
|
|
304
|
+
const nodes = exacts.length ? exacts : fallback.slice(0, 6);
|
|
305
|
+
for (const node of nodes) {
|
|
306
|
+
let cur = node;
|
|
307
|
+
for (let depth = 0; cur && depth < 6; depth++, cur = cur.parentElement) {
|
|
308
|
+
const t = (cur.innerText || cur.textContent || '');
|
|
309
|
+
if (re.test(t) && numRe.test(t)) {
|
|
310
|
+
const stripped = t.replace(re, ' ');
|
|
311
|
+
const m = stripped.match(numRe);
|
|
312
|
+
if (m) return m[0].trim();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return null;
|
|
317
|
+
};
|
|
318
|
+
return {
|
|
319
|
+
valid: findFor(labels.valid),
|
|
320
|
+
warnings: findFor(labels.warnings),
|
|
321
|
+
errors: findFor(labels.errors),
|
|
322
|
+
};
|
|
323
|
+
})()`,
|
|
324
|
+
r.tabId,
|
|
325
|
+
);
|
|
326
|
+
const tableRes = await ni.evaluate(r.sessionId, TABLE_EXTRACTOR_SCRIPT('[role="grid"], [role="table"], table'), r.tabId);
|
|
327
|
+
const summary = (summaryRes.result as { valid: string | null; warnings: string | null; errors: string | null } | null)
|
|
328
|
+
|| { valid: null, warnings: null, errors: null };
|
|
329
|
+
return text(JSON.stringify({ report: args.report, summary, issues: tableRes.result || { headers: [], rows: [] } }, null, 2));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── gsc_links_report ──────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
export const gscLinksReportSchema = {
|
|
335
|
+
section: z.enum(['top_linked_external', 'top_linking_sites', 'top_linking_text', 'top_linked_internal'] as const),
|
|
336
|
+
limit: z.coerce.number().int().positive().optional().describe('Max rows to scroll-load (default 100).'),
|
|
337
|
+
property: z.string().optional(),
|
|
338
|
+
sessionId: z.string().optional(),
|
|
339
|
+
tabId,
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
export const gscLinksReportDescription =
|
|
343
|
+
'Read a Links report section. top_linked_external / top_linking_sites / top_linking_text / top_linked_internal. Returns full table.';
|
|
344
|
+
|
|
345
|
+
export async function handleGscLinksReport(args: { section: string; limit?: number; property?: string; sessionId?: string; tabId?: string }) {
|
|
346
|
+
const { err, r } = await loadAndExtract(args.sessionId, args.tabId, args.property, '/links');
|
|
347
|
+
if (err) return text(err);
|
|
348
|
+
|
|
349
|
+
const sectionLabels: Record<string, RegExp> = {
|
|
350
|
+
top_linked_external: /Top linked pages|Páginas mais vinculadas/i,
|
|
351
|
+
top_linking_sites: /Top linking sites|Sites com mais links/i,
|
|
352
|
+
top_linking_text: /Top linking text|Texto âncora mais comum/i,
|
|
353
|
+
top_linked_internal: /Internally linked|Páginas com links internos/i,
|
|
354
|
+
};
|
|
355
|
+
const re = sectionLabels[args.section];
|
|
356
|
+
// Each section has a "more" link; click it to navigate to the full-table view.
|
|
357
|
+
await ni.evaluate(
|
|
358
|
+
r.sessionId,
|
|
359
|
+
`(() => {
|
|
360
|
+
const cards = Array.from(document.querySelectorAll('section, [role="region"], div'));
|
|
361
|
+
const card = cards.find(c => ${re.toString()}.test(c.textContent || ''));
|
|
362
|
+
if (card) {
|
|
363
|
+
const more = card.querySelector('a, button, [role="link"], [role="button"]');
|
|
364
|
+
if (more && /more|mais/i.test(more.textContent || '')) more.click();
|
|
365
|
+
}
|
|
366
|
+
})()`,
|
|
367
|
+
r.tabId,
|
|
368
|
+
);
|
|
369
|
+
// Wait for navigation to a drilldown URL OR for a single full-page grid
|
|
370
|
+
// with rows to appear. Without this, extraction races the overview cards.
|
|
371
|
+
await pollFor(
|
|
372
|
+
r,
|
|
373
|
+
`(() => {
|
|
374
|
+
if (/\\/links\\/(?:drilldown|details|external)/.test(location.pathname + location.search)) return true;
|
|
375
|
+
const main = document.querySelector('[role="main"], main') || document.body;
|
|
376
|
+
const grids = main.querySelectorAll('[role="grid"], [role="table"], table');
|
|
377
|
+
// Drilldown view typically has exactly one grid with > 1 row.
|
|
378
|
+
if (grids.length === 1) {
|
|
379
|
+
const rows = grids[0].querySelectorAll('[role="row"]:not(:has([role="columnheader"])), tbody tr');
|
|
380
|
+
if (rows.length > 0) return true;
|
|
381
|
+
}
|
|
382
|
+
return null;
|
|
383
|
+
})()`,
|
|
384
|
+
8000,
|
|
385
|
+
400,
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
const limit = args.limit ?? 100;
|
|
389
|
+
for (let i = 0; i < 8; i++) {
|
|
390
|
+
const count = await ni.evaluate(r.sessionId, `(() => document.querySelectorAll('[role="main"] [role="grid"] [role="row"], [role="main"] [role="table"] tr, main [role="grid"] [role="row"], main [role="table"] tr').length)()`, r.tabId);
|
|
391
|
+
if (Number(count.result || 0) >= limit) break;
|
|
392
|
+
await ni.scroll(r.sessionId, { direction: 'down', distance: 1500 }, r.tabId);
|
|
393
|
+
await wait(600);
|
|
394
|
+
}
|
|
395
|
+
const tableRes = await ni.evaluate(
|
|
396
|
+
r.sessionId,
|
|
397
|
+
TABLE_EXTRACTOR_SCRIPT('[role="main"] [role="grid"], [role="main"] [role="table"], main [role="grid"], main [role="table"]'),
|
|
398
|
+
r.tabId,
|
|
399
|
+
);
|
|
400
|
+
const t = (tableRes.result || { headers: [], rows: [] }) as { headers: string[]; rows: Array<Record<string, unknown>> };
|
|
401
|
+
t.rows = t.rows.slice(0, limit);
|
|
402
|
+
return text(JSON.stringify({ section: args.section, table: t }, null, 2));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── gsc_links_export ──────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
export const gscLinksExportSchema = {
|
|
408
|
+
scope: z.enum(['external', 'internal', 'sample_external', 'sample_internal'] as const).describe('Export scope from the Links report Export menu.'),
|
|
409
|
+
property: z.string().optional(),
|
|
410
|
+
sessionId: z.string().optional(),
|
|
411
|
+
tabId,
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
export const gscLinksExportDescription =
|
|
415
|
+
'Trigger Links report Export menu. Browser handles file download.';
|
|
416
|
+
|
|
417
|
+
export async function handleGscLinksExport(args: { scope: string; property?: string; sessionId?: string; tabId?: string }) {
|
|
418
|
+
const { err, r } = await loadAndExtract(args.sessionId, args.tabId, args.property, '/links');
|
|
419
|
+
if (err) return text(err);
|
|
420
|
+
await ni.evaluate(
|
|
421
|
+
r.sessionId,
|
|
422
|
+
`(() => { const btns = Array.from(document.querySelectorAll('button, [role="button"]')); const exp = btns.find(b => /export\\s*external\\s*links|exportar\\s*links\\s*externos/i.test(b.textContent || '') || /^export$|^exportar$/i.test((b.textContent || '').trim())); if (exp) exp.click(); })()`,
|
|
423
|
+
r.tabId,
|
|
424
|
+
);
|
|
425
|
+
await wait(800);
|
|
426
|
+
const labels: Record<string, RegExp> = {
|
|
427
|
+
external: /external links|links externos/i,
|
|
428
|
+
internal: /internal links|links internos/i,
|
|
429
|
+
sample_external: /sample.*external|amostra.*externos/i,
|
|
430
|
+
sample_internal: /sample.*internal|amostra.*internos/i,
|
|
431
|
+
};
|
|
432
|
+
const re = labels[args.scope];
|
|
433
|
+
await ni.evaluate(
|
|
434
|
+
r.sessionId,
|
|
435
|
+
`(() => { const items = Array.from(document.querySelectorAll('[role="menuitem"], a, button')); const t = items.find(i => ${re.toString()}.test(i.textContent || '')); if (t) t.click(); })()`,
|
|
436
|
+
r.tabId,
|
|
437
|
+
);
|
|
438
|
+
await wait(2000);
|
|
439
|
+
return text(`Links export triggered: ${args.scope}. Download in browser default dir.`);
|
|
440
|
+
}
|