pagesight 0.15.0 → 0.16.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/search.ts +130 -2
- package/src/tools/speed.ts +2 -1
package/package.json
CHANGED
package/src/tools/search.ts
CHANGED
|
@@ -466,15 +466,103 @@ function daysAgo(n: number): string {
|
|
|
466
466
|
return parts; // en-CA formats as YYYY-MM-DD
|
|
467
467
|
}
|
|
468
468
|
|
|
469
|
+
// ── Keyword gap analysis ──
|
|
470
|
+
|
|
471
|
+
function extractPageText(html: string): string {
|
|
472
|
+
// Strip scripts, styles, and HTML tags to get visible text content
|
|
473
|
+
let text = html;
|
|
474
|
+
text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, " ");
|
|
475
|
+
text = text.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, " ");
|
|
476
|
+
text = text.replace(/<[^>]+>/g, " ");
|
|
477
|
+
// Decode common HTML entities
|
|
478
|
+
text = text
|
|
479
|
+
.replace(/&/g, "&")
|
|
480
|
+
.replace(/</g, "<")
|
|
481
|
+
.replace(/>/g, ">")
|
|
482
|
+
.replace(/"/g, '"')
|
|
483
|
+
.replace(/'/g, "'")
|
|
484
|
+
.replace(/ /g, " ");
|
|
485
|
+
// Normalize whitespace
|
|
486
|
+
text = text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
487
|
+
return text;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function formatGapAnalysis(
|
|
491
|
+
pageUrl: string,
|
|
492
|
+
siteUrl: string,
|
|
493
|
+
queries: Array<{ query: string; clicks: number; impressions: number; ctr: number; position: number }>,
|
|
494
|
+
pageText: string,
|
|
495
|
+
): string {
|
|
496
|
+
const lines: string[] = [`=== Keyword Gap Analysis: ${pageUrl} ===`, `Property: ${siteUrl}`, ""];
|
|
497
|
+
|
|
498
|
+
if (queries.length === 0) {
|
|
499
|
+
lines.push("No search queries found for this page.");
|
|
500
|
+
return lines.join("\n");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const onPage: typeof queries = [];
|
|
504
|
+
const gaps: typeof queries = [];
|
|
505
|
+
|
|
506
|
+
for (const q of queries) {
|
|
507
|
+
const words = q.query.toLowerCase().split(/\s+/);
|
|
508
|
+
const allPresent = words.every((w) => pageText.includes(w));
|
|
509
|
+
if (allPresent) {
|
|
510
|
+
onPage.push(q);
|
|
511
|
+
} else {
|
|
512
|
+
gaps.push(q);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
lines.push(`Queries analyzed: ${queries.length}`);
|
|
517
|
+
lines.push(`On page: ${onPage.length}`);
|
|
518
|
+
lines.push(`Gaps (not on page): ${gaps.length}`);
|
|
519
|
+
lines.push("");
|
|
520
|
+
|
|
521
|
+
if (gaps.length > 0) {
|
|
522
|
+
lines.push("--- Keyword Gaps (queries with impressions but missing from page) ---", "");
|
|
523
|
+
gaps.sort((a, b) => b.impressions - a.impressions);
|
|
524
|
+
for (const q of gaps.slice(0, 20)) {
|
|
525
|
+
const ctr = (q.ctr * 100).toFixed(1);
|
|
526
|
+
lines.push(
|
|
527
|
+
` "${q.query}" — ${q.impressions} imp, ${q.clicks} clicks, CTR ${ctr}%, pos ${q.position.toFixed(1)}`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
if (gaps.length > 20) lines.push(` ... and ${gaps.length - 20} more`);
|
|
531
|
+
lines.push("");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (onPage.length > 0) {
|
|
535
|
+
lines.push("--- Already Targeted (query found on page) ---", "");
|
|
536
|
+
onPage.sort((a, b) => b.impressions - a.impressions);
|
|
537
|
+
for (const q of onPage.slice(0, 10)) {
|
|
538
|
+
const ctr = (q.ctr * 100).toFixed(1);
|
|
539
|
+
lines.push(
|
|
540
|
+
` "${q.query}" — ${q.impressions} imp, ${q.clicks} clicks, CTR ${ctr}%, pos ${q.position.toFixed(1)}`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
if (onPage.length > 10) lines.push(` ... and ${onPage.length - 10} more`);
|
|
544
|
+
lines.push("");
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const totalGapImpressions = gaps.reduce((sum, q) => sum + q.impressions, 0);
|
|
548
|
+
const totalImpressions = queries.reduce((sum, q) => sum + q.impressions, 0);
|
|
549
|
+
if (totalImpressions > 0) {
|
|
550
|
+
const gapPct = Math.round((totalGapImpressions / totalImpressions) * 100);
|
|
551
|
+
lines.push(`${gapPct}% of impressions come from queries not on the page — potential content opportunity.`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return lines.join("\n");
|
|
555
|
+
}
|
|
556
|
+
|
|
469
557
|
// ── Tool registration ──
|
|
470
558
|
|
|
471
559
|
export function registerSearchTool(server: McpServer): void {
|
|
472
560
|
server.tool(
|
|
473
561
|
"search",
|
|
474
|
-
"Query Google Search Console. Inspect URL indexing status, sample-inspect sitemap URLs, list properties and sitemaps,
|
|
562
|
+
"Query Google Search Console. Inspect URL indexing status, sample-inspect sitemap URLs, list properties and sitemaps, analyze search traffic, or find keyword gaps (queries with impressions but missing from page content).",
|
|
475
563
|
{
|
|
476
564
|
action: z
|
|
477
|
-
.enum(["inspect", "sample", "sitemaps", "analytics", "list_sites", "get_site", "get_sitemap"])
|
|
565
|
+
.enum(["inspect", "sample", "sitemaps", "analytics", "gaps", "list_sites", "get_site", "get_sitemap"])
|
|
478
566
|
.optional()
|
|
479
567
|
.describe("Action to perform. Auto-detected from params when unambiguous."),
|
|
480
568
|
site_url: z.string().optional().describe("GSC property (e.g., 'sc-domain:example.com')."),
|
|
@@ -710,6 +798,46 @@ export function registerSearchTool(server: McpServer): void {
|
|
|
710
798
|
return textResult(formatPerformance(site_url, result, dims, startDate, endDate));
|
|
711
799
|
}
|
|
712
800
|
|
|
801
|
+
// ── gaps ──
|
|
802
|
+
if (resolvedAction === "gaps") {
|
|
803
|
+
if (!url) return textResult("Error: url is required for gap analysis.");
|
|
804
|
+
if (!site_url) return textResult("Error: site_url is required for gap analysis.");
|
|
805
|
+
|
|
806
|
+
// Fetch page content and GSC queries in parallel
|
|
807
|
+
const [pageRes, analyticsRes] = await Promise.all([
|
|
808
|
+
fetch(url, {
|
|
809
|
+
headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", Accept: "text/html" },
|
|
810
|
+
redirect: "follow",
|
|
811
|
+
}),
|
|
812
|
+
querySearchAnalytics(site_url, {
|
|
813
|
+
startDate: start_date ?? daysAgo(28),
|
|
814
|
+
endDate: end_date ?? daysAgo(3),
|
|
815
|
+
dimensions: ["query"],
|
|
816
|
+
rowLimit: row_limit ?? 500,
|
|
817
|
+
dimensionFilterGroups: [
|
|
818
|
+
{
|
|
819
|
+
groupType: "and" as const,
|
|
820
|
+
filters: [{ dimension: "page", operator: "equals", expression: url } as SearchAnalyticsFilter],
|
|
821
|
+
},
|
|
822
|
+
],
|
|
823
|
+
}),
|
|
824
|
+
]);
|
|
825
|
+
|
|
826
|
+
if (!pageRes.ok) return textResult(`Error fetching ${url}: HTTP ${pageRes.status}`);
|
|
827
|
+
const html = await pageRes.text();
|
|
828
|
+
const pageText = extractPageText(html);
|
|
829
|
+
|
|
830
|
+
const queries = (analyticsRes.rows ?? []).map((row) => ({
|
|
831
|
+
query: row.keys[0],
|
|
832
|
+
clicks: row.clicks,
|
|
833
|
+
impressions: row.impressions,
|
|
834
|
+
ctr: row.ctr,
|
|
835
|
+
position: row.position,
|
|
836
|
+
}));
|
|
837
|
+
|
|
838
|
+
return textResult(formatGapAnalysis(url, site_url, queries, pageText));
|
|
839
|
+
}
|
|
840
|
+
|
|
713
841
|
return textResult(`Error: Unknown action "${resolvedAction}".`);
|
|
714
842
|
} catch (err) {
|
|
715
843
|
const msg = err instanceof Error ? err.message : String(err);
|
package/src/tools/speed.ts
CHANGED
|
@@ -457,7 +457,8 @@ function formatBatchTable(results: Array<{ url: string; result: PsiResult }>, st
|
|
|
457
457
|
if (sharedOpps.length > 0) {
|
|
458
458
|
lines.push("--- Shared Opportunities ---", "");
|
|
459
459
|
for (const [, { title, count, maxSavings }] of sharedOpps.slice(0, 10)) {
|
|
460
|
-
const
|
|
460
|
+
const cleaned = maxSavings.replace(/^Est savings of /i, "");
|
|
461
|
+
const savingsStr = cleaned ? `, up to ${cleaned}` : "";
|
|
461
462
|
lines.push(` ${title} (${count}/${results.length} pages${savingsStr})`);
|
|
462
463
|
}
|
|
463
464
|
lines.push("");
|