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.
- checksums.yaml +4 -4
- data/AUTH.md +4 -1
- data/README.md +448 -408
- 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
data/lib/gsc/schema_validator.rb
CHANGED
|
@@ -2,11 +2,28 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require 'json'
|
|
5
|
+
require_relative 'schema_generator'
|
|
5
6
|
|
|
6
7
|
module GSC
|
|
7
8
|
class SchemaValidator
|
|
8
9
|
attr_reader :url, :schemas, :validation_results
|
|
9
10
|
|
|
11
|
+
AVAILABLE_TEMPLATES = [
|
|
12
|
+
{ type: 'faq', name: 'FAQPage', feature: 'Interactive FAQ Accordion Drop-down' },
|
|
13
|
+
{ type: 'product', name: 'Product', feature: 'Merchant Rich Card, Star Ratings & Prices' },
|
|
14
|
+
{ type: 'software', name: 'SoftwareApplication', feature: 'App Card, OS Badge, Pricing & Rating' },
|
|
15
|
+
{ type: 'breadcrumb', name: 'BreadcrumbList', feature: 'Hierarchical SERP Navigation Path' },
|
|
16
|
+
{ type: 'article', name: 'Article', feature: 'Google Top Stories Carousel & Author E-E-A-T' },
|
|
17
|
+
{ type: 'howto', name: 'HowTo', feature: 'Step-by-Step Guided Instructions' },
|
|
18
|
+
{ type: 'course', name: 'Course', feature: 'Course Carousel & Provider Card' },
|
|
19
|
+
{ type: 'job', name: 'JobPosting', feature: 'Google for Jobs Interactive Listing' },
|
|
20
|
+
{ type: 'event', name: 'Event', feature: 'Event Schedule, Venue & Ticket Offers' },
|
|
21
|
+
{ type: 'localbusiness', name: 'LocalBusiness', feature: 'Google Maps & Local 3-Pack with Hours/Phone' },
|
|
22
|
+
{ type: 'video', name: 'VideoObject', feature: 'Video SERP Thumbnail & Key Moments' },
|
|
23
|
+
{ type: 'recipe', name: 'Recipe', feature: 'Recipe Card with Cook Time & Nutrition' },
|
|
24
|
+
{ type: 'organization', name: 'Organization', feature: 'Brand Knowledge Graph Panel & Logo' }
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
10
27
|
def initialize(url)
|
|
11
28
|
@url = url.to_s.strip
|
|
12
29
|
end
|
|
@@ -14,7 +31,8 @@ module GSC
|
|
|
14
31
|
def audit
|
|
15
32
|
pa = GSC::PageAnalyzer.new(@url)
|
|
16
33
|
data = pa.fetch_and_analyze
|
|
17
|
-
|
|
34
|
+
raw_schemas = data[:schema] || data.dig(:structured_data, :schemas) || []
|
|
35
|
+
@schemas = self.class.flatten_schemas(raw_schemas)
|
|
18
36
|
|
|
19
37
|
results = []
|
|
20
38
|
@schemas.each_with_index do |schema, idx|
|
|
@@ -28,6 +46,22 @@ module GSC
|
|
|
28
46
|
}
|
|
29
47
|
end
|
|
30
48
|
|
|
49
|
+
def self.flatten_schemas(items)
|
|
50
|
+
return [] unless items.is_a?(Array)
|
|
51
|
+
items.flat_map do |item|
|
|
52
|
+
target = item.is_a?(Hash) && (item[:data] || item['data']) ? (item[:data] || item['data']) : item
|
|
53
|
+
if target.is_a?(Hash) && target['@graph'].is_a?(Array)
|
|
54
|
+
flatten_schemas(target['@graph'])
|
|
55
|
+
elsif target.is_a?(Array)
|
|
56
|
+
flatten_schemas(target)
|
|
57
|
+
elsif target.is_a?(Hash)
|
|
58
|
+
[target]
|
|
59
|
+
else
|
|
60
|
+
[]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
31
65
|
def validate_single_schema(schema, idx)
|
|
32
66
|
type = schema['@type'] || 'Unknown'
|
|
33
67
|
errors = []
|
|
@@ -80,43 +114,7 @@ module GSC
|
|
|
80
114
|
end
|
|
81
115
|
|
|
82
116
|
def self.generate_template(type, params = {})
|
|
83
|
-
|
|
84
|
-
when 'faq'
|
|
85
|
-
{
|
|
86
|
-
'@context' => 'https://schema.org',
|
|
87
|
-
'@type' => 'FAQPage',
|
|
88
|
-
'mainEntity' => [
|
|
89
|
-
{
|
|
90
|
-
'@type' => 'Question',
|
|
91
|
-
'name' => params[:question] || 'What is PackingLog?',
|
|
92
|
-
'acceptedAnswer' => {
|
|
93
|
-
'@type' => 'Answer',
|
|
94
|
-
'text' => params[:answer] || 'PackingLog is a free moving box and inventory management system.'
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
]
|
|
98
|
-
}
|
|
99
|
-
when 'software', 'app'
|
|
100
|
-
{
|
|
101
|
-
'@context' => 'https://schema.org',
|
|
102
|
-
'@type' => 'SoftwareApplication',
|
|
103
|
-
'name' => params[:name] || 'PackingLog',
|
|
104
|
-
'applicationCategory' => params[:category] || 'UtilitiesApplication',
|
|
105
|
-
'operatingSystem' => 'Web, iOS, Android',
|
|
106
|
-
'offers' => {
|
|
107
|
-
'@type' => 'Offer',
|
|
108
|
-
'price' => '0',
|
|
109
|
-
'priceCurrency' => 'USD'
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
else
|
|
113
|
-
{
|
|
114
|
-
'@context' => 'https://schema.org',
|
|
115
|
-
'@type' => 'Organization',
|
|
116
|
-
'name' => params[:name] || 'PackingLog',
|
|
117
|
-
'url' => params[:url] || 'https://packinglog.com'
|
|
118
|
-
}
|
|
119
|
-
end
|
|
117
|
+
GSC::SchemaGenerator.new.generate(type, params)[:schema]
|
|
120
118
|
end
|
|
121
119
|
end
|
|
122
120
|
end
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'json'
|
|
6
|
+
require_relative 'color'
|
|
7
|
+
require_relative 'google_trends'
|
|
8
|
+
|
|
9
|
+
module GSC
|
|
10
|
+
class SeasonalPredictor
|
|
11
|
+
MONTH_NAMES = %w[Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :keyword_or_domain, :options
|
|
14
|
+
|
|
15
|
+
def initialize(keyword_or_domain = nil, options = {})
|
|
16
|
+
@keyword_or_domain = keyword_or_domain
|
|
17
|
+
@options = options || {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Analyze single keyword seasonality using 5-year trends data
|
|
21
|
+
def analyze_keyword(keyword, geo: 'US', time: '5y')
|
|
22
|
+
trends_data = GoogleTrends.fetch(keyword, geo: geo, time: time)
|
|
23
|
+
points = trends_data[:points] || []
|
|
24
|
+
|
|
25
|
+
# If live trends returns empty or fails, report genuine status
|
|
26
|
+
if points.empty?
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
error: "Google Trends data currently unavailable or rate-limited for '#{keyword}'.",
|
|
30
|
+
keyword: keyword,
|
|
31
|
+
geo: geo,
|
|
32
|
+
time: time,
|
|
33
|
+
points_count: 0
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
monthly_stats = decompose_monthly_seasonality(points)
|
|
38
|
+
runway = detect_runway_and_deadlines(monthly_stats)
|
|
39
|
+
classification = classify_seasonality_pattern(monthly_stats)
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
ok: true,
|
|
43
|
+
keyword: keyword,
|
|
44
|
+
geo: geo,
|
|
45
|
+
time: time,
|
|
46
|
+
sparkline: trends_data[:sparkline],
|
|
47
|
+
monthly_stats: monthly_stats,
|
|
48
|
+
runway: runway,
|
|
49
|
+
classification: classification,
|
|
50
|
+
points_count: points.size
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Group time series points into 12 calendar months and compute Seasonality Index (SI)
|
|
55
|
+
def decompose_monthly_seasonality(points)
|
|
56
|
+
buckets = Hash.new { |h, k| h[k] = [] }
|
|
57
|
+
|
|
58
|
+
points.each do |pt|
|
|
59
|
+
d_str = pt[:date].to_s
|
|
60
|
+
score = pt[:score].to_i
|
|
61
|
+
|
|
62
|
+
# Parse month from formatted date e.g. "2023-11-15", "Nov 2023", or Unix timestamp
|
|
63
|
+
month = parse_month_number(d_str)
|
|
64
|
+
buckets[month] << score if month && month >= 1 && month <= 12
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Ensure all 12 months exist
|
|
68
|
+
1.upto(12) { |m| buckets[m] = [50] if buckets[m].empty? }
|
|
69
|
+
|
|
70
|
+
month_averages = {}
|
|
71
|
+
buckets.each do |m, scores|
|
|
72
|
+
month_averages[m] = scores.sum.to_f / [scores.size, 1].max
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
annual_baseline = month_averages.values.sum.to_f / 12.0
|
|
76
|
+
annual_baseline = 1.0 if annual_baseline.zero?
|
|
77
|
+
|
|
78
|
+
monthly_indices = {}
|
|
79
|
+
1.upto(12) do |m|
|
|
80
|
+
avg_score = month_averages[m]
|
|
81
|
+
si = ((avg_score / annual_baseline) * 100).round
|
|
82
|
+
monthly_indices[m] = {
|
|
83
|
+
month: m,
|
|
84
|
+
month_name: MONTH_NAMES[m - 1],
|
|
85
|
+
avg_score: avg_score.round(1),
|
|
86
|
+
seasonality_index: si,
|
|
87
|
+
relative_multiplier: (si / 100.0).round(2),
|
|
88
|
+
sample_points: buckets[m].size
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Identify peak and trough months
|
|
93
|
+
peak_m = monthly_indices.values.max_by { |v| v[:seasonality_index] }
|
|
94
|
+
trough_m = monthly_indices.values.min_by { |v| v[:seasonality_index] }
|
|
95
|
+
|
|
96
|
+
{
|
|
97
|
+
months: monthly_indices,
|
|
98
|
+
annual_baseline: annual_baseline.round(1),
|
|
99
|
+
peak_month: peak_m[:month],
|
|
100
|
+
peak_month_name: peak_m[:month_name],
|
|
101
|
+
peak_index: peak_m[:seasonality_index],
|
|
102
|
+
peak_multiplier: peak_m[:relative_multiplier],
|
|
103
|
+
trough_month: trough_m[:month],
|
|
104
|
+
trough_month_name: trough_m[:month_name],
|
|
105
|
+
trough_index: trough_m[:seasonality_index]
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The 60-Day Runway & Publish Deadline Calculator
|
|
110
|
+
def detect_runway_and_deadlines(monthly_stats, current_date = Date.today)
|
|
111
|
+
curr_m = current_date.month
|
|
112
|
+
curr_y = current_date.year
|
|
113
|
+
months = monthly_stats[:months]
|
|
114
|
+
|
|
115
|
+
upcoming_3m = [1, 2, 3].map do |offset|
|
|
116
|
+
target_m = ((curr_m - 1 + offset) % 12) + 1
|
|
117
|
+
months[target_m]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Find highest impending surge
|
|
121
|
+
spike_target = upcoming_3m.find { |m| m[:seasonality_index] >= 125 } || upcoming_3m.max_by { |m| m[:seasonality_index] }
|
|
122
|
+
is_significant_spike = spike_target[:seasonality_index] >= 120
|
|
123
|
+
|
|
124
|
+
target_month_num = spike_target[:month]
|
|
125
|
+
months_away = ((target_month_num - curr_m) % 12)
|
|
126
|
+
months_away = 12 if months_away.zero?
|
|
127
|
+
|
|
128
|
+
# Calculate estimated peak start date
|
|
129
|
+
target_year = (curr_m + months_away > 12) ? curr_y + 1 : curr_y
|
|
130
|
+
peak_start_date = Date.new(target_year, target_month_num, 1)
|
|
131
|
+
|
|
132
|
+
# Googlebot indexing runway: recommend publishing 35 days before peak starts
|
|
133
|
+
publish_deadline = peak_start_date - 35
|
|
134
|
+
days_to_deadline = (publish_deadline - current_date).to_i
|
|
135
|
+
|
|
136
|
+
urgency = if days_to_deadline <= 14
|
|
137
|
+
'CRITICAL'
|
|
138
|
+
elsif days_to_deadline <= 45
|
|
139
|
+
'HIGH'
|
|
140
|
+
elsif days_to_deadline <= 75
|
|
141
|
+
'MODERATE'
|
|
142
|
+
else
|
|
143
|
+
'EARLY_PLANNING'
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
{
|
|
147
|
+
current_month: curr_m,
|
|
148
|
+
current_month_name: MONTH_NAMES[curr_m - 1],
|
|
149
|
+
current_month_index: months[curr_m][:seasonality_index],
|
|
150
|
+
is_spiking_soon: is_significant_spike,
|
|
151
|
+
spike_month: target_month_num,
|
|
152
|
+
spike_month_name: spike_target[:month_name],
|
|
153
|
+
spike_index: spike_target[:seasonality_index],
|
|
154
|
+
spike_multiplier: spike_target[:relative_multiplier],
|
|
155
|
+
months_away: months_away,
|
|
156
|
+
peak_start_date: peak_start_date.iso8601,
|
|
157
|
+
publish_deadline: publish_deadline.iso8601,
|
|
158
|
+
days_to_deadline: [days_to_deadline, 0].max,
|
|
159
|
+
urgency: urgency,
|
|
160
|
+
recommendation: generate_action_recommendation(urgency, spike_target[:month_name], days_to_deadline, spike_target[:relative_multiplier])
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Classify seasonality profile
|
|
165
|
+
def classify_seasonality_pattern(monthly_stats)
|
|
166
|
+
peak_m = monthly_stats[:peak_month]
|
|
167
|
+
peak_idx = monthly_stats[:peak_index]
|
|
168
|
+
trough_idx = monthly_stats[:trough_index]
|
|
169
|
+
spread = peak_idx - trough_idx
|
|
170
|
+
|
|
171
|
+
if spread < 30
|
|
172
|
+
{
|
|
173
|
+
pattern: 'EVERGREEN',
|
|
174
|
+
label: '🌲 Evergreen / Consistent Demand',
|
|
175
|
+
description: 'Steady year-round search volume with minimal cyclical variation (<30% swing).'
|
|
176
|
+
}
|
|
177
|
+
elsif [10, 11, 12].include?(peak_m)
|
|
178
|
+
{
|
|
179
|
+
pattern: 'Q4_HOLIDAY',
|
|
180
|
+
label: '🎁 Q4 Holiday & Black Friday Peak',
|
|
181
|
+
description: 'Massive surge in November/December driven by holiday gift buying and year-end budgets.'
|
|
182
|
+
}
|
|
183
|
+
elsif [6, 7, 8].include?(peak_m)
|
|
184
|
+
{
|
|
185
|
+
pattern: 'Q3_SUMMER',
|
|
186
|
+
label: '☀️ Summer Surge (Q3)',
|
|
187
|
+
description: 'High summer demand peaking June–August; cooling rapidly by September.'
|
|
188
|
+
}
|
|
189
|
+
elsif [1, 2].include?(peak_m)
|
|
190
|
+
{
|
|
191
|
+
pattern: 'Q1_NEW_YEAR',
|
|
192
|
+
label: '🎯 New Year & Resolution Spike (Q1)',
|
|
193
|
+
description: 'January/February surge driven by new year resolutions, renewals, or fresh fiscal budgets.'
|
|
194
|
+
}
|
|
195
|
+
elsif [3, 4, 5].include?(peak_m)
|
|
196
|
+
{
|
|
197
|
+
pattern: 'Q2_SPRING',
|
|
198
|
+
label: '🌱 Spring Refresh & Tax Season (Q2)',
|
|
199
|
+
description: 'Demand rises through spring, peaking March through May.'
|
|
200
|
+
}
|
|
201
|
+
else
|
|
202
|
+
{
|
|
203
|
+
pattern: 'CYCLICAL',
|
|
204
|
+
label: '🔄 Seasonal Cyclical Demand',
|
|
205
|
+
description: "Distinct peak concentrated around #{monthly_stats[:peak_month_name]} (#{peak_idx} SI)."
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# False-Flag Penalty vs Seasonal Trough Detector
|
|
211
|
+
def diagnose_traffic_drop(query, gsc_change_pct, current_month = Date.today.month, monthly_stats = nil)
|
|
212
|
+
stats = monthly_stats || analyze_keyword(query)[:monthly_stats]
|
|
213
|
+
months = stats[:months]
|
|
214
|
+
prev_m = current_month == 1 ? 12 : current_month - 1
|
|
215
|
+
|
|
216
|
+
curr_si = months[current_month][:seasonality_index] || 1.0
|
|
217
|
+
prev_si = months[prev_m][:seasonality_index] || 1.0
|
|
218
|
+
expected_seasonal_change = prev_si.to_f > 0 ? (((curr_si - prev_si).to_f / prev_si) * 100).round : 0
|
|
219
|
+
|
|
220
|
+
# If traffic dropped and historical trends drop similarly
|
|
221
|
+
is_seasonal_trough = (gsc_change_pct < -15 && expected_seasonal_change < -15)
|
|
222
|
+
deviation = (gsc_change_pct - expected_seasonal_change).round
|
|
223
|
+
|
|
224
|
+
verdict = if is_seasonal_trough && deviation.abs <= 25
|
|
225
|
+
:seasonal_cycle
|
|
226
|
+
elsif gsc_change_pct < -25 && expected_seasonal_change >= -10
|
|
227
|
+
:potential_algorithmic_penalty
|
|
228
|
+
elsif gsc_change_pct > 15 && expected_seasonal_change <= 0
|
|
229
|
+
:outperforming_seasonality
|
|
230
|
+
else
|
|
231
|
+
:normal_variance
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
{
|
|
235
|
+
query: query,
|
|
236
|
+
gsc_change_pct: gsc_change_pct,
|
|
237
|
+
expected_seasonal_change_pct: expected_seasonal_change,
|
|
238
|
+
deviation_pct: deviation,
|
|
239
|
+
verdict: verdict,
|
|
240
|
+
explanation: format_verdict_explanation(verdict, query, gsc_change_pct, expected_seasonal_change)
|
|
241
|
+
}
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Seasonal Striking-Distance ROI Multiplier
|
|
245
|
+
# Combines CTR Curve from Turn 4 with Seasonal Surge Multipliers
|
|
246
|
+
def calculate_seasonal_roi(gsc_clicks, gsc_impressions, gsc_position, peak_multiplier, target_pos: 3)
|
|
247
|
+
curr_pos = [gsc_position.to_f, 1.0].max
|
|
248
|
+
target = [target_pos.to_f, 1.0].max
|
|
249
|
+
|
|
250
|
+
# Turn 4 empirical CTR Curve model
|
|
251
|
+
ctr_curve = {
|
|
252
|
+
1 => 0.285, 2 => 0.157, 3 => 0.110, 4 => 0.080, 5 => 0.055,
|
|
253
|
+
6 => 0.042, 7 => 0.032, 8 => 0.024, 9 => 0.019, 10 => 0.014
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
curr_ctr = ctr_curve[curr_pos.round] || [0.01, (1.0 / (curr_pos * 8))].max
|
|
257
|
+
target_ctr = ctr_curve[target.round] || 0.110
|
|
258
|
+
|
|
259
|
+
# Standard 30-day baseline projection
|
|
260
|
+
baseline_lift = gsc_impressions * (target_ctr - curr_ctr)
|
|
261
|
+
baseline_lift = [baseline_lift, 0.0].max
|
|
262
|
+
|
|
263
|
+
# Seasonal Peak Surge Multiplier
|
|
264
|
+
peak_monthly_clicks = (baseline_lift * peak_multiplier).round
|
|
265
|
+
peak_90d_clicks = (peak_monthly_clicks * 2.5).round # Peak surge lasts ~2.5 months
|
|
266
|
+
|
|
267
|
+
{
|
|
268
|
+
current_position: curr_pos.round(1),
|
|
269
|
+
target_position: target.round,
|
|
270
|
+
current_ctr_pct: (curr_ctr * 100).round(2),
|
|
271
|
+
target_ctr_pct: (target_ctr * 100).round(2),
|
|
272
|
+
baseline_monthly_click_gain: baseline_lift.round,
|
|
273
|
+
peak_surge_multiplier: peak_multiplier,
|
|
274
|
+
seasonal_monthly_click_gain: peak_monthly_clicks,
|
|
275
|
+
peak_90d_click_harvest: peak_90d_clicks,
|
|
276
|
+
roi_multiplier: peak_multiplier
|
|
277
|
+
}
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Render a 12-Month ASCII Calendar Bar Chart
|
|
281
|
+
def render_ascii_calendar(monthly_stats, current_month = Date.today.month)
|
|
282
|
+
months = monthly_stats[:months]
|
|
283
|
+
peak_m = monthly_stats[:peak_month]
|
|
284
|
+
trough_m = monthly_stats[:trough_month]
|
|
285
|
+
|
|
286
|
+
out = []
|
|
287
|
+
out << " #{Color::BOLD}MONTH SEASONALITY INDEX (SI) DEMAND TRAJECTORY#{Color::RESET}"
|
|
288
|
+
out << " " + ("─" * 65)
|
|
289
|
+
|
|
290
|
+
1.upto(12) do |m|
|
|
291
|
+
info = months[m]
|
|
292
|
+
si = info[:seasonality_index]
|
|
293
|
+
name = info[:month_name]
|
|
294
|
+
|
|
295
|
+
# Max bar width 28 chars (where 100 SI = 14 chars)
|
|
296
|
+
bar_len = [((si / 200.0) * 28).round, 1].max
|
|
297
|
+
bar_len = [bar_len, 32].min
|
|
298
|
+
|
|
299
|
+
is_current = (m == current_month)
|
|
300
|
+
is_peak = (m == peak_m)
|
|
301
|
+
is_trough = (m == trough_m)
|
|
302
|
+
|
|
303
|
+
bar_char = "█"
|
|
304
|
+
bar_str = bar_char * bar_len
|
|
305
|
+
|
|
306
|
+
colored_bar = if is_peak
|
|
307
|
+
Color.c(bar_str, Color::MAGENTA, Color::BOLD)
|
|
308
|
+
elsif si >= 125
|
|
309
|
+
Color.c(bar_str, Color::RED, Color::BOLD)
|
|
310
|
+
elsif si >= 100
|
|
311
|
+
Color.c(bar_str, Color::GREEN)
|
|
312
|
+
elsif is_trough
|
|
313
|
+
Color.c(bar_str, Color::BLUE)
|
|
314
|
+
else
|
|
315
|
+
Color.c(bar_str, Color::YELLOW)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
badge = []
|
|
319
|
+
badge << Color.c("👈 CURRENT", Color::CYAN, Color::BOLD) if is_current
|
|
320
|
+
badge << Color.c("🏆 PEAK (#{info[:relative_multiplier]}x)", Color::MAGENTA, Color::BOLD) if is_peak
|
|
321
|
+
badge << Color.c("❄️ TROUGH", Color::BLUE) if is_trough && !is_peak
|
|
322
|
+
|
|
323
|
+
si_formatted = si.to_s.rjust(3)
|
|
324
|
+
m_display = is_current ? Color.c(name, Color::CYAN, Color::BOLD) : name
|
|
325
|
+
|
|
326
|
+
out << " #{m_display.ljust(6)} #{Color.c(si_formatted, Color::BOLD)} | #{colored_bar.ljust(35)} #{badge.join(' ')}"
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
out << " " + ("─" * 65)
|
|
330
|
+
out << " #{Color::DIM}100 = Annual Average Baseline | >125 = Surge Season | <75 = Low Trough#{Color::RESET}"
|
|
331
|
+
out.join("\n")
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
private
|
|
335
|
+
|
|
336
|
+
def parse_month_number(date_str)
|
|
337
|
+
# Format: "YYYY-MM-DD"
|
|
338
|
+
if date_str =~ /^\d{4}-(\d{2})-\d{2}/
|
|
339
|
+
return $1.to_i
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Format: "Jan 2023" or "Nov 15, 2023"
|
|
343
|
+
MONTH_NAMES.each_with_index do |name, idx|
|
|
344
|
+
return idx + 1 if date_str.downcase.include?(name.downcase)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Epoch timestamp fallback
|
|
348
|
+
if date_str =~ /^\d{10,}$/
|
|
349
|
+
return Time.at(date_str.to_i).utc.month rescue 1
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
nil
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def generate_action_recommendation(urgency, spike_month, days_left, multiplier)
|
|
356
|
+
case urgency
|
|
357
|
+
when 'CRITICAL'
|
|
358
|
+
"🚨 URGENT: Demand spikes in #{spike_month} (#{multiplier}x surge)! Only #{days_left} days before Googlebot indexing cutoff. Publish updated headings, FAQ schema, and internal links immediately."
|
|
359
|
+
when 'HIGH'
|
|
360
|
+
"⚡ OPTIMIZE NOW: #{days_left} days remaining until freeze. Begin title tag rewrites and expand product/landing page content before the #{spike_month} #{multiplier}x surge."
|
|
361
|
+
when 'MODERATE'
|
|
362
|
+
"📅 PREPARE BRIEFS: #{days_left} days left. Finalize seasonal keyword targets and schedule new content to publish 30 days ahead of the #{spike_month} peak."
|
|
363
|
+
else
|
|
364
|
+
"🌲 EVERGREEN/MONITOR: Steady demand ahead. Review quarterly to ensure stable rankings."
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def format_verdict_explanation(verdict, query, gsc_change, expected_change)
|
|
369
|
+
case verdict
|
|
370
|
+
when :seasonal_cycle
|
|
371
|
+
"✅ False alarm! GSC clicks dropped #{gsc_change}%, matching the expected seasonal trough of #{expected_change}%. Do NOT panic or rewrite URLs; demand naturally rebounds."
|
|
372
|
+
when :potential_algorithmic_penalty
|
|
373
|
+
"⚠️ Potential ranking loss detected: GSC traffic plunged #{gsc_change}%, but historical search volume only dipped #{expected_change}%. Investigate search positions and technical crawl logs."
|
|
374
|
+
when :outperforming_seasonality
|
|
375
|
+
"🚀 Remarkable organic gain: GSC clicks climbed +#{gsc_change}% despite a flat/cooling seasonal macro trend (#{expected_change}%). Your market share is expanding!"
|
|
376
|
+
else
|
|
377
|
+
"ℹ️ Normal traffic variance within standard expected boundaries (#{gsc_change}% vs #{expected_change}% seasonal expectation)."
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|