gsc-cli 2.1.0 → 2.2.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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +410 -414
  3. data/bin/gsc +28067 -5661
  4. data/dist/gsc +29121 -5046
  5. data/lib/gsc/aio_hunter.rb +343 -0
  6. data/lib/gsc/answer_synthesizer.rb +157 -0
  7. data/lib/gsc/api.rb +53 -1
  8. data/lib/gsc/auth.rb +26 -0
  9. data/lib/gsc/brand_segmenter.rb +140 -0
  10. data/lib/gsc/cache_manager.rb +806 -0
  11. data/lib/gsc/cannibalization_analyzer.rb +141 -0
  12. data/lib/gsc/canonical_chains.rb +367 -0
  13. data/lib/gsc/citation_simulator.rb +339 -0
  14. data/lib/gsc/cli/aio_hunter.rb +154 -0
  15. data/lib/gsc/cli/analytics.rb +788 -0
  16. data/lib/gsc/cli/audit.rb +1976 -0
  17. data/lib/gsc/cli/base.rb +384 -0
  18. data/lib/gsc/cli/cache.rb +266 -0
  19. data/lib/gsc/cli/canonical.rb +223 -0
  20. data/lib/gsc/cli/citation_simulator.rb +152 -0
  21. data/lib/gsc/cli/dashboard.rb +354 -0
  22. data/lib/gsc/cli/doctor.rb +129 -0
  23. data/lib/gsc/cli/eeat.rb +125 -0
  24. data/lib/gsc/cli/ga4.rb +852 -0
  25. data/lib/gsc/cli/growth.rb +650 -0
  26. data/lib/gsc/cli/hreflang.rb +164 -0
  27. data/lib/gsc/cli/image_seo.rb +162 -0
  28. data/lib/gsc/cli/indexing.rb +458 -0
  29. data/lib/gsc/cli/intent_shift.rb +125 -0
  30. data/lib/gsc/cli/keyword_value.rb +134 -0
  31. data/lib/gsc/cli/keywords.rb +795 -0
  32. data/lib/gsc/cli/landing_roi.rb +308 -0
  33. data/lib/gsc/cli/low_ctr.rb +213 -0
  34. data/lib/gsc/cli/mobile_parity.rb +150 -0
  35. data/lib/gsc/cli/report.rb +100 -0
  36. data/lib/gsc/cli/rich_results.rb +172 -0
  37. data/lib/gsc/cli/schema_generate.rb +149 -0
  38. data/lib/gsc/cli/seasonal.rb +232 -0
  39. data/lib/gsc/cli/security.rb +153 -0
  40. data/lib/gsc/cli/setup.rb +1291 -0
  41. data/lib/gsc/cli/sitemap_tree.rb +143 -0
  42. data/lib/gsc/cli/skill_pack.rb +62 -0
  43. data/lib/gsc/cli/soft_404.rb +199 -0
  44. data/lib/gsc/cli/sparkline.rb +227 -0
  45. data/lib/gsc/cli/watchdog.rb +150 -0
  46. data/lib/gsc/cli/zombie_purger.rb +208 -0
  47. data/lib/gsc/cli.rb +707 -5265
  48. data/lib/gsc/cli_advanced.rb +987 -44
  49. data/lib/gsc/client.rb +17 -2
  50. data/lib/gsc/color.rb +16 -1
  51. data/lib/gsc/command_registry.rb +47 -9
  52. data/lib/gsc/config.rb +2 -2
  53. data/lib/gsc/ctr_curve.rb +115 -0
  54. data/lib/gsc/decay_predictor.rb +322 -0
  55. data/lib/gsc/doctor.rb +434 -0
  56. data/lib/gsc/eeat_auditor.rb +428 -0
  57. data/lib/gsc/entity_auditor.rb +229 -0
  58. data/lib/gsc/firewall_scanner.rb +733 -0
  59. data/lib/gsc/geo_auditor.rb +368 -0
  60. data/lib/gsc/google_trends.rb +8 -1
  61. data/lib/gsc/heading_validator.rb +283 -0
  62. data/lib/gsc/hreflang_validator.rb +412 -0
  63. data/lib/gsc/image_seo.rb +286 -0
  64. data/lib/gsc/indexing_queue.rb +179 -0
  65. data/lib/gsc/indexnow.rb +93 -0
  66. data/lib/gsc/intent_shift.rb +188 -0
  67. data/lib/gsc/internal_links.rb +153 -36
  68. data/lib/gsc/keyword_value.rb +191 -0
  69. data/lib/gsc/landing_roi.rb +195 -0
  70. data/lib/gsc/llms_generator.rb +343 -22
  71. data/lib/gsc/low_ctr_rewriter.rb +408 -0
  72. data/lib/gsc/mobile_parity.rb +222 -0
  73. data/lib/gsc/network_tracer.rb +8 -1
  74. data/lib/gsc/page_analyzer.rb +47 -7
  75. data/lib/gsc/prompts.rb +38 -29
  76. data/lib/gsc/questions_harvester.rb +178 -0
  77. data/lib/gsc/report_generator.rb +461 -0
  78. data/lib/gsc/rich_results.rb +388 -0
  79. data/lib/gsc/robots_checker.rb +46 -15
  80. data/lib/gsc/schema_generator.rb +788 -0
  81. data/lib/gsc/schema_validator.rb +36 -38
  82. data/lib/gsc/seasonal_predictor.rb +381 -0
  83. data/lib/gsc/security_scanner.rb +496 -0
  84. data/lib/gsc/serp_feature_detector.rb +359 -0
  85. data/lib/gsc/serp_preview.rb +108 -22
  86. data/lib/gsc/site_crawler.rb +113 -21
  87. data/lib/gsc/sitemap_loader.rb +15 -4
  88. data/lib/gsc/sitemap_tree.rb +301 -0
  89. data/lib/gsc/skill_pack.rb +195 -0
  90. data/lib/gsc/soft_404_analyzer.rb +385 -0
  91. data/lib/gsc/sparkline.rb +171 -0
  92. data/lib/gsc/speed_correlator.rb +416 -0
  93. data/lib/gsc/striking_playbook.rb +190 -0
  94. data/lib/gsc/title_optimizer.rb +420 -0
  95. data/lib/gsc/vault.rb +260 -0
  96. data/lib/gsc/version.rb +1 -1
  97. data/lib/gsc/watchdog.rb +235 -0
  98. data/lib/gsc/zombie_purger.rb +366 -0
  99. data/lib/gsc.rb +118 -0
  100. metadata +75 -1
data/lib/gsc/client.rb CHANGED
@@ -55,12 +55,27 @@ module GSC
55
55
  # Transparent gzip decompression
56
56
  raw_body = response.body
57
57
  body_str = if response['content-encoding'] =~ /gzip/i && raw_body && !raw_body.empty?
58
- Zlib::GzipReader.new(StringIO.new(raw_body)).read
58
+ begin
59
+ Zlib::GzipReader.new(StringIO.new(raw_body)).read
60
+ rescue Zlib::GzipFile::Error, Zlib::Error
61
+ raw_body
62
+ end
59
63
  else
60
64
  raw_body
61
65
  end
62
66
 
63
- body = body_str ? (JSON.parse(body_str) rescue body_str) : nil
67
+ body_str = body_str.to_s.dup.force_encoding('UTF-8').scrub if body_str
68
+
69
+ body = if body_str && !body_str.strip.empty?
70
+ begin
71
+ parsed = JSON.parse(body_str)
72
+ parsed.is_a?(Hash) || parsed.is_a?(Array) ? parsed : { 'value' => parsed }
73
+ rescue JSON::ParserError, Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
74
+ { 'error' => { 'message' => body_str }, 'raw' => body_str }
75
+ end
76
+ else
77
+ {}
78
+ end
64
79
 
65
80
  {
66
81
  ok: response.is_a?(Net::HTTPSuccess),
data/lib/gsc/color.rb CHANGED
@@ -16,7 +16,22 @@ module GSC
16
16
  GRAY = "\e[90m"
17
17
 
18
18
  def self.c(text, *styles)
19
- "#{styles.join}#{text}#{RESET}"
19
+ t = text.to_s.dup.force_encoding('UTF-8').scrub
20
+ "#{styles.join}#{t}#{RESET}"
21
+ end
22
+
23
+ def self.cyan(str); c(str, CYAN); end
24
+ def self.yellow(str); c(str, YELLOW); end
25
+ def self.green(str); c(str, GREEN); end
26
+ def self.red(str); c(str, RED); end
27
+ def self.bold(str); c(str, BOLD); end
28
+ def self.gray(str); c(str, GRAY); end
29
+ def self.magenta(str); c(str, MAGENTA); end
30
+ def self.white(str); c(str, WHITE); end
31
+ def self.dim(str); c(str, DIM); end
32
+
33
+ def self.strip_ansi(str)
34
+ str.to_s.gsub(/\e\[[0-9;]*[mK]/, '')
20
35
  end
21
36
  end
22
37
  end
@@ -6,9 +6,9 @@ COMMAND_REGISTRY = [
6
6
  {
7
7
  category: "Google Trends & Keyword Demand",
8
8
  commands: [
9
- { name: "ke <seed|file>", shortcut: "ke", desc: "Keywords Everywhere: Exact monthly volume, CPC, competition & GSC correlation", flags: ["--country", "--limit", "--json"] },
10
- { name: "ke-credits", shortcut: "ke-credits", desc: "Check remaining Keywords Everywhere account API credits", flags: ["--json"] },
11
- { name: "connect ke [key]", shortcut: "connect ke", desc: "Connect Keywords Everywhere API key and save to config.json", flags: [] },
9
+ { name: "ke <seed|file>", shortcut: "ke", desc: "Keywords Everywhere: Exact monthly volume, CPC, competition & GSC correlation", flags: ["--country", "--limit", "--json"] },
10
+ { name: "ke-credits", shortcut: "ke-credits", desc: "Check remaining Keywords Everywhere account API credits", flags: ["--json"] },
11
+ { name: "connect ke [key]", shortcut: "connect ke", desc: "Connect Keywords Everywhere API key and save to config.json", flags: [] },
12
12
  { name: "trends <query>", shortcut: "tr", desc: "Live Google Trends: 5yr/1yr demand trajectory, velocity & breakout queries", flags: ["--geo", "--time", "--json"] },
13
13
  { name: "planner <seed>", shortcut: "kp", desc: "Free keyword planner: intent expansion & GSC ranking correlation", flags: ["--country", "--limit", "--json"] },
14
14
  { name: "planner-import <file>", shortcut: "pi", desc: "Import Google Ads CSV or Markdown table to score & rank opportunities", flags: ["--limit", "--json"] }
@@ -18,12 +18,18 @@ COMMAND_REGISTRY = [
18
18
  category: "Search Console Intelligence",
19
19
  commands: [
20
20
  { name: "performance", shortcut: "p", desc: "Executive dashboard: Clicks, Impressions, CTR, Position", flags: ["--days", "--limit", "--csv"] },
21
- { name: "top-queries", shortcut: "t", desc: "Top search queries, rankings, and CTR", flags: ["--days", "--limit", "-s", "--order", "--csv"] },
21
+ { name: "top-queries", shortcut: "t", desc: "Top search queries, rankings, and CTR", flags: ["--days", "--limit", "-s", "--order", "--brand", "--non-brand", "--brand-name", "--csv"] },
22
+ { name: "brand", shortcut: "b", desc: "Brand vs. non-brand search query segmentation & share", flags: ["--days", "--limit", "--brand-name", "--json"] },
23
+ { name: "ctr-curve", shortcut: nil, desc: "Expected SERP CTR curve & click gain simulator", flags: ["--target-pos", "--min-imp", "--limit", "--days", "--csv"] },
22
24
  { name: "top-pages", shortcut: nil, desc: "Top landing pages driving organic search clicks", flags: ["--days", "--limit", "-s", "--order", "--csv"] },
23
25
  { name: "opportunities", shortcut: "o", desc: "Striking-distance queries (Pos 7-20) to push to Top 3", flags: ["--days", "--min-imp", "--min-pos", "--max-pos"] },
26
+ { name: "strike", shortcut: "sp", desc: "Striking-distance tactical playbook: Title rewrites, heading recipes & internal link anchors (Pos 7-20)", flags: ["--days", "--min-imp", "--min-pos", "--max-pos", "--limit", "--csv", "--json"] },
24
27
  { name: "underperformers", shortcut: "u", desc: "High-ranking queries (Top 10) with low CTR (title tag wins)", flags: ["--days", "--min-imp"] },
28
+ { name: "low-ctr [query/url]", shortcut: "lc", desc: "Low-CTR High-Impression Title Tag Rewriter: 3 SERP-safe hook titles (<560px), click hemorrhage & revenue recovery", flags: ["--min-imp", "--cpc", "--limit", "--brand", "--csv", "--json"] },
25
29
  { name: "cannibalization", shortcut: "c", desc: "Detect internal URLs competing for the same keywords", flags: ["--days", "--min-imp"] },
30
+ { name: "questions-harvest [page]", shortcut: "qh", desc: "Harvest question queries from GSC and generate FAQPage JSON-LD schema", flags: ["--days", "--min-imp", "--limit", "--generate", "--csv", "--json"] },
26
31
  { name: "decay", shortcut: "d", desc: "Period-over-period decay detection (decaying vs surging)", flags: ["--compare"] },
32
+ { name: "sparklines [query|page]", shortcut: "spark", desc: "Terminal ASCII Sparklines & Multi-Horizon Trajectory Graphs (Clicks, Imp, CTR, Pos)", flags: ["--days", "--metric", "--height", "--data", "--csv", "--json"] },
27
33
  { name: "trends", shortcut: nil, desc: "Compare current vs prior period search trends", flags: ["--compare"] },
28
34
  { name: "devices", shortcut: nil, desc: "Desktop vs Mobile vs Tablet search traffic share", flags: ["--days"] },
29
35
  { name: "countries", shortcut: nil, desc: "Geographic search demand by country", flags: ["--days", "--limit"] },
@@ -52,27 +58,59 @@ COMMAND_REGISTRY = [
52
58
  commands: [
53
59
  { name: "inspect <url>", shortcut: nil, desc: "Live Google index check (coverage, canonical, date, robots.txt)", flags: ["-d"] },
54
60
  { name: "index <url>", shortcut: nil, desc: "Notify Googlebot to crawl/index URL immediately (URL_UPDATED)", flags: ["--dry-run"] },
61
+ { name: "index-batch [action]", shortcut: "ib", desc: "Automated 200 URL/day quota queue manager (add, run, status, clear)", flags: ["--batch-size", "--dry-run", "--json"] },
55
62
  { name: "remove <url>", shortcut: nil, desc: "Notify Googlebot a page has been deleted (URL_DELETED)", flags: ["--dry-run"] },
56
63
  { name: "status <url>", shortcut: nil, desc: "Check Google Indexing API notification metadata", flags: [] },
57
64
  { name: "sitemaps-list", shortcut: nil, desc: "List registered XML sitemaps in Search Console", flags: ["-d"] },
58
65
  { name: "sitemaps-submit <url>", shortcut: nil, desc: "Submit/register XML sitemap with Search Console", flags: ["-d"] },
59
66
  { name: "index-sitemap <file/url>", shortcut: nil, desc: "Batch notify Googlebot to index all sitemap URLs", flags: ["--delay", "--dry-run"] },
60
- { name: "inspect-sitemap <file/url>", shortcut: nil, desc: "Bulk inspect indexation status for all sitemap URLs", flags: ["--delay"] }
67
+ { name: "inspect-sitemap <file/url>", shortcut: nil, desc: "Bulk inspect indexation status for all sitemap URLs", flags: ["--delay"] },
68
+ { name: "indexnow <url>", shortcut: "in", desc: "Instant indexing via IndexNow protocol (Bing, Yandex, Seznam, Naver)", flags: ["--key", "--json"] },
69
+ { name: "indexnow-sitemap <sitemap>", shortcut: "ins", desc: "Batch submit all sitemap URLs to IndexNow", flags: ["--key", "--limit", "--json"] },
70
+ { name: "connect indexnow [key]", shortcut: nil, desc: "Configure IndexNow API key and display verification file details", flags: [] }
61
71
  ]
62
72
  },
63
73
  {
64
74
  category: "Health, Diagnostics & Setup",
65
75
  commands: [
76
+ { name: "geo [url]", shortcut: "aeo", desc: "Generative Engine Optimization: Audit AI bot access & citability score (ChatGPT, Perplexity, Claude)", flags: ["--json"] },
77
+ { name: "cite-sim <url|file> [query]", shortcut: "cite", desc: "AI Search Citation Simulator: Emulates ChatGPT, Perplexity & Claude citations, verifiable quote extraction & CLS score", flags: ["--query", "--model", "--csv", "--json"] },
78
+ { name: "answer <query>", shortcut: "ans", desc: "Synthesize high-citability 40-60w direct answers & info-gain snippets (Featured Snippets & Perplexity)", flags: ["--brand", "--type", "--generate", "--json"] },
79
+ { name: "entity [url]", shortcut: "kg", desc: "Knowledge Graph entity & sameAs disambiguation audit (Wikidata, Wikipedia, LinkedIn)", flags: ["--generate", "--json"] },
80
+ { name: "serp <query|url>", shortcut: "preview", desc: "Live SERP Feature Detector (AI Overviews, Snippets, PAA, Local) & Pixel Width Simulator", flags: ["--live", "--preview", "--title", "--desc", "--url", "--json"] },
81
+ { name: "serp-features <query>", shortcut: "sf", desc: "Scan live Google SERP features, Zero-Click Threat index & Rich Snippet capture playbook", flags: ["--json"] },
66
82
  { name: "page <url|file>", shortcut: "pg", desc: "Detailed On-Page DOM + Off-Page GSC Performance Audit", flags: ["--check-links", "--json"] },
83
+ { name: "headings <url|file>", shortcut: "ht", desc: "Heading hierarchy tree validator: H1-H6 nesting, depth, empty tags & health score", flags: ["--keyword", "--json"] },
67
84
  { name: "site-audit [sitemap]", shortcut: "crl", desc: "Full site crawl, broken links (404/500), image alts & AI report", flags: ["--check-links", "--report", "--json"] },
85
+ { name: "orphans [sitemap|url]", shortcut: "il", desc: "Internal link equity & orphan page rescue: Detect 0-link pages, click depth & suggested rescue sources", flags: ["--limit", "--concurrency", "--csv", "--json"] },
86
+ { name: "speed-correlate [url]", shortcut: "cwv", desc: "Correlate Core Web Vitals (LCP/CLS) directly with Search Console impressions & projected traffic surge", flags: ["--days", "--strategy", "-d", "--json"] },
87
+ { name: "firewall [url]", shortcut: "ai-bots", desc: "Scan /robots.txt & edge WAF/Cloudflare bot challenges for ChatGPT, Claude & Perplexity", flags: ["--user-agent", "--json"] },
88
+ { name: "security [url]", shortcut: "sec", desc: "Security Headers & Mixed Content Auditor: HSTS preload, CSP, clickjacking & SSL/TLS certificate expiry", flags: ["--json"] },
89
+ { name: "landing-roi [url]", shortcut: "roi", desc: "Landing Page Economic ROI & Revenue Hemorrhage: Merge GSC clicks with GA4 bounce rates to calculate dollar revenue leaks", flags: ["--aov", "--conv-rate", "--benchmark", "--clicks", "--bounce", "--csv", "--json"] },
90
+ { name: "schema-generate [url]", shortcut: "schema-gen", desc: "Structured Data Rich Snippet Generator: Auto-synthesizes valid Schema.org JSON-LD (Product, FAQ, HowTo, Article, Software) with Google validation", flags: ["--type", "--price", "--currency", "--rating", "--format", "--open", "--json"] },
91
+ { name: "sitemap-tree [path|url]", shortcut: "smt", desc: "Sitemap Index Hierarchy & Coverage Auditor: Recursive sub-sitemap tree, Google 50k URL / 50MB compliance & GSC coverage ratio", flags: ["--csv", "--json"] },
92
+ { name: "titles [sitemap|url]", shortcut: "title-opt", desc: "Batch audit title pixel width (>580px) & synthesize non-truncating alternatives", flags: ["--overflow-only", "--limit", "--concurrency", "--csv", "--json"] },
93
+ { name: "image-seo <url|file>", shortcut: "img", desc: "Image SEO & Next-Gen Format Auditor: WebP/AVIF formats, missing alt text, explicit width/height for CLS & LCP preloading", flags: ["--check-size", "--limit", "--csv", "--json"] },
94
+ { name: "hreflang-check <url|file>", shortcut: "hreflang", desc: "International Hreflang Reciprocity Validator: Bidirectional link graph, ISO 639-1 / 3166-1 codes & x-default fallback", flags: ["--no-reciprocity", "--csv", "--json"] },
95
+ { name: "eeat <url|file>", shortcut: "author", desc: "Author E-E-A-T & Credential Auditor: Person schema, credentials, editorial reviews, dates & Knowledge Graph links", flags: ["--csv", "--json"] },
68
96
  { name: "audit", shortcut: "a", desc: "360-degree Comprehensive SEO & GA4 health audit", flags: ["--days", "-d"] },
69
- { name: "zombies <sitemap>", shortcut: nil, desc: "Find zero-impression crawl waste pages over 90 days", flags: [] },
70
- { name: "use <domain or 1-9>", shortcut: nil, desc: "Switch active default domain", flags: [] },
97
+ { name: "report [domain]", shortcut: "rep", desc: "Automated Executive SEO & AI Search Report Generator: Single-file HTML dashboard & Markdown reports", flags: ["--html", "--md", "--days", "--title", "--json"] },
98
+ { name: "intent-shift [domain]", shortcut: "intent", desc: "Search Intent Drift & Landing Page Mismatch Tracker: Detect transactional/informational intent divergence & compute Portfolio Volatility Index", flags: ["--days", "--brand", "--csv", "--json"] },
99
+ { name: "rich-results <url|file>", shortcut: "rich-test", desc: "Google Rich Results Testing & Validation Suite: Disqualifying errors, recommended enhancements, 1-click JSON-LD fixes & browser testing", flags: ["--open", "--patch", "--csv", "--json"] },
100
+ { name: "watch [domain]", shortcut: "mon", desc: "Continuous SERP Watchdog & Rank Drift Monitor: Real-time rank drop alerts, CTR crash detection, webhooks & automated cron/launchd daemon", flags: ["--once", "--daemon", "--interval", "--threshold", "--webhook", "--slack", "--cron", "--launchd", "--systemd", "--csv", "--json"] },
101
+ { name: "kw-value [domain]", shortcut: "kwval", desc: "Conversion-Weighted Keyword Opportunity Matrix: Calculate revenue per keyword, simulate Top 3 pipeline upside & prioritize high-buyer-intent terms", flags: ["--aov", "--conv-rate", "--margin", "--target-pos", "--days", "--csv", "--json"] },
102
+ { name: "mobile-parity [domain]", shortcut: "mobile", desc: "Mobile vs. Desktop SERP Parity Auditor: Cross-device rank gaps, responsive suppression penalties & mobile CTR loss diagnosis", flags: ["--days", "--min-imp", "--gap-threshold", "--csv", "--json"] },
103
+ { name: "aio-hunter [query|domain]", shortcut: "aio", desc: "Google AI Overview (AIO) Opportunity Hunter: Detects AIO SERP presence, extracts cited sources, quantifies citation gaps & generates snippet capture recipes", flags: ["--limit", "--min-imp", "--days", "--csv", "--json"] },
104
+ { name: "skill-pack [dir]", shortcut: "agent-pack", desc: "Autonomous AI Agent Skill Packaging Engine: Synthesizes production SKILL.md, recipes.json & integration hooks for Antigravity, Claude Code & Cursor", flags: ["--target", "--dir", "--dry-run", "--json"] },
105
+ { name: "zombies [sitemap|file]", shortcut: "z", desc: "Zombie Content Purger & Crawl Equity Optimizer: CEDI score (0-100), automated 410/301 triage & 1-click server rules", flags: ["--days", "--threshold", "--action", "--format", "--inventory", "--csv", "--json"] },
106
+ { name: "soft-404 [url|file]", shortcut: "404", desc: "404 & Soft-404 Crawl Error Diagnostic: Detects thin/empty pages returning 200 OK, calculates crawl waste & synthesizes smart 301/410 redirect rules", flags: ["--concurrency", "--threshold", "--format", "--csv", "--json"] },
107
+ { name: "switch <domain>", shortcut: "use", desc: "Instant agency domain & credentials switcher (domain, alias, or index #)", flags: ["--json"] },
108
+ { name: "vault [action]", shortcut: nil, desc: "Agency Credential Vault: AES-256-GCM encrypted store for 50+ service accounts (list, add, remove, status)", flags: ["--domain", "--alias", "--property", "--json"] },
71
109
  { name: "domains", shortcut: nil, desc: "List verified domains and GA4 property links", flags: [] },
110
+ { name: "doctor", shortcut: "doc", desc: "Zero-Dependency Architecture Certification & System Health: Verifies 100% pure Ruby stdlib, cold-start latency (<100ms), OpenSSL cipher support & config permissions", flags: ["--strict", "--fix", "--json"] },
72
111
  { name: "where", shortcut: nil, desc: "Inspect installation path, active key, and config file", flags: [] },
73
112
  { name: "connect", shortcut: nil, desc: "1-Click Setup Wizard: auto-detects key or drag & drop", flags: [] },
74
113
  { name: "connect-ga4", shortcut: nil, desc: "Interactive GA4 linking wizard", flags: [] },
75
- { name: "open", shortcut: nil, desc: "Reveal configuration directory (~/.config/gsc) in Finder", flags: [] },
76
114
  { name: "prompts [id]", shortcut: "pb", desc: "25 Autonomous AI SEO Playbooks & ready-to-paste prompts", flags: ["--copy", "--json"] },
77
115
  { name: "skills [install|show]", shortcut: nil, desc: "Inspect or auto-install AI Agent Skill", flags: [] },
78
116
  { name: "commands", shortcut: nil, desc: "List all commands (human-readable or JSON with --json)", flags: ["--json"] }
@@ -267,7 +305,7 @@ COMMAND_REGISTRY = [
267
305
  2. **Optimal Length**: Keep `<title>` between **50 and 60 characters** (maximum 580px width) so Google does not truncate with `...`.
268
306
  3. **Emotional Hook / CTR Multiplier**: Include brackets `[Free Calculator]`, actionable numbers (`10 Best`, `2026 Checklist`), or primary value props.
269
307
  4. **Brand Suffix**: Always append ` | BrandName` at the end.
270
- 5. **Meta Description**: 130–155 characters summarizing the page benefit with a clear call-to-action (e.g. *"Calculate exact moving box counts by room, size, and weight. Free instant estimator."*).
308
+ 5. **Meta Description**: 130–155 characters summarizing the page benefit with a clear call-to-action (e.g. *"Inspect technical SEO health, Core Web Vitals, and index coverage instantly. Free diagnostic audit."*).
271
309
  6. **Heading Polish**: Ensure `<h1>` matches search intent and **never ends with a period (`.`)**.
272
310
 
273
311
  #### Step 4: Immediately Trigger Googlebot Indexing
data/lib/gsc/config.rb CHANGED
@@ -153,7 +153,7 @@ def self.load_saved_keywords(domain, identifier)
153
153
 
154
154
  target_file = if identifier.to_s =~ /^\d+$/
155
155
  idx = identifier.to_i - 1
156
- list[idx]&.fetch('path', nil)
156
+ idx >= 0 ? list[idx]&.fetch('path', nil) : nil
157
157
  else
158
158
  found = list.find { |item| item['file'] == identifier || item['seed'] == identifier || item['file'].include?(identifier.to_s) }
159
159
  found ? found['path'] : nil
@@ -171,7 +171,7 @@ def self.delete_saved_keywords(domain, identifier)
171
171
 
172
172
  target_file = if identifier.to_s =~ /^\d+$/
173
173
  idx = identifier.to_i - 1
174
- list[idx]&.fetch('path', nil)
174
+ idx >= 0 ? list[idx]&.fetch('path', nil) : nil
175
175
  else
176
176
  found = list.find { |item| item['file'] == identifier || item['file'].include?(identifier.to_s) }
177
177
  found ? found['path'] : nil
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GSC
4
+ class CtrCurve
5
+ # Empirical Google Organic SERP CTR Curve benchmark (Ahrefs / Backlinko / Advanced Web Ranking composite)
6
+ BENCHMARK_CTR = {
7
+ 1 => 28.0,
8
+ 2 => 15.5,
9
+ 3 => 11.0,
10
+ 4 => 8.0,
11
+ 5 => 6.0,
12
+ 6 => 4.5,
13
+ 7 => 3.5,
14
+ 8 => 2.8,
15
+ 9 => 2.2,
16
+ 10 => 1.8,
17
+ 11 => 1.4,
18
+ 12 => 1.2,
19
+ 13 => 1.0,
20
+ 14 => 0.9,
21
+ 15 => 0.8,
22
+ 16 => 0.7,
23
+ 17 => 0.6,
24
+ 18 => 0.5,
25
+ 19 => 0.4,
26
+ 20 => 0.4
27
+ }.freeze
28
+
29
+ DEFAULT_PAGE2_CTR = 0.5
30
+ DEFAULT_PAGE3_CTR = 0.2
31
+
32
+ def self.benchmark_for(position)
33
+ pos = position.to_f.round
34
+ return BENCHMARK_CTR[1] if pos <= 1
35
+ return BENCHMARK_CTR[pos] if BENCHMARK_CTR.key?(pos)
36
+ return DEFAULT_PAGE2_CTR if pos <= 20
37
+
38
+ DEFAULT_PAGE3_CTR
39
+ end
40
+
41
+ def self.project(rows, target_pos: 3, min_imp: 10)
42
+ target_pos = target_pos.to_i
43
+ target_pos = 3 if target_pos <= 0
44
+ target_ctr = benchmark_for(target_pos)
45
+
46
+ analyzed = []
47
+ total_incremental = 0
48
+ underperforming_queries = []
49
+
50
+ rows.each do |row|
51
+ imp = row[:impressions] || row['impressions'] || 0
52
+ next if imp < min_imp
53
+
54
+ clicks = row[:clicks] || row['clicks'] || 0
55
+ actual_ctr = if imp > 0
56
+ (clicks.to_f / imp * 100.0).round(2)
57
+ elsif (row[:ctr] || row['ctr'])
58
+ (row[:ctr] || row['ctr']).to_f.round(2)
59
+ else
60
+ 0.0
61
+ end
62
+ pos = (row[:position] || row['position'] || 100.0).to_f.round(1)
63
+ expected_ctr = benchmark_for(pos)
64
+
65
+ # Gain projection if ranking moved to target_pos
66
+ projected_clicks = ((target_ctr / 100.0) * imp).round
67
+ incremental_clicks = [projected_clicks - clicks, 0].max
68
+
69
+ # Priority score: Higher incremental clicks and closer to top page 1 = higher leverage
70
+ proximity_factor = [1.0, (25.0 - [pos, 25.0].min) / 20.0].max
71
+ opportunity_score = (incremental_clicks * proximity_factor).round(1)
72
+
73
+ # Flag title tag optimization wins: ranking on Page 1 (<=10) but CTR is < 50% of benchmark
74
+ is_underperforming = pos <= 10.0 && actual_ctr < (expected_ctr * 0.6) && imp >= 20
75
+
76
+ data = {
77
+ query: row[:query] || row['query'],
78
+ current_position: pos,
79
+ target_position: target_pos,
80
+ impressions: imp,
81
+ current_clicks: clicks,
82
+ current_ctr: actual_ctr,
83
+ expected_ctr: expected_ctr,
84
+ target_ctr: target_ctr,
85
+ projected_clicks: projected_clicks,
86
+ simulated_clicks: projected_clicks, # alias for backward compatibility
87
+ incremental_clicks: incremental_clicks,
88
+ opportunity_score: opportunity_score,
89
+ underperforming: is_underperforming
90
+ }
91
+
92
+ analyzed << data
93
+ total_incremental += incremental_clicks
94
+ underperforming_queries << data if is_underperforming
95
+ end
96
+
97
+ # Sort by highest opportunity score
98
+ analyzed.sort_by! { |r| -r[:opportunity_score] }
99
+ underperforming_queries.sort_by! { |r| -r[:impressions] }
100
+
101
+ {
102
+ target_position: target_pos,
103
+ target_ctr: target_ctr,
104
+ total_queries_analyzed: analyzed.size,
105
+ total_incremental_clicks: total_incremental,
106
+ opportunities: analyzed,
107
+ underperformers: underperforming_queries
108
+ }
109
+ end
110
+
111
+ class << self
112
+ alias simulate project
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module GSC
6
+ class DecayPredictor
7
+ # Minimum impressions over the 28-day window to evaluate decay
8
+ DEFAULT_MIN_IMP = 10
9
+
10
+ def self.analyze_timeseries(rows, dimension: 'page', min_imp: DEFAULT_MIN_IMP)
11
+ # rows is array of { "keys" => [entity, "YYYY-MM-DD"], "clicks" => n, "impressions" => n, "position" => f }
12
+ # Group by entity
13
+ entity_days = Hash.new { |h, k| h[k] = {} }
14
+ all_dates = []
15
+
16
+ rows.each do |r|
17
+ entity = r['keys'][0]
18
+ date_str = r['keys'][1]
19
+ all_dates << date_str
20
+ entity_days[entity][date_str] = {
21
+ clicks: (r['clicks'] || 0).to_i,
22
+ impressions: (r['impressions'] || 0).to_i,
23
+ position: (r['position'] || 0.0).to_f
24
+ }
25
+ end
26
+
27
+ all_dates = all_dates.uniq.sort
28
+ return empty_result(dimension) if all_dates.empty?
29
+
30
+ # Partition the dates into 4 chronological buckets (oldest to newest)
31
+ # total dates usually 28 (or whatever was queried)
32
+ chunk_size = (all_dates.size / 4.0).ceil
33
+ chunk_size = 1 if chunk_size < 1
34
+
35
+ w4_dates = all_dates[0...chunk_size] || []
36
+ w3_dates = all_dates[chunk_size...(chunk_size * 2)] || []
37
+ w2_dates = all_dates[(chunk_size * 2)...(chunk_size * 3)] || []
38
+ w1_dates = all_dates[(chunk_size * 3)..] || []
39
+
40
+ results = []
41
+
42
+ entity_days.each do |entity, days|
43
+ w4 = sum_window(days, w4_dates)
44
+ w3 = sum_window(days, w3_dates)
45
+ w2 = sum_window(days, w2_dates)
46
+ w1 = sum_window(days, w1_dates)
47
+
48
+ total_imp = w1[:impressions] + w2[:impressions] + w3[:impressions] + w4[:impressions]
49
+ total_clicks = w1[:clicks] + w2[:clicks] + w3[:clicks] + w4[:clicks]
50
+
51
+ next if total_imp < min_imp
52
+
53
+ # 7-day velocity: Compare W1 (latest 7d) vs W2 (prior 7d)
54
+ w1_clicks = w1[:clicks]
55
+ w2_clicks = w2[:clicks]
56
+ w1_imp = w1[:impressions]
57
+ w2_imp = w2[:impressions]
58
+
59
+ vel_7d_clicks_pct = calc_pct_change(w1_clicks, w2_clicks)
60
+ vel_7d_imp_pct = calc_pct_change(w1_imp, w2_imp)
61
+
62
+ # 14-day velocity: (W1 + W2) vs (W3 + W4)
63
+ h1_clicks = w1_clicks + w2_clicks
64
+ h2_clicks = w3[:clicks] + w4[:clicks]
65
+ h1_imp = w1_imp + w2_imp
66
+ h2_imp = w3[:impressions] + w4[:impressions]
67
+
68
+ vel_14d_clicks_pct = calc_pct_change(h1_clicks, h2_clicks)
69
+ vel_14d_imp_pct = calc_pct_change(h1_imp, h2_imp)
70
+
71
+ # 28-day Macro Velocity: W1 vs W4
72
+ vel_28d_clicks_pct = calc_pct_change(w1_clicks, w4[:clicks])
73
+ vel_28d_imp_pct = calc_pct_change(w1_imp, w4[:impressions])
74
+
75
+ # Historical linear regression slope across the 4 weeks:
76
+ # x = [1, 2, 3, 4], y = [w4_imp, w3_imp, w2_imp, w1_imp]
77
+ imp_series = [w4[:impressions], w3[:impressions], w2[:impressions], w1[:impressions]]
78
+ click_series = [w4[:clicks], w3[:clicks], w2[:clicks], w1[:clicks]]
79
+ pos_series = [w4[:position], w3[:position], w2[:position], w1[:position]].compact
80
+
81
+ imp_slope = calc_slope(imp_series)
82
+ click_slope = calc_slope(click_series)
83
+ pos_drift = (w1[:position] && w4[:position]) ? (w1[:position] - w4[:position]).round(1) : 0.0
84
+
85
+ # Classification
86
+ classification, severity = classify_decay(
87
+ vel_7d_imp_pct: vel_7d_imp_pct,
88
+ vel_14d_imp_pct: vel_14d_imp_pct,
89
+ vel_28d_imp_pct: vel_28d_imp_pct,
90
+ imp_slope: imp_slope,
91
+ pos_drift: pos_drift,
92
+ total_imp: total_imp,
93
+ w1: w1,
94
+ w4: w4
95
+ )
96
+
97
+ # Projected loss if trend continues for 30 days
98
+ projected_monthly_clicks_lost = 0
99
+ if click_slope.negative?
100
+ projected_monthly_clicks_lost = [(-click_slope * 4).round, w1_clicks * 4].min
101
+ end
102
+
103
+ prescription = formulate_prescription(
104
+ classification: classification,
105
+ pos_drift: pos_drift,
106
+ vel_7d_imp_pct: vel_7d_imp_pct,
107
+ vel_14d_imp_pct: vel_14d_imp_pct,
108
+ w1_imp: w1_imp,
109
+ w1_clicks: w1_clicks,
110
+ entity: entity,
111
+ dimension: dimension
112
+ )
113
+
114
+ results << {
115
+ entity: entity,
116
+ dimension: dimension,
117
+ classification: classification,
118
+ severity: severity,
119
+ total_impressions: total_imp,
120
+ total_clicks: total_clicks,
121
+ current_7d_impressions: w1_imp,
122
+ prior_7d_impressions: w2_imp,
123
+ current_7d_clicks: w1_clicks,
124
+ prior_7d_clicks: w2_clicks,
125
+ vel_7d_imp_pct: vel_7d_imp_pct,
126
+ vel_7d_clicks_pct: vel_7d_clicks_pct,
127
+ vel_14d_imp_pct: vel_14d_imp_pct,
128
+ vel_14d_clicks_pct: vel_14d_clicks_pct,
129
+ vel_28d_imp_pct: vel_28d_imp_pct,
130
+ vel_28d_clicks_pct: vel_28d_clicks_pct,
131
+ weekly_impressions: imp_series,
132
+ weekly_clicks: click_series,
133
+ impression_slope: imp_slope.round(2),
134
+ click_slope: click_slope.round(2),
135
+ current_position: w1[:position] ? w1[:position].round(1) : nil,
136
+ prior_position: w4[:position] ? w4[:position].round(1) : nil,
137
+ position_drift: pos_drift,
138
+ projected_monthly_clicks_lost: projected_monthly_clicks_lost,
139
+ prescription: prescription
140
+ }
141
+ end
142
+
143
+ # Sort: Critical decay first, then by negative slope
144
+ decaying_items = results.select { |r| %w[CRITICAL_COLLAPSE ACCELERATING_DECAY STEADY_EROSION].include?(r[:classification]) }
145
+ .sort_by { |r| [severity_weight(r[:severity]), r[:impression_slope]] }
146
+
147
+ surging_items = results.select { |r| r[:classification] == 'SURGING' }
148
+ .sort_by { |r| -r[:vel_14d_imp_pct] }
149
+
150
+ stable_items = results.select { |r| r[:classification] == 'STABLE' }
151
+
152
+ health_score = calculate_health_score(results)
153
+
154
+ {
155
+ dimension: dimension,
156
+ total_evaluated: results.size,
157
+ decaying_count: decaying_items.size,
158
+ surging_count: surging_items.size,
159
+ stable_count: stable_items.size,
160
+ health_score: health_score,
161
+ health_grade: grade_for_score(health_score),
162
+ decaying: decaying_items,
163
+ surging: surging_items,
164
+ date_range: {
165
+ oldest: all_dates.first,
166
+ newest: all_dates.last,
167
+ total_days: all_dates.size
168
+ }
169
+ }
170
+ end
171
+
172
+ def self.sum_window(days_map, dates)
173
+ clicks = 0
174
+ impressions = 0
175
+ positions = []
176
+ weighted_pos_sum = 0.0
177
+ weighted_pos_imp = 0
178
+
179
+ dates.each do |d|
180
+ entry = days_map[d]
181
+ next unless entry
182
+
183
+ clicks += entry[:clicks]
184
+ impressions += entry[:impressions]
185
+ if entry[:position] && entry[:position] > 0
186
+ positions << entry[:position]
187
+ if entry[:impressions] > 0
188
+ weighted_pos_sum += (entry[:position] * entry[:impressions])
189
+ weighted_pos_imp += entry[:impressions]
190
+ end
191
+ end
192
+ end
193
+
194
+ avg_pos = if weighted_pos_imp > 0
195
+ weighted_pos_sum / weighted_pos_imp.to_f
196
+ elsif positions.any?
197
+ positions.sum / positions.size.to_f
198
+ else
199
+ nil
200
+ end
201
+
202
+ { clicks: clicks, impressions: impressions, position: avg_pos }
203
+ end
204
+
205
+ def self.calc_pct_change(curr, prior)
206
+ return 0.0 if prior.to_f.zero? && curr.to_f.zero?
207
+ return 100.0 if prior.to_f.zero? && curr.positive?
208
+ return -100.0 if curr.to_f.zero? && prior.positive?
209
+
210
+ (((curr - prior) / prior.to_f) * 100.0).round(1)
211
+ end
212
+
213
+ def self.calc_slope(series)
214
+ # x = [1, 2, 3, 4], y = series
215
+ n = series.size
216
+ return 0.0 if n < 2
217
+
218
+ x_coords = (1..n).to_a
219
+ x_mean = x_coords.sum / n.to_f
220
+ y_mean = series.sum / n.to_f
221
+
222
+ numerator = 0.0
223
+ denominator = 0.0
224
+
225
+ n.times do |i|
226
+ x_diff = x_coords[i] - x_mean
227
+ y_diff = series[i] - y_mean
228
+ numerator += (x_diff * y_diff)
229
+ denominator += (x_diff**2)
230
+ end
231
+
232
+ return 0.0 if denominator.zero?
233
+
234
+ numerator / denominator
235
+ end
236
+
237
+ def self.classify_decay(vel_7d_imp_pct:, vel_14d_imp_pct:, vel_28d_imp_pct:, imp_slope:, pos_drift:, total_imp:, w1:, w4:)
238
+ # pos_drift > 0 means current position number is higher (e.g. dropped from 5 to 9 => +4 drift)
239
+ if (vel_14d_imp_pct <= -50.0 && total_imp >= 20) || (w1[:impressions].zero? && w4[:impressions] >= 10)
240
+ ['CRITICAL_COLLAPSE', 'CRITICAL']
241
+ elsif vel_7d_imp_pct <= -35.0 && vel_7d_imp_pct < (vel_14d_imp_pct - 15.0)
242
+ ['ACCELERATING_DECAY', 'HIGH']
243
+ elsif imp_slope.negative? && (vel_14d_imp_pct <= -20.0 || pos_drift >= 3.0)
244
+ ['STEADY_EROSION', 'MODERATE']
245
+ elsif vel_14d_imp_pct >= 25.0 || imp_slope >= 5.0
246
+ ['SURGING', 'INFO']
247
+ else
248
+ ['STABLE', 'LOW']
249
+ end
250
+ end
251
+
252
+ def self.formulate_prescription(classification:, pos_drift:, vel_7d_imp_pct:, vel_14d_imp_pct:, w1_imp:, w1_clicks:, entity:, dimension:)
253
+ case classification
254
+ when 'CRITICAL_COLLAPSE'
255
+ if pos_drift >= 5.0
256
+ "🚨 Severe SERP Ranking Drop (+#{pos_drift} pos): Inspect for indexability errors, manual actions, or lost canonical tag."
257
+ elsif w1_imp.zero?
258
+ "💀 Zero Search Impressions in Past 7 Days: Run 'gsc inspect #{entity}' to verify Googlebot coverage."
259
+ else
260
+ "⚠️ High-Volume Collapse (#{vel_14d_imp_pct}% over 14d): Prioritize emergency content refresh and re-index via 'gsc index #{entity}'."
261
+ end
262
+ when 'ACCELERATING_DECAY'
263
+ "⚡ Rapid 7-Day Traffic Drop (#{vel_7d_imp_pct}%): Competitor surge detected. Enhance headline hook and verify schema."
264
+ when 'STEADY_EROSION'
265
+ if pos_drift >= 2.0
266
+ "📉 Gradual Position Drift (+#{pos_drift}): Update dated statistics, expand FAQ answers, and add 2-3 internal links."
267
+ else
268
+ "📉 Search Demand Erosion (#{vel_14d_imp_pct}%): Content freshness fatigue. Add 2026 insights and interactive widgets."
269
+ end
270
+ when 'SURGING'
271
+ "🚀 Surging Velocity (+#{vel_14d_imp_pct}%): Momentum window! Add related internal links to capture additional cluster queries."
272
+ else
273
+ "✅ Consistent Performance: Maintain current on-page content and monitor monthly."
274
+ end
275
+ end
276
+
277
+ def self.severity_weight(sev)
278
+ case sev
279
+ when 'CRITICAL' then 1
280
+ when 'HIGH' then 2
281
+ when 'MODERATE' then 3
282
+ else 4
283
+ end
284
+ end
285
+
286
+ def self.calculate_health_score(results)
287
+ return 100 if results.empty?
288
+
289
+ critical = results.count { |r| r[:severity] == 'CRITICAL' }
290
+ high = results.count { |r| r[:severity] == 'HIGH' }
291
+ moderate = results.count { |r| r[:severity] == 'MODERATE' }
292
+
293
+ score = 100 - (critical * 25) - (high * 15) - (moderate * 5)
294
+ [[score, 0].max, 100].min
295
+ end
296
+
297
+ def self.grade_for_score(score)
298
+ case score
299
+ when 90..100 then 'A'
300
+ when 80..89 then 'B'
301
+ when 70..79 then 'C'
302
+ when 60..69 then 'D'
303
+ else 'F'
304
+ end
305
+ end
306
+
307
+ def self.empty_result(dim)
308
+ {
309
+ dimension: dim,
310
+ total_evaluated: 0,
311
+ decaying_count: 0,
312
+ surging_count: 0,
313
+ stable_count: 0,
314
+ health_score: 100,
315
+ health_grade: 'A',
316
+ decaying: [],
317
+ surging: [],
318
+ date_range: {}
319
+ }
320
+ end
321
+ end
322
+ end