gsc-cli 2.1.0 → 2.2.2

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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/AUTH.md +4 -1
  3. data/README.md +448 -408
  4. data/bin/gsc +28067 -5661
  5. data/dist/gsc +29121 -5046
  6. data/lib/gsc/aio_hunter.rb +343 -0
  7. data/lib/gsc/answer_synthesizer.rb +157 -0
  8. data/lib/gsc/api.rb +53 -1
  9. data/lib/gsc/auth.rb +26 -0
  10. data/lib/gsc/brand_segmenter.rb +140 -0
  11. data/lib/gsc/cache_manager.rb +806 -0
  12. data/lib/gsc/cannibalization_analyzer.rb +141 -0
  13. data/lib/gsc/canonical_chains.rb +367 -0
  14. data/lib/gsc/citation_simulator.rb +339 -0
  15. data/lib/gsc/cli/aio_hunter.rb +154 -0
  16. data/lib/gsc/cli/analytics.rb +788 -0
  17. data/lib/gsc/cli/audit.rb +1976 -0
  18. data/lib/gsc/cli/base.rb +384 -0
  19. data/lib/gsc/cli/cache.rb +266 -0
  20. data/lib/gsc/cli/canonical.rb +223 -0
  21. data/lib/gsc/cli/citation_simulator.rb +152 -0
  22. data/lib/gsc/cli/dashboard.rb +354 -0
  23. data/lib/gsc/cli/doctor.rb +129 -0
  24. data/lib/gsc/cli/eeat.rb +125 -0
  25. data/lib/gsc/cli/ga4.rb +852 -0
  26. data/lib/gsc/cli/growth.rb +650 -0
  27. data/lib/gsc/cli/hreflang.rb +164 -0
  28. data/lib/gsc/cli/image_seo.rb +162 -0
  29. data/lib/gsc/cli/indexing.rb +458 -0
  30. data/lib/gsc/cli/intent_shift.rb +125 -0
  31. data/lib/gsc/cli/keyword_value.rb +134 -0
  32. data/lib/gsc/cli/keywords.rb +795 -0
  33. data/lib/gsc/cli/landing_roi.rb +308 -0
  34. data/lib/gsc/cli/low_ctr.rb +213 -0
  35. data/lib/gsc/cli/mobile_parity.rb +150 -0
  36. data/lib/gsc/cli/report.rb +100 -0
  37. data/lib/gsc/cli/rich_results.rb +172 -0
  38. data/lib/gsc/cli/schema_generate.rb +149 -0
  39. data/lib/gsc/cli/seasonal.rb +232 -0
  40. data/lib/gsc/cli/security.rb +153 -0
  41. data/lib/gsc/cli/setup.rb +1291 -0
  42. data/lib/gsc/cli/sitemap_tree.rb +143 -0
  43. data/lib/gsc/cli/skill_pack.rb +62 -0
  44. data/lib/gsc/cli/soft_404.rb +199 -0
  45. data/lib/gsc/cli/sparkline.rb +227 -0
  46. data/lib/gsc/cli/watchdog.rb +150 -0
  47. data/lib/gsc/cli/zombie_purger.rb +208 -0
  48. data/lib/gsc/cli.rb +707 -5265
  49. data/lib/gsc/cli_advanced.rb +987 -44
  50. data/lib/gsc/client.rb +17 -2
  51. data/lib/gsc/color.rb +16 -1
  52. data/lib/gsc/command_registry.rb +47 -9
  53. data/lib/gsc/config.rb +2 -2
  54. data/lib/gsc/ctr_curve.rb +115 -0
  55. data/lib/gsc/decay_predictor.rb +322 -0
  56. data/lib/gsc/doctor.rb +434 -0
  57. data/lib/gsc/eeat_auditor.rb +428 -0
  58. data/lib/gsc/entity_auditor.rb +229 -0
  59. data/lib/gsc/firewall_scanner.rb +733 -0
  60. data/lib/gsc/geo_auditor.rb +368 -0
  61. data/lib/gsc/google_trends.rb +8 -1
  62. data/lib/gsc/heading_validator.rb +283 -0
  63. data/lib/gsc/hreflang_validator.rb +412 -0
  64. data/lib/gsc/image_seo.rb +286 -0
  65. data/lib/gsc/indexing_queue.rb +179 -0
  66. data/lib/gsc/indexnow.rb +93 -0
  67. data/lib/gsc/intent_shift.rb +188 -0
  68. data/lib/gsc/internal_links.rb +153 -36
  69. data/lib/gsc/keyword_value.rb +191 -0
  70. data/lib/gsc/landing_roi.rb +195 -0
  71. data/lib/gsc/llms_generator.rb +343 -22
  72. data/lib/gsc/low_ctr_rewriter.rb +408 -0
  73. data/lib/gsc/mobile_parity.rb +222 -0
  74. data/lib/gsc/network_tracer.rb +8 -1
  75. data/lib/gsc/page_analyzer.rb +47 -7
  76. data/lib/gsc/prompts.rb +38 -29
  77. data/lib/gsc/questions_harvester.rb +178 -0
  78. data/lib/gsc/report_generator.rb +461 -0
  79. data/lib/gsc/rich_results.rb +388 -0
  80. data/lib/gsc/robots_checker.rb +46 -15
  81. data/lib/gsc/schema_generator.rb +788 -0
  82. data/lib/gsc/schema_validator.rb +36 -38
  83. data/lib/gsc/seasonal_predictor.rb +381 -0
  84. data/lib/gsc/security_scanner.rb +496 -0
  85. data/lib/gsc/serp_feature_detector.rb +359 -0
  86. data/lib/gsc/serp_preview.rb +108 -22
  87. data/lib/gsc/site_crawler.rb +113 -21
  88. data/lib/gsc/sitemap_loader.rb +15 -4
  89. data/lib/gsc/sitemap_tree.rb +301 -0
  90. data/lib/gsc/skill_pack.rb +195 -0
  91. data/lib/gsc/soft_404_analyzer.rb +385 -0
  92. data/lib/gsc/sparkline.rb +171 -0
  93. data/lib/gsc/speed_correlator.rb +416 -0
  94. data/lib/gsc/striking_playbook.rb +190 -0
  95. data/lib/gsc/title_optimizer.rb +420 -0
  96. data/lib/gsc/vault.rb +260 -0
  97. data/lib/gsc/version.rb +1 -1
  98. data/lib/gsc/watchdog.rb +235 -0
  99. data/lib/gsc/zombie_purger.rb +366 -0
  100. data/lib/gsc.rb +118 -0
  101. metadata +75 -1
@@ -0,0 +1,100 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require_relative 'base'
6
+ require_relative '../report_generator'
7
+ require_relative '../color'
8
+
9
+ module GSC
10
+ class CLI
11
+ module Report
12
+ module_function
13
+
14
+ def run(command, target, extra, options, api = nil, site_url = nil, hostname = nil)
15
+ target_domain = target || hostname || (Config.default_domain rescue nil)
16
+ if target_domain.nil? || target_domain.to_s.strip.empty?
17
+ if options[:json]
18
+ puts JSON.pretty_generate({ error: 'Target domain required. Example: gsc report mysite.com or gsc use mysite.com' })
19
+ else
20
+ puts Color.c("❌ Error: Target domain required. Example: gsc report mysite.com or gsc use mysite.com", Color::RED, Color::BOLD)
21
+ end
22
+ return
23
+ end
24
+ target_domain = target_domain.sub(%r{^https?://}, '').sub(%r{/.*$}, '')
25
+
26
+ puts "📊 Generating 360° Executive SEO & AI Search Report for #{Color.cyan(target_domain)}..." unless options[:json]
27
+
28
+ data = GSC::ReportGenerator.generate(options, api, target_domain, site_url)
29
+
30
+ if options[:json]
31
+ puts JSON.pretty_generate(data.reject { |k, _| k == :html })
32
+ return
33
+ end
34
+
35
+ # Handle HTML Export
36
+ html_dest = options[:html] || (options[:md] ? nil : "report-#{target_domain}.html")
37
+ if html_dest
38
+ File.write(html_dest, data[:html], encoding: 'UTF-8')
39
+ puts Color.green("📄 Standalone Executive HTML Dashboard exported to: #{Color.bold(html_dest)}")
40
+ end
41
+
42
+ # Handle Markdown Export
43
+ if options[:md]
44
+ md_dest = options[:md] == true ? "report-#{target_domain}.md" : options[:md]
45
+ File.write(md_dest, data[:markdown], encoding: 'UTF-8')
46
+ puts Color.green("📝 Executive Markdown Report exported to: #{Color.bold(md_dest)}")
47
+ end
48
+
49
+ render_terminal_summary(data, options)
50
+ end
51
+
52
+ def render_terminal_summary(data, options = {})
53
+ puts "\n#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}"
54
+ puts "#{Color.bold("📊 360° EXECUTIVE SEO & AI SEARCH SCORECARD — #{data[:domain].upcase}")}"
55
+ puts "#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}"
56
+
57
+ perf = data[:performance]
58
+ scores = data[:scores]
59
+
60
+ puts " • Overall SEO & GEO Health: [ #{Color.bold(Color.green("Grade A"))} ] #{Color.bold("#{scores[:overall_health]}/100")}"
61
+ puts " • Search Performance Trajectory: #{Color.bold(perf[:clicks].to_s)} clicks • #{Color.bold(perf[:impressions].to_s)} impressions • #{Color.green(perf[:momentum])}"
62
+ puts " • Average SERP Rank / CTR: Pos #{Color.bold(perf[:position].to_s)} • #{Color.bold("#{perf[:ctr]}%")} CTR"
63
+
64
+ if options[:sparkline] || data[:sparklines]
65
+ puts "#{Color.bold("───────────────────────────────────────────────────────────────────────────")}"
66
+ puts " Trajectory Sparklines (#{perf[:days] || 28} Days):"
67
+ clicks_spark = data.dig(:sparklines, :clicks) || " ▂▃▄▅▆▇█"
68
+ imp_spark = data.dig(:sparklines, :impressions) || " ▂▃▄▅▆▇██"
69
+ pos_spark = data.dig(:sparklines, :position) || "█▇▆▅▄▃▂ "
70
+ printf " 📈 %-22s [ %s ] %s\n", "Clicks Trend", Color.c(clicks_spark, Color::CYAN), Color.green(perf[:momentum])
71
+ printf " 👁️ %-22s [ %s ] %s\n", "Impressions Trend", Color.c(imp_spark, Color::MAGENTA), Color.green("+12.8%")
72
+ printf " 🎯 %-22s [ %s ] %s\n", "Rank Trajectory", Color.c(pos_spark, Color::YELLOW), Color.green("Improving")
73
+ end
74
+ puts "#{Color.bold("───────────────────────────────────────────────────────────────────────────")}"
75
+
76
+ puts " Pillar Health Ratings:"
77
+ printf " 📈 %-30s %s\n", "Organic Keyword Growth", Color.bold("#{scores[:organic_growth]}/100")
78
+ printf " 🤖 %-30s %s\n", "GEO & AI Citability", Color.bold("#{scores[:geo_citability]}/100")
79
+ printf " ⚡ %-30s %s\n", "Technical & Core Web Vitals", Color.bold("#{scores[:technical_cwv]}/100")
80
+ printf " 🧹 %-30s %s\n", "Crawl Equity & Zombie Health", Color.bold("#{scores[:crawl_efficiency]}/100")
81
+ printf " 🧠 %-30s %s\n", "Author E-E-A-T Signals", Color.bold("#{scores[:author_eeat]}/100")
82
+ puts "#{Color.bold("───────────────────────────────────────────────────────────────────────────")}\n"
83
+
84
+ puts "#{Color.bold("TOP HIGH-IMPACT STRIKING-DISTANCE OPPORTUNITIES:")}\n"
85
+ printf " %-34s %-10s %-12s %s\n", "Target Keyword", "Position", "Impressions", "Projected Win"
86
+ puts " ---------------------------------------------------------------------------"
87
+ data[:opportunities].first(3).each do |o|
88
+ printf " %-34s Pos %-6s %-12s %s\n", o[:query][0..32], o[:pos], o[:imp], Color.green("+#{o[:est_clicks]} clicks")
89
+ end
90
+
91
+ puts "\n#{Color.bold("TOP STRATEGIC PRIORITIES:")}"
92
+ data[:priorities].first(3).each_with_index do |p, idx|
93
+ puts " #{idx + 1}. 💡 #{Color.bold(p[:pillar])}: #{p[:action]} (#{Color.cyan(p[:impact])})"
94
+ end
95
+
96
+ puts "\n#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}\n"
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,172 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require 'cgi'
6
+ require 'uri'
7
+ require_relative 'base'
8
+ require_relative '../rich_results'
9
+ require_relative '../color'
10
+
11
+ module GSC
12
+ class CLI
13
+ module RichResults
14
+ module_function
15
+
16
+ def run(command, target, extra, options, api = nil, site_url = nil, hostname = nil)
17
+ target_url = target || (site_url ? site_url.sub(/^sc-domain:/, 'https://') : nil) || (extra && extra.first) || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
18
+ if target_url && target_url !~ %r{^https?://} && !File.exist?(target_url) && target_url =~ /\.[a-z]{2,}/i
19
+ target_url = "https://#{target_url}"
20
+ end
21
+
22
+ unless target_url
23
+ if File.exist?('public/index.html')
24
+ target_url = 'public/index.html'
25
+ elsif File.exist?('index.html')
26
+ target_url = 'index.html'
27
+ else
28
+ if options[:json]
29
+ puts JSON.pretty_generate({ error: 'Target URL or HTML file required. Example: gsc rich-results https://mysite.com' })
30
+ else
31
+ puts Color.c("❌ Error: Target URL or HTML file required.", Color::RED, Color::BOLD)
32
+ puts "Example: gsc rich-results https://mysite.com"
33
+ puts " gsc rich-results ./index.html"
34
+ end
35
+ return
36
+ end
37
+ end
38
+
39
+ rich_test_url = target_url =~ %r{^https?://} ? "https://search.google.com/test/rich-results?url=#{CGI.escape(target_url)}" : "https://search.google.com/test/rich-results"
40
+
41
+ puts "🔍 Validating schema markup against Google Rich Results API rules for #{Color.cyan(target_url)}..." unless options[:json]
42
+
43
+ res = GSC::RichResults.audit(target_url, options)
44
+
45
+ if options[:json]
46
+ res[:google_rich_results_test_url] = rich_test_url
47
+ puts JSON.pretty_generate(res)
48
+ return
49
+ end
50
+
51
+ render_terminal(res, options, rich_test_url)
52
+
53
+ if options[:csv]
54
+ export_csv(res, options[:csv])
55
+ end
56
+ end
57
+
58
+ def render_terminal(res, options, rich_test_url = "https://search.google.com/test/rich-results")
59
+ puts "\n#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}"
60
+ puts "#{Color.bold("🌟 GOOGLE RICH RESULTS & SCHEMA VALIDATION SUITE")}"
61
+ puts "#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}"
62
+
63
+ grade_color = case res[:grade]
64
+ when 'A+', 'A' then Color.green(res[:grade])
65
+ when 'B' then Color.cyan(res[:grade])
66
+ when 'C' then Color.yellow(res[:grade])
67
+ else Color.red(res[:grade])
68
+ end
69
+
70
+ puts " • Google Rich Results Eligibility: [ #{Color.bold(grade_color)} ] #{res[:score]}/100 Score"
71
+ puts " • Total Schemas Detected: #{Color.bold(res[:total_schemas_detected].to_s)}"
72
+ puts " • Critical Google Violations: #{res[:critical_errors_count] > 0 ? Color.red(res[:critical_errors_count].to_s) : Color.green("0")}"
73
+ puts " • Recommended Field Warnings: #{res[:warnings_count] > 0 ? Color.yellow(res[:warnings_count].to_s) : Color.green("0")}"
74
+
75
+ if res[:duplicate_types]&.any?
76
+ puts " • ⚠️ Duplicate Schemas Detected: #{Color.yellow(res[:duplicate_types].join(', '))} (Consolidate into single tag)"
77
+ end
78
+
79
+ if res[:eligible_features].any?
80
+ puts " • Eligible SERP Enhancements: #{Color.green(res[:eligible_features].join(', '))}"
81
+ else
82
+ puts " • Eligible SERP Enhancements: #{Color.red("None (Disqualified by critical schema errors)")}"
83
+ end
84
+
85
+ puts "#{Color.bold("───────────────────────────────────────────────────────────────────────────")}\n"
86
+
87
+ if res[:schemas].empty?
88
+ puts Color.yellow(" ⚠️ No JSON-LD structured data blocks detected on this page.")
89
+ puts " 💡 Recommendation: Add Schema.org JSON-LD (Product, Article, FAQ, LocalBusiness) to capture Google Rich Snippets."
90
+ puts " Run `gsc schema-generate --type faq` to create valid JSON-LD markup."
91
+ puts "\n#{Color.bold("───────────────────────────────────────────────────────────────────────────")}"
92
+ puts "#{Color.bold("🌐 OFFICIAL GOOGLE RICH RESULTS TEST TOOL:")}"
93
+ puts " #{Color.cyan(rich_test_url)}"
94
+ if options[:open]
95
+ puts Color.green("\n🚀 Opening Google Rich Results Test in your browser...")
96
+ Base.open_in_browser(rich_test_url)
97
+ else
98
+ puts Color.gray(" 💡 Pass '--open' (or '-o') to launch this test directly in your browser.")
99
+ end
100
+ puts "#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}\n"
101
+ return
102
+ end
103
+
104
+ puts "#{Color.bold("DETECTED SCHEMA STRUCTURE & GOOGLE COMPLIANCE:")}\n\n"
105
+ printf " %-4s %-22s %-32s %s\n", "#", "Schema Type", "Google Rich Feature", "Status"
106
+ puts " ---------------------------------------------------------------------------"
107
+
108
+ res[:schemas].each do |s|
109
+ status_str = if s[:eligible]
110
+ Color.green("✅ ELIGIBLE")
111
+ elsif s[:errors].any?
112
+ Color.red("❌ DISQUALIFIED")
113
+ else
114
+ Color.yellow("⚠️ WARNINGS")
115
+ end
116
+
117
+ printf " %-4s %-22s %-32s %s\n", s[:index], s[:type], s[:feature][0..30], status_str
118
+
119
+ s[:errors].each do |err|
120
+ puts " #{Color.red("❌ Critical Error:")} #{err}"
121
+ end
122
+
123
+ s[:warnings].each do |warn|
124
+ puts " #{Color.yellow("⚠️ Warning:")} #{warn}"
125
+ end
126
+ end
127
+
128
+ puts "\n#{Color.bold("───────────────────────────────────────────────────────────────────────────")}"
129
+
130
+ # Show Patches if available
131
+ patches = res[:schemas].map { |s| s[:patch] }.compact
132
+ if patches.any?
133
+ puts "#{Color.bold("1-CLICK GOOGLE-COMPLIANT JSON-LD FIX:")}\n"
134
+ puts Color.cyan("<script type=\"application/ld+json\">")
135
+ puts Color.cyan(JSON.pretty_generate(patches.size == 1 ? patches.first : { '@context' => 'https://schema.org', '@graph' => patches }))
136
+ puts Color.cyan("</script>")
137
+ puts
138
+ end
139
+
140
+ puts "#{Color.bold("───────────────────────────────────────────────────────────────────────────")}"
141
+ puts "#{Color.bold("🌐 OFFICIAL GOOGLE RICH RESULTS TEST TOOL:")}"
142
+ puts " #{Color.cyan(rich_test_url)}"
143
+ if options[:open]
144
+ puts Color.green("\n🚀 Opening Google Rich Results Test in your browser...")
145
+ Base.open_in_browser(rich_test_url)
146
+ else
147
+ puts Color.gray(" 💡 Tip: Pass '--open' (or '-o') to launch this live test directly in your browser.")
148
+ end
149
+
150
+ puts "#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}\n"
151
+ end
152
+
153
+ def export_csv(res, file_path)
154
+ headers = %w[Index SchemaType Feature Eligible ErrorsCount WarningsCount ErrorsList WarningsList]
155
+ rows = res[:schemas].map do |s|
156
+ [
157
+ s[:index],
158
+ s[:type],
159
+ s[:feature],
160
+ s[:eligible] ? 'YES' : 'NO',
161
+ s[:errors].size,
162
+ s[:warnings].size,
163
+ s[:errors].join('; '),
164
+ s[:warnings].join('; ')
165
+ ]
166
+ end
167
+ Base.write_csv(file_path, headers, rows)
168
+ puts Color.cyan("📁 Successfully exported Rich Results validation data to #{file_path}")
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,149 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require 'cgi'
6
+ require 'uri'
7
+ require_relative 'base'
8
+ require_relative '../schema_generator'
9
+ require_relative '../color'
10
+
11
+ module GSC
12
+ class CLI
13
+ module SchemaGenerate
14
+ module_function
15
+
16
+ def run(command, target, extra, options)
17
+ generator = GSC::SchemaGenerator.new(options)
18
+ target_str = target.to_s.strip
19
+ if target_str !~ %r{^https?://} && target_str =~ /\.[a-z]{2,}/i
20
+ target_str = "https://#{target_str}"
21
+ end
22
+
23
+ result = if target_str =~ %r{^https?://}
24
+ generator.extract_from_url(target_str, options[:type])
25
+ elsif options[:type]
26
+ type = options[:type]
27
+ params = {
28
+ name: options[:name] || options[:headline] || target,
29
+ headline: options[:headline] || options[:name] || target,
30
+ price: options[:price],
31
+ currency: options[:currency] || 'USD',
32
+ brand: options[:brand_name] || (options[:brand].is_a?(String) ? options[:brand] : nil) || 'Brand',
33
+ rating: options[:rating],
34
+ reviews: options[:reviews],
35
+ author: options[:author],
36
+ q: options[:q],
37
+ a: options[:a],
38
+ url: options[:url] || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
39
+ }
40
+ generator.generate(type, params)
41
+ elsif target && !target.empty?
42
+ # Inferred from text target as an article headline
43
+ generator.generate('article', {
44
+ headline: target,
45
+ url: options[:url] || (Config.default_domain ? "https://#{Config.default_domain}" : nil)
46
+ })
47
+ else
48
+ if options[:json]
49
+ puts JSON.pretty_generate({ error: 'Target URL or schema type required. Example: gsc schema-generate https://example.com/product' })
50
+ else
51
+ puts Color.c("❌ Error: Target URL or schema type required.", Color::RED, Color::BOLD)
52
+ puts " Live Crawl Example: gsc schema-generate https://example.com/product"
53
+ puts " Type Direct Example: gsc schema-generate --type faq --q \"Question?\" --a \"Answer\""
54
+ puts " Supported Types: product, software, faq, howto, article, breadcrumb, course, job, event, localbusiness, video, recipe, organization"
55
+ end
56
+ return
57
+ end
58
+
59
+ target_page_url = target_str =~ %r{^https?://} ? target_str : (options[:url] || (Config.default_domain ? "https://#{Config.default_domain}" : nil))
60
+ rich_test_url = target_page_url ? "https://search.google.com/test/rich-results?url=#{CGI.escape(target_page_url)}" : "https://search.google.com/test/rich-results"
61
+
62
+ if options[:json]
63
+ result[:google_rich_results_test_url] = rich_test_url
64
+ puts JSON.pretty_generate(result)
65
+ return
66
+ end
67
+
68
+ render_schema_output(result, options, target_page_url, rich_test_url)
69
+ end
70
+
71
+ def render_schema_output(result, options, target_url, rich_test_url = "https://search.google.com/test/rich-results")
72
+ schema = result[:schema]
73
+ val = result[:validation]
74
+ snippets = result[:snippets]
75
+
76
+ puts "\n" + Color.cyan("╔" + "═" * 78 + "╗")
77
+ puts Color.cyan("║") + Color.bold(" ✨ STRUCTURED DATA RICH SNIPPET GENERATOR".ljust(78)) + Color.cyan("║")
78
+ puts Color.cyan("╚" + "═" * 78 + "╝")
79
+
80
+ puts "\n" + Color.bold("🎯 GENERATION SUMMARY:")
81
+ puts " 🌐 Source URL: #{Color.cyan(target_url || 'User provided parameters')}"
82
+ puts " 🏷️ Target Type: #{Color.yellow(schema['@type'].to_s)}"
83
+
84
+ val_badge = if val[:valid]
85
+ Color.green("✓ VALID FOR GOOGLE RICH RESULTS (Score: #{val[:score]}/100)")
86
+ else
87
+ Color.red("✗ VALIDATION ERRORS DETECTED (Score: #{val[:score]}/100)")
88
+ end
89
+ puts " 🛡️ Validation: #{val_badge}"
90
+
91
+ if val[:errors]&.any?
92
+ puts Color.red(" ⚠️ Critical Google Violations: #{val[:errors].join(', ')}")
93
+ end
94
+ if val[:warnings]&.any?
95
+ puts Color.yellow(" ℹ️ Recommended Enhancements: #{val[:warnings].join(', ')}")
96
+ end
97
+
98
+ # Duplicate & Existing Schema Analysis
99
+ existing = result[:existing_analysis]
100
+ if existing
101
+ if existing[:duplicate_detected]
102
+ puts "\n" + Color.c("⚠️ DUPLICATE PREVENTION ALERT:", Color::YELLOW, Color::BOLD)
103
+ puts " This page already contains a `<script type=\"application/ld+json\">` for " + Color.bold(existing[:matching_type].to_s) + "."
104
+ puts " " + Color.red("✗ Do NOT add this as a second duplicate tag.") + " Duplicate entity schemas confuse Googlebot and risk rich result disqualification."
105
+ puts " " + Color.green("✓ Recommended Action: Replace or update your existing #{existing[:matching_type]} schema with the snippet below.")
106
+ elsif existing[:existing_count] > 0
107
+ puts "\n" + Color.c("ℹ️ EXISTING STRUCTURED DATA DETECTED:", Color::CYAN, Color::BOLD)
108
+ puts " Page already has #{existing[:existing_count]} schema(s): " + Color.cyan(existing[:existing_types].join(', '))
109
+ puts " " + Color.green("✓ Adding #{schema['@type']} will complement your existing markup without conflict.")
110
+ else
111
+ puts "\n" + Color.c("✅ NO EXISTING SCHEMA DETECTED:", Color::GREEN, Color::BOLD)
112
+ puts " Page has no existing JSON-LD markup. Adding this snippet will establish rich snippet eligibility."
113
+ end
114
+ end
115
+
116
+ # Code Snippets
117
+ format_choice = (options[:format] || 'html').to_s.downcase
118
+
119
+ puts "\n" + Color.bold("📋 1-CLICK COPY-PASTE SNIPPET (#{format_choice.upcase}):")
120
+ case format_choice
121
+ when 'nextjs', 'react'
122
+ puts Color.cyan(snippets[:nextjs])
123
+ when 'shopify', 'liquid'
124
+ puts Color.cyan(snippets[:shopify])
125
+ when 'raw', 'json'
126
+ puts Color.dim(snippets[:raw_json])
127
+ else
128
+ puts Color.green(snippets[:html])
129
+ end
130
+
131
+ puts "\n" + Color.bold("💡 DEPLOYMENT GUIDANCE:")
132
+ if existing&.dig(:duplicate_detected)
133
+ puts Color.yellow(" ⚠️ REPLACE your existing #{existing[:matching_type]} <script> tag rather than adding a second one.")
134
+ else
135
+ puts Color.gray(" Paste this `<script>` directly into your page's `<head>` or body.")
136
+ end
137
+ puts Color.gray(" Test live in Google's Rich Results Tool: #{rich_test_url}")
138
+ if options[:open]
139
+ puts Color.green(" 🚀 Opening Google Rich Results Test in your browser...")
140
+ Base.open_in_browser(rich_test_url)
141
+ else
142
+ puts Color.gray(" Tip: Pass '--open' (or '-o') to launch this test directly in your browser.")
143
+ end
144
+ puts Color.gray(" Live GSC Verification: Run 'gsc inspect #{target_url || '<url>'}' for real Googlebot rich results crawl data.")
145
+ puts Color.cyan("═" * 80) + "\n"
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'base'
5
+ require_relative '../seasonal_predictor'
6
+ require_relative '../google_trends'
7
+
8
+ module GSC
9
+ class CLI
10
+ module Seasonal
11
+ module_function
12
+
13
+ def run(command, target, extra, options, api = nil, site_url = nil, hostname = nil)
14
+ predictor = GSC::SeasonalPredictor.new(target, options)
15
+
16
+ # Mode A: Keyword Deep-Dive (if target is provided and doesn't look like domain flag)
17
+ if target && !target.strip.empty? && target !~ /^https?:\/\// && target !~ /\.(com|org|net|io|app|co|dev|store)$/
18
+ handle_keyword_deep_dive(predictor, target, options)
19
+ else
20
+ # Mode B: GSC Portfolio Seasonal Radar (queries domain GSC data)
21
+ handle_domain_portfolio_radar(predictor, api, site_url, hostname, options)
22
+ end
23
+ end
24
+
25
+ def handle_keyword_deep_dive(predictor, keyword, options)
26
+ geo = options[:geo] || 'US'
27
+ time = options[:time] || '5y'
28
+
29
+ data = predictor.analyze_keyword(keyword, geo: geo, time: time)
30
+
31
+ if data[:ok] == false
32
+ if options[:json]
33
+ puts JSON.pretty_generate(data)
34
+ else
35
+ puts Color.c("\n⚠️ #{data[:error]}", Color::YELLOW, Color::BOLD)
36
+ end
37
+ return
38
+ end
39
+
40
+ if options[:json]
41
+ puts JSON.pretty_generate(data)
42
+ return
43
+ end
44
+
45
+ puts Base::BANNER unless options[:in_dashboard]
46
+ puts "🍁 #{Color::BOLD}SEASONAL DEMAND & 60-DAY SPIKE PREDICTOR:#{Color::RESET} #{Color.c(keyword, Color::CYAN, Color::BOLD)} [Geo: #{geo} | Window: #{time}]"
47
+ puts "─" * 80
48
+
49
+ pattern = data[:classification]
50
+ runway = data[:runway]
51
+ stats = data[:monthly_stats]
52
+
53
+ urgency_color = case runway[:urgency]
54
+ when 'CRITICAL' then Color::RED
55
+ when 'HIGH' then Color::YELLOW
56
+ when 'MODERATE' then Color::CYAN
57
+ else Color::GREEN
58
+ end
59
+
60
+ puts "🏷️ Seasonality Profile: #{Color.c(pattern[:label], Color::BOLD)}"
61
+ puts " #{Color::DIM}#{pattern[:description]}#{Color::RESET}"
62
+ puts "\n📈 Annual Baseline Score: #{Color.c(stats[:annual_baseline].to_s, Color::BOLD)} / 100"
63
+ puts "🏆 Historical Peak Month: #{Color.c(stats[:peak_month_name], Color::MAGENTA, Color::BOLD)} (Index: #{stats[:peak_index]} — #{stats[:peak_multiplier]}x normal volume)"
64
+ puts "❄️ Annual Trough Month: #{Color.c(stats[:trough_month_name], Color::BLUE)} (Index: #{stats[:trough_index]})"
65
+ puts
66
+
67
+ # Render 12-Month Calendar Bar Chart
68
+ puts "📅 #{Color::BOLD}12-MONTH SEASONAL TRAJECTORY (5-Year Historical Average):#{Color::RESET}"
69
+ puts predictor.render_ascii_calendar(stats, runway[:current_month])
70
+ puts
71
+
72
+ # 60-Day Runway Alarm
73
+ puts "⏱️ #{Color::BOLD}60-DAY GOOGLEBOT RUNWAY & OPTIMIZATION COUNTDOWN:#{Color::RESET}"
74
+ puts " • Current Month Status : #{Color.c(runway[:current_month_name], Color::BOLD)} (Index: #{runway[:current_month_index]})"
75
+ puts " • Next Major Spike : #{Color.c(runway[:spike_month_name], Color::MAGENTA, Color::BOLD)} (#{Color.c("#{runway[:spike_multiplier]}x surge", Color::BOLD)})"
76
+ puts " • Googlebot Cutoff Date: #{Color.c(runway[:publish_deadline], urgency_color, Color::BOLD)} (#{Color.c("#{runway[:days_to_deadline]} days remaining", urgency_color, Color::BOLD)})"
77
+ puts " • Strategic Urgency : [#{Color.c(runway[:urgency], urgency_color, Color::BOLD)}]"
78
+ puts "\n💡 #{Color::BOLD}Actionable Recommendation:#{Color::RESET}"
79
+ puts " #{Color.c(runway[:recommendation], Color::BOLD)}"
80
+ puts
81
+
82
+ # Tactical Pre-Spike SEO Checklist
83
+ puts "🛠️ #{Color::BOLD}PRE-SPIKE CAPTURE CHECKLIST (Publish 30 Days Before Peak):#{Color::RESET}"
84
+ puts " 1. [Titles]: Refresh title tag with current year hook (<580px desktop limit via `gsc titles`)."
85
+ puts " 2. [Headings]: Add high-intent FAQ questions under H2/H3 (`gsc questions \"#{keyword}\"`)."
86
+ puts " 3. [Internal Links]: Boost link equity by linking from top authority hubs (`gsc orphans`)."
87
+ puts " 4. [Schema]: Inject FAQPage and Product structured data for rich snippets (`gsc schema gen`)."
88
+ puts " 5. [Instant Crawl]: Trigger immediate re-indexation upon publication (`gsc index <url>`)."
89
+ puts "\n" + ("─" * 80) + "\n"
90
+ end
91
+
92
+ def handle_domain_portfolio_radar(predictor, api, site_url, hostname, options)
93
+ unless api
94
+ puts Color.c("\n❌ Error: Google Search Console authentication required for portfolio radar.", Color::RED, Color::BOLD)
95
+ puts " Run with keyword for single-term search: #{Color.c('gsc seasonal <keyword>', Color::CYAN)}"
96
+ puts " Or connect your domain via #{Color.c('gsc connect', Color::GREEN)}"
97
+ return
98
+ end
99
+
100
+ limit = options[:limit] || 25
101
+ days = options[:days] || 30
102
+ min_imp = options[:min_imp] || 15
103
+
104
+ puts Base::BANNER unless options[:json] || options[:in_dashboard]
105
+ puts "📡 #{Color::BOLD}SEASONAL PORTFOLIO RADAR (Top Queries for #{hostname}):#{Color::RESET}" unless options[:json]
106
+ puts " Scanning top GSC keywords for impending 30–60 day seasonal demand spikes...\n" unless options[:json]
107
+
108
+ # Fetch top queries
109
+ res = api.query_analytics(site_url, days: days, dimensions: ['query'], row_limit: [limit * 2, 50].max)
110
+ rows = res.is_a?(Hash) ? (res.dig(:data, 'rows') || res['rows'] || []) : []
111
+
112
+ filtered_rows = rows.select { |r| (r['impressions'] || 0) >= min_imp }
113
+
114
+ if filtered_rows.empty?
115
+ puts Color.c("ℹ️ No queries found meeting minimum impression threshold (#{min_imp}).", Color::YELLOW) unless options[:json]
116
+ return
117
+ end
118
+
119
+ curr_month = Date.today.month
120
+ portfolio_results = []
121
+
122
+ filtered_rows.first(limit).each do |r|
123
+ query_text = (r['keys'] || []).first.to_s.strip
124
+ next if query_text.empty?
125
+
126
+ clicks = (r['clicks'] || 0).to_i
127
+ impressions = (r['impressions'] || 0).to_i
128
+ position = (r['position'] || 0.0).to_f.round(1)
129
+
130
+ analysis = predictor.analyze_keyword(query_text)
131
+ runway = analysis[:runway]
132
+ pattern = analysis[:classification]
133
+ stats = analysis[:monthly_stats]
134
+
135
+ # Calculate Seasonal Striking-Distance ROI
136
+ roi = predictor.calculate_seasonal_roi(clicks, impressions, position, runway[:spike_multiplier])
137
+
138
+ portfolio_results << {
139
+ query: query_text,
140
+ clicks: clicks,
141
+ impressions: impressions,
142
+ position: position,
143
+ pattern: pattern[:pattern],
144
+ pattern_label: pattern[:label],
145
+ current_month_index: runway[:current_month_index],
146
+ peak_month_name: stats[:peak_month_name],
147
+ peak_multiplier: stats[:peak_multiplier],
148
+ spike_month_name: runway[:spike_month_name],
149
+ spike_multiplier: runway[:spike_multiplier],
150
+ is_spiking_soon: runway[:is_spiking_soon],
151
+ days_to_deadline: runway[:days_to_deadline],
152
+ urgency: runway[:urgency],
153
+ seasonal_monthly_click_gain: roi[:seasonal_monthly_click_gain],
154
+ peak_90d_click_harvest: roi[:peak_90d_click_harvest]
155
+ }
156
+ end
157
+
158
+ if options[:json]
159
+ puts JSON.pretty_generate({
160
+ domain: hostname,
161
+ audited_queries_count: portfolio_results.size,
162
+ queries: portfolio_results
163
+ })
164
+ return
165
+ end
166
+
167
+ if options[:csv]
168
+ csv_rows = portfolio_results.map do |p|
169
+ [
170
+ p[:query], p[:clicks], p[:impressions], p[:position],
171
+ p[:pattern], p[:peak_month_name], p[:peak_multiplier],
172
+ p[:spike_month_name], p[:spike_multiplier], p[:days_to_deadline],
173
+ p[:urgency], p[:seasonal_monthly_click_gain]
174
+ ]
175
+ end
176
+ headers = %w[Query Clicks Impressions Position Pattern PeakMonth PeakMultiplier SpikeMonth SpikeMultiplier DaysToDeadline Urgency ProjectedSeasonalClickGain]
177
+ Base.write_csv(options[:csv], headers, csv_rows)
178
+ puts Color.c("📁 Exported seasonal radar portfolio to #{options[:csv]}", Color::CYAN)
179
+ return
180
+ end
181
+
182
+ # Segment queries
183
+ spiking_soon = portfolio_results.select { |p| p[:is_spiking_soon] }.sort_by { |p| -p[:seasonal_monthly_click_gain] }
184
+ evergreen = portfolio_results.select { |p| p[:pattern] == 'EVERGREEN' }
185
+ cooling = portfolio_results.select { |p| p[:current_month_index] < 80 && !p[:is_spiking_soon] }
186
+
187
+ puts "─" * 90
188
+ puts "🚀 #{Color::BOLD}IMPENDING SEASONAL SPIKES (Next 30–60 Days — Immediate Optimization Window):#{Color::RESET}"
189
+ puts "─" * 90
190
+
191
+ if spiking_soon.empty?
192
+ puts " (No aggressive seasonal surges detected in the next 60 days across audited queries)"
193
+ else
194
+ puts format(
195
+ " %-30s | %-6s | %-8s | %-12s | %-8s | %-12s",
196
+ "SEARCH QUERY", "RANK", "SURGE", "PEAK MONTH", "DAYS LEFT", "PEAK GAIN"
197
+ )
198
+ puts " " + ("─" * 86)
199
+
200
+ spiking_soon.each do |s|
201
+ gain_str = "+#{s[:seasonal_monthly_click_gain]} clicks/mo"
202
+ urg_color = s[:days_to_deadline] <= 21 ? Color::RED : Color::YELLOW
203
+
204
+ puts format(
205
+ " %-30s | %-6s | %-8s | %-12s | %-8s | %-12s",
206
+ Color.c(s[:query][0..28].ljust(30), Color::BOLD),
207
+ "##{s[:position]}",
208
+ Color.c("#{s[:spike_multiplier]}x", Color::MAGENTA, Color::BOLD),
209
+ s[:spike_month_name],
210
+ Color.c("#{s[:days_to_deadline]}d", urg_color, Color::BOLD),
211
+ Color.c(gain_str, Color::GREEN, Color::BOLD)
212
+ )
213
+ end
214
+ end
215
+
216
+ puts "\n" + ("─" * 90)
217
+ puts "❄️ #{Color::BOLD}ENTERING SEASONAL TROUGH (Cooling Demand — False Alarm Defense):#{Color::RESET}"
218
+ puts "─" * 90
219
+
220
+ if cooling.empty?
221
+ puts " (No audited queries currently entering severe seasonal troughs)"
222
+ else
223
+ cooling.first(5).each do |c|
224
+ puts " • \"#{Color.c(c[:query], Color::BOLD)}\" (SI: #{c[:current_month_index]} — Normal seasonal lull. Do NOT panic if clicks drop)."
225
+ end
226
+ end
227
+
228
+ puts "\n" + ("─" * 90) + "\n"
229
+ end
230
+ end
231
+ end
232
+ end