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.
- checksums.yaml +4 -4
- data/README.md +410 -414
- data/bin/gsc +28067 -5661
- data/dist/gsc +29121 -5046
- data/lib/gsc/aio_hunter.rb +343 -0
- data/lib/gsc/answer_synthesizer.rb +157 -0
- data/lib/gsc/api.rb +53 -1
- data/lib/gsc/auth.rb +26 -0
- data/lib/gsc/brand_segmenter.rb +140 -0
- data/lib/gsc/cache_manager.rb +806 -0
- data/lib/gsc/cannibalization_analyzer.rb +141 -0
- data/lib/gsc/canonical_chains.rb +367 -0
- data/lib/gsc/citation_simulator.rb +339 -0
- data/lib/gsc/cli/aio_hunter.rb +154 -0
- data/lib/gsc/cli/analytics.rb +788 -0
- data/lib/gsc/cli/audit.rb +1976 -0
- data/lib/gsc/cli/base.rb +384 -0
- data/lib/gsc/cli/cache.rb +266 -0
- data/lib/gsc/cli/canonical.rb +223 -0
- data/lib/gsc/cli/citation_simulator.rb +152 -0
- data/lib/gsc/cli/dashboard.rb +354 -0
- data/lib/gsc/cli/doctor.rb +129 -0
- data/lib/gsc/cli/eeat.rb +125 -0
- data/lib/gsc/cli/ga4.rb +852 -0
- data/lib/gsc/cli/growth.rb +650 -0
- data/lib/gsc/cli/hreflang.rb +164 -0
- data/lib/gsc/cli/image_seo.rb +162 -0
- data/lib/gsc/cli/indexing.rb +458 -0
- data/lib/gsc/cli/intent_shift.rb +125 -0
- data/lib/gsc/cli/keyword_value.rb +134 -0
- data/lib/gsc/cli/keywords.rb +795 -0
- data/lib/gsc/cli/landing_roi.rb +308 -0
- data/lib/gsc/cli/low_ctr.rb +213 -0
- data/lib/gsc/cli/mobile_parity.rb +150 -0
- data/lib/gsc/cli/report.rb +100 -0
- data/lib/gsc/cli/rich_results.rb +172 -0
- data/lib/gsc/cli/schema_generate.rb +149 -0
- data/lib/gsc/cli/seasonal.rb +232 -0
- data/lib/gsc/cli/security.rb +153 -0
- data/lib/gsc/cli/setup.rb +1291 -0
- data/lib/gsc/cli/sitemap_tree.rb +143 -0
- data/lib/gsc/cli/skill_pack.rb +62 -0
- data/lib/gsc/cli/soft_404.rb +199 -0
- data/lib/gsc/cli/sparkline.rb +227 -0
- data/lib/gsc/cli/watchdog.rb +150 -0
- data/lib/gsc/cli/zombie_purger.rb +208 -0
- data/lib/gsc/cli.rb +707 -5265
- data/lib/gsc/cli_advanced.rb +987 -44
- data/lib/gsc/client.rb +17 -2
- data/lib/gsc/color.rb +16 -1
- data/lib/gsc/command_registry.rb +47 -9
- data/lib/gsc/config.rb +2 -2
- data/lib/gsc/ctr_curve.rb +115 -0
- data/lib/gsc/decay_predictor.rb +322 -0
- data/lib/gsc/doctor.rb +434 -0
- data/lib/gsc/eeat_auditor.rb +428 -0
- data/lib/gsc/entity_auditor.rb +229 -0
- data/lib/gsc/firewall_scanner.rb +733 -0
- data/lib/gsc/geo_auditor.rb +368 -0
- data/lib/gsc/google_trends.rb +8 -1
- data/lib/gsc/heading_validator.rb +283 -0
- data/lib/gsc/hreflang_validator.rb +412 -0
- data/lib/gsc/image_seo.rb +286 -0
- data/lib/gsc/indexing_queue.rb +179 -0
- data/lib/gsc/indexnow.rb +93 -0
- data/lib/gsc/intent_shift.rb +188 -0
- data/lib/gsc/internal_links.rb +153 -36
- data/lib/gsc/keyword_value.rb +191 -0
- data/lib/gsc/landing_roi.rb +195 -0
- data/lib/gsc/llms_generator.rb +343 -22
- data/lib/gsc/low_ctr_rewriter.rb +408 -0
- data/lib/gsc/mobile_parity.rb +222 -0
- data/lib/gsc/network_tracer.rb +8 -1
- data/lib/gsc/page_analyzer.rb +47 -7
- data/lib/gsc/prompts.rb +38 -29
- data/lib/gsc/questions_harvester.rb +178 -0
- data/lib/gsc/report_generator.rb +461 -0
- data/lib/gsc/rich_results.rb +388 -0
- data/lib/gsc/robots_checker.rb +46 -15
- data/lib/gsc/schema_generator.rb +788 -0
- data/lib/gsc/schema_validator.rb +36 -38
- data/lib/gsc/seasonal_predictor.rb +381 -0
- data/lib/gsc/security_scanner.rb +496 -0
- data/lib/gsc/serp_feature_detector.rb +359 -0
- data/lib/gsc/serp_preview.rb +108 -22
- data/lib/gsc/site_crawler.rb +113 -21
- data/lib/gsc/sitemap_loader.rb +15 -4
- data/lib/gsc/sitemap_tree.rb +301 -0
- data/lib/gsc/skill_pack.rb +195 -0
- data/lib/gsc/soft_404_analyzer.rb +385 -0
- data/lib/gsc/sparkline.rb +171 -0
- data/lib/gsc/speed_correlator.rb +416 -0
- data/lib/gsc/striking_playbook.rb +190 -0
- data/lib/gsc/title_optimizer.rb +420 -0
- data/lib/gsc/vault.rb +260 -0
- data/lib/gsc/version.rb +1 -1
- data/lib/gsc/watchdog.rb +235 -0
- data/lib/gsc/zombie_purger.rb +366 -0
- data/lib/gsc.rb +118 -0
- 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
|