pagesight 0.15.1 → 0.17.0
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/package.json +1 -1
- package/src/tools/audit.ts +10 -1
- package/src/tools/search.ts +404 -10
- package/src/tools/setup.ts +58 -19
package/package.json
CHANGED
package/src/tools/audit.ts
CHANGED
|
@@ -326,7 +326,16 @@ export function registerAuditTool(server: McpServer): void {
|
|
|
326
326
|
if (pagespeedResult.status === "fulfilled") {
|
|
327
327
|
addPagespeedFindings(pagespeedResult.value, findings);
|
|
328
328
|
} else {
|
|
329
|
-
|
|
329
|
+
const psiErr = String(pagespeedResult.reason);
|
|
330
|
+
const statusMatch = psiErr.match(/\((\d{3})\)/);
|
|
331
|
+
const status = statusMatch ? Number(statusMatch[1]) : 0;
|
|
332
|
+
if (status === 403) {
|
|
333
|
+
errors.push("PageSpeed: SKIPPED (API key not authorized — enable PageSpeed Insights API in Google Cloud)");
|
|
334
|
+
} else if (status === 429) {
|
|
335
|
+
errors.push("PageSpeed: SKIPPED (rate limited — try again later or set GOOGLE_API_KEY)");
|
|
336
|
+
} else {
|
|
337
|
+
errors.push(`PageSpeed: ${psiErr.replace(/:\s*\{[\s\S]*$/, "")}`);
|
|
338
|
+
}
|
|
330
339
|
}
|
|
331
340
|
|
|
332
341
|
// Process robots
|
package/src/tools/search.ts
CHANGED
|
@@ -105,6 +105,59 @@ function humanizeState(state: string): string {
|
|
|
105
105
|
return map[state] ?? state;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// ── Coverage helpers ──
|
|
109
|
+
|
|
110
|
+
const COVERAGE_FILTERS = [
|
|
111
|
+
"not_indexed",
|
|
112
|
+
"server_error",
|
|
113
|
+
"redirect",
|
|
114
|
+
"soft_404",
|
|
115
|
+
"blocked",
|
|
116
|
+
"duplicate",
|
|
117
|
+
"discovered",
|
|
118
|
+
"crawled_not_indexed",
|
|
119
|
+
] as const;
|
|
120
|
+
|
|
121
|
+
type CoverageFilter = (typeof COVERAGE_FILTERS)[number];
|
|
122
|
+
|
|
123
|
+
function matchesCoverageFilter(result: InspectionSummary, filter: CoverageFilter): boolean {
|
|
124
|
+
if (result.error) return false;
|
|
125
|
+
const cs = result.coverageState.toLowerCase();
|
|
126
|
+
const pf = result.pageFetchState.toLowerCase();
|
|
127
|
+
switch (filter) {
|
|
128
|
+
case "not_indexed":
|
|
129
|
+
return result.verdict !== "PASS";
|
|
130
|
+
case "server_error":
|
|
131
|
+
return cs.includes("server error") || pf === "server_error";
|
|
132
|
+
case "redirect":
|
|
133
|
+
return cs.includes("redirect") || pf.includes("redirect");
|
|
134
|
+
case "soft_404":
|
|
135
|
+
return cs.includes("soft 404") || pf === "soft_404";
|
|
136
|
+
case "blocked":
|
|
137
|
+
return cs.includes("blocked");
|
|
138
|
+
case "duplicate":
|
|
139
|
+
return cs.includes("duplicate");
|
|
140
|
+
case "discovered":
|
|
141
|
+
return cs.includes("discovered");
|
|
142
|
+
case "crawled_not_indexed":
|
|
143
|
+
return cs.includes("crawled - currently not indexed");
|
|
144
|
+
default:
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function filterNameForState(state: string): string | null {
|
|
150
|
+
const s = state.toLowerCase();
|
|
151
|
+
if (s.includes("server error")) return "server_error";
|
|
152
|
+
if (s.includes("blocked")) return "blocked";
|
|
153
|
+
if (s.includes("redirect")) return "redirect";
|
|
154
|
+
if (s.includes("soft 404")) return "soft_404";
|
|
155
|
+
if (s.includes("duplicate")) return "duplicate";
|
|
156
|
+
if (s.includes("discovered - currently not indexed")) return "discovered";
|
|
157
|
+
if (s.includes("crawled - currently not indexed")) return "crawled_not_indexed";
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
108
161
|
function formatSampleResults(
|
|
109
162
|
siteUrl: string,
|
|
110
163
|
sitemapUrl: string,
|
|
@@ -187,6 +240,90 @@ function formatSampleResults(
|
|
|
187
240
|
return lines.join("\n");
|
|
188
241
|
}
|
|
189
242
|
|
|
243
|
+
// ── Coverage formatter ──
|
|
244
|
+
|
|
245
|
+
function formatCoverage(siteUrl: string, sitemapUrl: string, totalUrls: number, results: InspectionSummary[]): string {
|
|
246
|
+
const inspected = results.filter((r) => !r.error);
|
|
247
|
+
const errors = results.filter((r) => r.error);
|
|
248
|
+
const indexed = inspected.filter((r) => r.verdict === "PASS");
|
|
249
|
+
const notIndexed = inspected.filter((r) => r.verdict !== "PASS");
|
|
250
|
+
|
|
251
|
+
const lines: string[] = [
|
|
252
|
+
`=== Index Coverage: ${siteUrl} ===`,
|
|
253
|
+
`Sitemap: ${sitemapUrl} (${totalUrls.toLocaleString()} URLs)`,
|
|
254
|
+
`Inspected: ${results.length}`,
|
|
255
|
+
"",
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
const total = inspected.length;
|
|
259
|
+
if (total === 0) {
|
|
260
|
+
lines.push("No URLs could be inspected.");
|
|
261
|
+
if (errors.length > 0) {
|
|
262
|
+
lines.push("");
|
|
263
|
+
for (const e of errors) lines.push(` ${e.url}: ${e.error}`);
|
|
264
|
+
}
|
|
265
|
+
return lines.join("\n");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Group not-indexed by coverageState
|
|
269
|
+
const issueGroups: Record<string, InspectionSummary[]> = {};
|
|
270
|
+
for (const r of notIndexed) {
|
|
271
|
+
const key = r.coverageState;
|
|
272
|
+
if (!issueGroups[key]) issueGroups[key] = [];
|
|
273
|
+
issueGroups[key].push(r);
|
|
274
|
+
}
|
|
275
|
+
const sortedIssues = Object.entries(issueGroups).sort((a, b) => b[1].length - a[1].length);
|
|
276
|
+
|
|
277
|
+
// Breakdown
|
|
278
|
+
lines.push("--- Breakdown ---", "");
|
|
279
|
+
lines.push(`Indexed: ${indexed.length}/${total} (${Math.round((indexed.length / total) * 100)}%)`);
|
|
280
|
+
lines.push(`Not indexed: ${notIndexed.length}/${total} (${Math.round((notIndexed.length / total) * 100)}%)`);
|
|
281
|
+
for (const [state, urls] of sortedIssues) {
|
|
282
|
+
lines.push(` ${state}: ${urls.length}`);
|
|
283
|
+
}
|
|
284
|
+
if (errors.length > 0) {
|
|
285
|
+
lines.push(`Inspection errors: ${errors.length}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Extrapolated estimates
|
|
289
|
+
if (totalUrls > total) {
|
|
290
|
+
lines.push("", "--- Estimated Totals ---", "");
|
|
291
|
+
const indexedEst = Math.round((indexed.length / total) * totalUrls);
|
|
292
|
+
const notIndexedEst = Math.round((notIndexed.length / total) * totalUrls);
|
|
293
|
+
lines.push(`~${indexedEst.toLocaleString()} indexed (of ${totalUrls.toLocaleString()})`);
|
|
294
|
+
lines.push(`~${notIndexedEst.toLocaleString()} not indexed`);
|
|
295
|
+
for (const [state, urls] of sortedIssues) {
|
|
296
|
+
const est = Math.round((urls.length / total) * totalUrls);
|
|
297
|
+
lines.push(` ~${est.toLocaleString()} ${state}`);
|
|
298
|
+
}
|
|
299
|
+
lines.push("", `Estimates based on ${total}-URL sample — actual numbers may vary.`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Example URLs per issue
|
|
303
|
+
if (sortedIssues.length > 0) {
|
|
304
|
+
lines.push("", "--- URLs by Issue ---", "");
|
|
305
|
+
for (const [state, urls] of sortedIssues) {
|
|
306
|
+
lines.push(`${state}:`);
|
|
307
|
+
for (const u of urls.slice(0, 3)) {
|
|
308
|
+
lines.push(` ${u.url}`);
|
|
309
|
+
if (u.lastCrawlTime) lines.push(` Last crawled: ${u.lastCrawlTime}`);
|
|
310
|
+
if (u.pageFetchState !== "SUCCESSFUL") lines.push(` Fetch: ${humanizeState(u.pageFetchState)}`);
|
|
311
|
+
}
|
|
312
|
+
if (urls.length > 3) lines.push(` ... and ${urls.length - 3} more in sample`);
|
|
313
|
+
lines.push("");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Suggest drill-down
|
|
318
|
+
const filterSuggestions = sortedIssues.map(([state]) => filterNameForState(state)).filter(Boolean);
|
|
319
|
+
if (filterSuggestions.length > 0) {
|
|
320
|
+
lines.push(`Drill deeper: search(action='sample', filter='...') to investigate specific issues.`);
|
|
321
|
+
lines.push(` Available filters: ${filterSuggestions.join(", ")}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return lines.join("\n");
|
|
325
|
+
}
|
|
326
|
+
|
|
190
327
|
// ── Sitemaps formatters ──
|
|
191
328
|
|
|
192
329
|
function formatSites(sites: GscSite[]): string {
|
|
@@ -466,25 +603,141 @@ function daysAgo(n: number): string {
|
|
|
466
603
|
return parts; // en-CA formats as YYYY-MM-DD
|
|
467
604
|
}
|
|
468
605
|
|
|
606
|
+
// ── Keyword gap analysis ──
|
|
607
|
+
|
|
608
|
+
function extractPageText(html: string): string {
|
|
609
|
+
// Strip scripts, styles, and HTML tags to get visible text content
|
|
610
|
+
let text = html;
|
|
611
|
+
text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, " ");
|
|
612
|
+
text = text.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, " ");
|
|
613
|
+
text = text.replace(/<[^>]+>/g, " ");
|
|
614
|
+
// Decode common HTML entities
|
|
615
|
+
text = text
|
|
616
|
+
.replace(/&/g, "&")
|
|
617
|
+
.replace(/</g, "<")
|
|
618
|
+
.replace(/>/g, ">")
|
|
619
|
+
.replace(/"/g, '"')
|
|
620
|
+
.replace(/'/g, "'")
|
|
621
|
+
.replace(/ /g, " ");
|
|
622
|
+
// Normalize whitespace
|
|
623
|
+
text = text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
624
|
+
return text;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function formatGapAnalysis(
|
|
628
|
+
pageUrl: string,
|
|
629
|
+
siteUrl: string,
|
|
630
|
+
queries: Array<{ query: string; clicks: number; impressions: number; ctr: number; position: number }>,
|
|
631
|
+
pageText: string,
|
|
632
|
+
): string {
|
|
633
|
+
const lines: string[] = [`=== Keyword Gap Analysis: ${pageUrl} ===`, `Property: ${siteUrl}`, ""];
|
|
634
|
+
|
|
635
|
+
if (queries.length === 0) {
|
|
636
|
+
lines.push("No search queries found for this page.");
|
|
637
|
+
return lines.join("\n");
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const onPage: typeof queries = [];
|
|
641
|
+
const gaps: typeof queries = [];
|
|
642
|
+
|
|
643
|
+
for (const q of queries) {
|
|
644
|
+
const words = q.query.toLowerCase().split(/\s+/);
|
|
645
|
+
const allPresent = words.every((w) => pageText.includes(w));
|
|
646
|
+
if (allPresent) {
|
|
647
|
+
onPage.push(q);
|
|
648
|
+
} else {
|
|
649
|
+
gaps.push(q);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
lines.push(`Queries analyzed: ${queries.length}`);
|
|
654
|
+
lines.push(`On page: ${onPage.length}`);
|
|
655
|
+
lines.push(`Gaps (not on page): ${gaps.length}`);
|
|
656
|
+
lines.push("");
|
|
657
|
+
|
|
658
|
+
if (gaps.length > 0) {
|
|
659
|
+
lines.push("--- Keyword Gaps (queries with impressions but missing from page) ---", "");
|
|
660
|
+
gaps.sort((a, b) => b.impressions - a.impressions);
|
|
661
|
+
for (const q of gaps.slice(0, 20)) {
|
|
662
|
+
const ctr = (q.ctr * 100).toFixed(1);
|
|
663
|
+
lines.push(
|
|
664
|
+
` "${q.query}" — ${q.impressions} imp, ${q.clicks} clicks, CTR ${ctr}%, pos ${q.position.toFixed(1)}`,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
if (gaps.length > 20) lines.push(` ... and ${gaps.length - 20} more`);
|
|
668
|
+
lines.push("");
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (onPage.length > 0) {
|
|
672
|
+
lines.push("--- Already Targeted (query found on page) ---", "");
|
|
673
|
+
onPage.sort((a, b) => b.impressions - a.impressions);
|
|
674
|
+
for (const q of onPage.slice(0, 10)) {
|
|
675
|
+
const ctr = (q.ctr * 100).toFixed(1);
|
|
676
|
+
lines.push(
|
|
677
|
+
` "${q.query}" — ${q.impressions} imp, ${q.clicks} clicks, CTR ${ctr}%, pos ${q.position.toFixed(1)}`,
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
if (onPage.length > 10) lines.push(` ... and ${onPage.length - 10} more`);
|
|
681
|
+
lines.push("");
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const totalGapImpressions = gaps.reduce((sum, q) => sum + q.impressions, 0);
|
|
685
|
+
const totalImpressions = queries.reduce((sum, q) => sum + q.impressions, 0);
|
|
686
|
+
if (totalImpressions > 0) {
|
|
687
|
+
const gapPct = Math.round((totalGapImpressions / totalImpressions) * 100);
|
|
688
|
+
lines.push(`${gapPct}% of impressions come from queries not on the page — potential content opportunity.`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return lines.join("\n");
|
|
692
|
+
}
|
|
693
|
+
|
|
469
694
|
// ── Tool registration ──
|
|
470
695
|
|
|
471
696
|
export function registerSearchTool(server: McpServer): void {
|
|
472
697
|
server.tool(
|
|
473
698
|
"search",
|
|
474
|
-
"Query Google Search Console. Inspect URL indexing
|
|
699
|
+
"Query Google Search Console. Inspect URL indexing, get coverage breakdown by issue type, sample-inspect with filters, list properties and sitemaps, analyze search traffic, or find keyword gaps.",
|
|
475
700
|
{
|
|
476
701
|
action: z
|
|
477
|
-
.enum([
|
|
702
|
+
.enum([
|
|
703
|
+
"inspect",
|
|
704
|
+
"sample",
|
|
705
|
+
"coverage",
|
|
706
|
+
"sitemaps",
|
|
707
|
+
"analytics",
|
|
708
|
+
"gaps",
|
|
709
|
+
"list_sites",
|
|
710
|
+
"get_site",
|
|
711
|
+
"get_sitemap",
|
|
712
|
+
])
|
|
478
713
|
.optional()
|
|
479
714
|
.describe("Action to perform. Auto-detected from params when unambiguous."),
|
|
480
715
|
site_url: z.string().optional().describe("GSC property (e.g., 'sc-domain:example.com')."),
|
|
481
716
|
url: z.string().url().optional().describe("URL to inspect in Google's index."),
|
|
482
|
-
sitemap_url: z.string().url().optional().describe("Sitemap URL for sample inspection or get_sitemap."),
|
|
483
|
-
sample_size: z
|
|
717
|
+
sitemap_url: z.string().url().optional().describe("Sitemap URL for sample/coverage inspection or get_sitemap."),
|
|
718
|
+
sample_size: z
|
|
719
|
+
.number()
|
|
720
|
+
.min(1)
|
|
721
|
+
.max(50)
|
|
722
|
+
.optional()
|
|
723
|
+
.describe("URLs to inspect (1-50). Default: 5 for sample, 20 for coverage."),
|
|
484
724
|
sample_strategy: z
|
|
485
725
|
.enum(["random", "first", "spread"])
|
|
486
726
|
.optional()
|
|
487
|
-
.describe("Sampling strategy. Default: 'random'."),
|
|
727
|
+
.describe("Sampling strategy. Default: 'spread' for coverage, 'random' for sample."),
|
|
728
|
+
filter: z
|
|
729
|
+
.enum([
|
|
730
|
+
"not_indexed",
|
|
731
|
+
"server_error",
|
|
732
|
+
"redirect",
|
|
733
|
+
"soft_404",
|
|
734
|
+
"blocked",
|
|
735
|
+
"duplicate",
|
|
736
|
+
"discovered",
|
|
737
|
+
"crawled_not_indexed",
|
|
738
|
+
])
|
|
739
|
+
.optional()
|
|
740
|
+
.describe("Filter sample results by coverage issue type. Inspects more URLs internally to find matches."),
|
|
488
741
|
start_date: z.string().optional().describe("Start date (YYYY-MM-DD) for analytics. Default: 28 days ago."),
|
|
489
742
|
end_date: z.string().optional().describe("End date (YYYY-MM-DD) for analytics. Default: 3 days ago."),
|
|
490
743
|
dimensions: z
|
|
@@ -521,6 +774,7 @@ export function registerSearchTool(server: McpServer): void {
|
|
|
521
774
|
sitemap_url,
|
|
522
775
|
sample_size,
|
|
523
776
|
sample_strategy,
|
|
777
|
+
filter,
|
|
524
778
|
start_date,
|
|
525
779
|
end_date,
|
|
526
780
|
dimensions,
|
|
@@ -540,7 +794,7 @@ export function registerSearchTool(server: McpServer): void {
|
|
|
540
794
|
params.action ??
|
|
541
795
|
(url && site_url && !hasAnalyticsParams
|
|
542
796
|
? "inspect"
|
|
543
|
-
: sample_size
|
|
797
|
+
: sample_size || filter
|
|
544
798
|
? "sample"
|
|
545
799
|
: hasAnalyticsParams
|
|
546
800
|
? "analytics"
|
|
@@ -595,7 +849,6 @@ export function registerSearchTool(server: McpServer): void {
|
|
|
595
849
|
if (sitemaps.length === 0) {
|
|
596
850
|
return textResult(`No sitemaps found for ${site_url} in GSC. Provide a sitemap_url directly.`);
|
|
597
851
|
}
|
|
598
|
-
// Pick the first non-index sitemap, or the first one
|
|
599
852
|
const nonIndex = sitemaps.find((s) => !s.isSitemapsIndex);
|
|
600
853
|
resolvedSitemapUrl = (nonIndex ?? sitemaps[0]).path;
|
|
601
854
|
}
|
|
@@ -603,7 +856,6 @@ export function registerSearchTool(server: McpServer): void {
|
|
|
603
856
|
// Fetch and parse sitemap
|
|
604
857
|
let parsed = await fetchSitemap(resolvedSitemapUrl);
|
|
605
858
|
|
|
606
|
-
// If it's a sitemap index, fetch the first child
|
|
607
859
|
if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
|
|
608
860
|
const childUrl = parsed.childSitemaps[0];
|
|
609
861
|
parsed = await fetchSitemap(childUrl);
|
|
@@ -614,10 +866,72 @@ export function registerSearchTool(server: McpServer): void {
|
|
|
614
866
|
return textResult(`Sitemap ${resolvedSitemapUrl} contains no URLs.`);
|
|
615
867
|
}
|
|
616
868
|
|
|
617
|
-
|
|
869
|
+
if (filter) {
|
|
870
|
+
// Filtered sample: inspect more URLs, return only matches
|
|
871
|
+
const scanLimit = Math.min(count * 5, 50, parsed.urls.length);
|
|
872
|
+
const pool = sampleUrls(parsed.urls, scanLimit, strategy);
|
|
873
|
+
const matched: InspectionSummary[] = [];
|
|
874
|
+
let scanned = 0;
|
|
875
|
+
|
|
876
|
+
for (const u of pool) {
|
|
877
|
+
const result = await inspectSingle(u, site_url);
|
|
878
|
+
scanned++;
|
|
879
|
+
if (matchesCoverageFilter(result, filter)) {
|
|
880
|
+
matched.push(result);
|
|
881
|
+
if (matched.length >= count) break;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (matched.length === 0) {
|
|
886
|
+
return textResult(
|
|
887
|
+
[
|
|
888
|
+
`=== Filtered Sample: ${site_url} ===`,
|
|
889
|
+
`Sitemap: ${resolvedSitemapUrl} (${parsed.urls.length.toLocaleString()} URLs)`,
|
|
890
|
+
`Filter: ${filter}`,
|
|
891
|
+
`Scanned: ${scanned} | Matched: 0`,
|
|
892
|
+
"",
|
|
893
|
+
`No URLs matching "${filter}" found in ${scanned} inspected URLs.`,
|
|
894
|
+
"Try a larger sample_size or a different filter.",
|
|
895
|
+
].join("\n"),
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const lines: string[] = [
|
|
900
|
+
`=== Filtered Sample: ${site_url} ===`,
|
|
901
|
+
`Sitemap: ${resolvedSitemapUrl} (${parsed.urls.length.toLocaleString()} URLs)`,
|
|
902
|
+
`Filter: ${filter}`,
|
|
903
|
+
`Scanned: ${scanned} | Matched: ${matched.length}`,
|
|
904
|
+
"",
|
|
905
|
+
"--- Matching URLs ---",
|
|
906
|
+
"",
|
|
907
|
+
];
|
|
908
|
+
|
|
909
|
+
for (let i = 0; i < matched.length; i++) {
|
|
910
|
+
const r = matched[i];
|
|
911
|
+
lines.push(`${i + 1}. ${r.url}`);
|
|
912
|
+
lines.push(` Coverage: ${r.coverageState}`);
|
|
913
|
+
lines.push(` Page fetch: ${humanizeState(r.pageFetchState)}`);
|
|
914
|
+
if (r.robotsTxtState !== "ALLOWED") lines.push(` Robots.txt: ${humanizeState(r.robotsTxtState)}`);
|
|
915
|
+
if (r.indexingState !== "INDEXING_ALLOWED") lines.push(` Indexing: ${humanizeState(r.indexingState)}`);
|
|
916
|
+
if (r.lastCrawlTime) lines.push(` Last crawled: ${r.lastCrawlTime}`);
|
|
917
|
+
if (r.googleCanonical && r.googleCanonical !== r.url) {
|
|
918
|
+
lines.push(` Google canonical: ${r.googleCanonical}`);
|
|
919
|
+
}
|
|
920
|
+
lines.push("");
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (matched.length < count && scanned >= scanLimit) {
|
|
924
|
+
lines.push(
|
|
925
|
+
`Found ${matched.length}/${count} requested — scanned ${scanned} URLs (limit ${scanLimit}). Increase sample_size for a wider scan.`,
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
return textResult(lines.join("\n"));
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Unfiltered sample (original behavior)
|
|
618
933
|
const sampled = sampleUrls(parsed.urls, count, strategy);
|
|
619
934
|
|
|
620
|
-
// Inspect each URL sequentially (API rate limits)
|
|
621
935
|
const results: InspectionSummary[] = [];
|
|
622
936
|
for (const u of sampled) {
|
|
623
937
|
results.push(await inspectSingle(u, site_url));
|
|
@@ -626,6 +940,46 @@ export function registerSearchTool(server: McpServer): void {
|
|
|
626
940
|
return textResult(formatSampleResults(site_url, resolvedSitemapUrl, parsed.urls.length, results));
|
|
627
941
|
}
|
|
628
942
|
|
|
943
|
+
// ── coverage ──
|
|
944
|
+
if (resolvedAction === "coverage") {
|
|
945
|
+
if (!site_url) return textResult("Error: site_url is required for coverage.");
|
|
946
|
+
|
|
947
|
+
const count = sample_size ?? 20;
|
|
948
|
+
const strategy = sample_strategy ?? "spread";
|
|
949
|
+
|
|
950
|
+
// Discover sitemap URL if not provided
|
|
951
|
+
let resolvedSitemapUrl = sitemap_url;
|
|
952
|
+
if (!resolvedSitemapUrl) {
|
|
953
|
+
const sitemaps = await listSitemaps(site_url);
|
|
954
|
+
if (sitemaps.length === 0) {
|
|
955
|
+
return textResult(`No sitemaps found for ${site_url} in GSC. Provide a sitemap_url directly.`);
|
|
956
|
+
}
|
|
957
|
+
const nonIndex = sitemaps.find((s) => !s.isSitemapsIndex);
|
|
958
|
+
resolvedSitemapUrl = (nonIndex ?? sitemaps[0]).path;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
let parsed = await fetchSitemap(resolvedSitemapUrl);
|
|
962
|
+
|
|
963
|
+
if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
|
|
964
|
+
const childUrl = parsed.childSitemaps[0];
|
|
965
|
+
parsed = await fetchSitemap(childUrl);
|
|
966
|
+
resolvedSitemapUrl = `${resolvedSitemapUrl} → ${childUrl}`;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
if (parsed.urls.length === 0) {
|
|
970
|
+
return textResult(`Sitemap ${resolvedSitemapUrl} contains no URLs.`);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const sampled = sampleUrls(parsed.urls, count, strategy);
|
|
974
|
+
|
|
975
|
+
const results: InspectionSummary[] = [];
|
|
976
|
+
for (const u of sampled) {
|
|
977
|
+
results.push(await inspectSingle(u, site_url));
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return textResult(formatCoverage(site_url, resolvedSitemapUrl, parsed.urls.length, results));
|
|
981
|
+
}
|
|
982
|
+
|
|
629
983
|
// ── list_sites ──
|
|
630
984
|
if (resolvedAction === "list_sites") {
|
|
631
985
|
const sites = await listSites();
|
|
@@ -710,6 +1064,46 @@ export function registerSearchTool(server: McpServer): void {
|
|
|
710
1064
|
return textResult(formatPerformance(site_url, result, dims, startDate, endDate));
|
|
711
1065
|
}
|
|
712
1066
|
|
|
1067
|
+
// ── gaps ──
|
|
1068
|
+
if (resolvedAction === "gaps") {
|
|
1069
|
+
if (!url) return textResult("Error: url is required for gap analysis.");
|
|
1070
|
+
if (!site_url) return textResult("Error: site_url is required for gap analysis.");
|
|
1071
|
+
|
|
1072
|
+
// Fetch page content and GSC queries in parallel
|
|
1073
|
+
const [pageRes, analyticsRes] = await Promise.all([
|
|
1074
|
+
fetch(url, {
|
|
1075
|
+
headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", Accept: "text/html" },
|
|
1076
|
+
redirect: "follow",
|
|
1077
|
+
}),
|
|
1078
|
+
querySearchAnalytics(site_url, {
|
|
1079
|
+
startDate: start_date ?? daysAgo(28),
|
|
1080
|
+
endDate: end_date ?? daysAgo(3),
|
|
1081
|
+
dimensions: ["query"],
|
|
1082
|
+
rowLimit: row_limit ?? 500,
|
|
1083
|
+
dimensionFilterGroups: [
|
|
1084
|
+
{
|
|
1085
|
+
groupType: "and" as const,
|
|
1086
|
+
filters: [{ dimension: "page", operator: "equals", expression: url } as SearchAnalyticsFilter],
|
|
1087
|
+
},
|
|
1088
|
+
],
|
|
1089
|
+
}),
|
|
1090
|
+
]);
|
|
1091
|
+
|
|
1092
|
+
if (!pageRes.ok) return textResult(`Error fetching ${url}: HTTP ${pageRes.status}`);
|
|
1093
|
+
const html = await pageRes.text();
|
|
1094
|
+
const pageText = extractPageText(html);
|
|
1095
|
+
|
|
1096
|
+
const queries = (analyticsRes.rows ?? []).map((row) => ({
|
|
1097
|
+
query: row.keys[0],
|
|
1098
|
+
clicks: row.clicks,
|
|
1099
|
+
impressions: row.impressions,
|
|
1100
|
+
ctr: row.ctr,
|
|
1101
|
+
position: row.position,
|
|
1102
|
+
}));
|
|
1103
|
+
|
|
1104
|
+
return textResult(formatGapAnalysis(url, site_url, queries, pageText));
|
|
1105
|
+
}
|
|
1106
|
+
|
|
713
1107
|
return textResult(`Error: Unknown action "${resolvedAction}".`);
|
|
714
1108
|
} catch (err) {
|
|
715
1109
|
const msg = err instanceof Error ? err.message : String(err);
|
package/src/tools/setup.ts
CHANGED
|
@@ -17,7 +17,33 @@ export function registerSetupTool(server: McpServer): void {
|
|
|
17
17
|
async ({ action, client_id, client_secret, code }) => {
|
|
18
18
|
if (action === "status") {
|
|
19
19
|
const method = getAuthMethod();
|
|
20
|
-
if (method
|
|
20
|
+
if (method !== "none") {
|
|
21
|
+
return {
|
|
22
|
+
content: [
|
|
23
|
+
{
|
|
24
|
+
type: "text",
|
|
25
|
+
text: `=== Pagesight Auth Status ===\n\nStatus: CONFIGURED\nMethod: ${method}`,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Check for partial OAuth config
|
|
32
|
+
const hasClientId = !!process.env.GSC_CLIENT_ID;
|
|
33
|
+
const hasClientSecret = !!process.env.GSC_CLIENT_SECRET;
|
|
34
|
+
const hasPartial = hasClientId || hasClientSecret;
|
|
35
|
+
|
|
36
|
+
if (hasPartial) {
|
|
37
|
+
const present = [hasClientId && "GSC_CLIENT_ID", hasClientSecret && "GSC_CLIENT_SECRET"]
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.join(", ");
|
|
40
|
+
const missing = [
|
|
41
|
+
!hasClientId && "GSC_CLIENT_ID",
|
|
42
|
+
!hasClientSecret && "GSC_CLIENT_SECRET",
|
|
43
|
+
"GSC_REFRESH_TOKEN",
|
|
44
|
+
]
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.join(", ");
|
|
21
47
|
return {
|
|
22
48
|
content: [
|
|
23
49
|
{
|
|
@@ -25,34 +51,47 @@ export function registerSetupTool(server: McpServer): void {
|
|
|
25
51
|
text: [
|
|
26
52
|
"=== Pagesight Auth Status ===",
|
|
27
53
|
"",
|
|
28
|
-
"Status:
|
|
54
|
+
"Status: PARTIAL",
|
|
55
|
+
` Present: ${present}`,
|
|
56
|
+
` Missing: ${missing}`,
|
|
29
57
|
"",
|
|
30
|
-
"
|
|
31
|
-
"",
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
" 3. Create OAuth 2.0 credentials (Desktop app)",
|
|
36
|
-
" 4. Call: setup(action='get_auth_url', client_id='YOUR_ID')",
|
|
37
|
-
" 5. Visit the URL, authorize, copy the code",
|
|
38
|
-
" 6. Call: setup(action='exchange_code', client_id='YOUR_ID', client_secret='YOUR_SECRET', code='THE_CODE')",
|
|
39
|
-
" 7. Set env vars: GSC_CLIENT_ID, GSC_CLIENT_SECRET, GSC_REFRESH_TOKEN",
|
|
40
|
-
"",
|
|
41
|
-
"Option 2: Service Account",
|
|
42
|
-
" 1. Create a service account in Google Cloud",
|
|
43
|
-
" 2. Download the JSON key file",
|
|
44
|
-
" 3. Add the service account email as a user in Search Console",
|
|
45
|
-
" 4. Set env var: GSC_SERVICE_ACCOUNT_KEY=/path/to/key.json",
|
|
58
|
+
"Complete OAuth setup:",
|
|
59
|
+
" 1. Call: setup(action='get_auth_url', client_id='YOUR_ID')",
|
|
60
|
+
" 2. Visit the URL, authorize, copy the code",
|
|
61
|
+
" 3. Call: setup(action='exchange_code', client_id='YOUR_ID', client_secret='YOUR_SECRET', code='THE_CODE')",
|
|
62
|
+
" 4. Set GSC_REFRESH_TOKEN from the response",
|
|
46
63
|
].join("\n"),
|
|
47
64
|
},
|
|
48
65
|
],
|
|
49
66
|
};
|
|
50
67
|
}
|
|
68
|
+
|
|
51
69
|
return {
|
|
52
70
|
content: [
|
|
53
71
|
{
|
|
54
72
|
type: "text",
|
|
55
|
-
text:
|
|
73
|
+
text: [
|
|
74
|
+
"=== Pagesight Auth Status ===",
|
|
75
|
+
"",
|
|
76
|
+
"Status: NOT CONFIGURED",
|
|
77
|
+
"",
|
|
78
|
+
"To use Pagesight, configure one of:",
|
|
79
|
+
"",
|
|
80
|
+
"Option 1: OAuth 2.0 (recommended for personal use)",
|
|
81
|
+
" 1. Create a Google Cloud project",
|
|
82
|
+
" 2. Enable 'Google Search Console API'",
|
|
83
|
+
" 3. Create OAuth 2.0 credentials (Desktop app)",
|
|
84
|
+
" 4. Call: setup(action='get_auth_url', client_id='YOUR_ID')",
|
|
85
|
+
" 5. Visit the URL, authorize, copy the code",
|
|
86
|
+
" 6. Call: setup(action='exchange_code', client_id='YOUR_ID', client_secret='YOUR_SECRET', code='THE_CODE')",
|
|
87
|
+
" 7. Set env vars: GSC_CLIENT_ID, GSC_CLIENT_SECRET, GSC_REFRESH_TOKEN",
|
|
88
|
+
"",
|
|
89
|
+
"Option 2: Service Account",
|
|
90
|
+
" 1. Create a service account in Google Cloud",
|
|
91
|
+
" 2. Download the JSON key file",
|
|
92
|
+
" 3. Add the service account email as a user in Search Console",
|
|
93
|
+
" 4. Set env var: GSC_SERVICE_ACCOUNT_KEY=/path/to/key.json",
|
|
94
|
+
].join("\n"),
|
|
56
95
|
},
|
|
57
96
|
],
|
|
58
97
|
};
|