pagesight 0.15.1 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/tools/search.ts +130 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagesight",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "See your site the way search engines and AI see it.",
5
5
  "keywords": [
6
6
  "seo",
@@ -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(/&amp;/g, "&")
480
+ .replace(/&lt;/g, "<")
481
+ .replace(/&gt;/g, ">")
482
+ .replace(/&quot;/g, '"')
483
+ .replace(/&#39;/g, "'")
484
+ .replace(/&nbsp;/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, or analyze search traffic (clicks, impressions, CTR, rankings).",
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);