gsc-cli 2.0.2 → 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/AUTH.md +205 -0
- data/FUNDING.md +120 -0
- data/README.md +463 -299
- data/bin/gsc +29158 -4921
- data/dist/gsc +29158 -4921
- 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/backlinks_manager.rb +96 -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 +706 -5111
- data/lib/gsc/cli_advanced.rb +1513 -0
- 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 +11 -2
- data/lib/gsc/content_gap.rb +112 -0
- 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_suggest.rb +109 -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 +249 -0
- data/lib/gsc/keyword_value.rb +191 -0
- data/lib/gsc/landing_roi.rb +195 -0
- data/lib/gsc/llms_generator.rb +425 -0
- data/lib/gsc/low_ctr_rewriter.rb +408 -0
- data/lib/gsc/mobile_parity.rb +222 -0
- data/lib/gsc/network_tracer.rb +93 -0
- data/lib/gsc/open_page_rank.rb +72 -0
- data/lib/gsc/page_analyzer.rb +47 -7
- data/lib/gsc/page_comparator.rb +108 -0
- data/lib/gsc/page_speed.rb +110 -0
- 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 +114 -0
- data/lib/gsc/schema_generator.rb +788 -0
- data/lib/gsc/schema_validator.rb +120 -0
- 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 +152 -0
- 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 +144 -0
- metadata +91 -2
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
module GSC
|
|
6
|
+
class DecayPredictor
|
|
7
|
+
# Minimum impressions over the 28-day window to evaluate decay
|
|
8
|
+
DEFAULT_MIN_IMP = 10
|
|
9
|
+
|
|
10
|
+
def self.analyze_timeseries(rows, dimension: 'page', min_imp: DEFAULT_MIN_IMP)
|
|
11
|
+
# rows is array of { "keys" => [entity, "YYYY-MM-DD"], "clicks" => n, "impressions" => n, "position" => f }
|
|
12
|
+
# Group by entity
|
|
13
|
+
entity_days = Hash.new { |h, k| h[k] = {} }
|
|
14
|
+
all_dates = []
|
|
15
|
+
|
|
16
|
+
rows.each do |r|
|
|
17
|
+
entity = r['keys'][0]
|
|
18
|
+
date_str = r['keys'][1]
|
|
19
|
+
all_dates << date_str
|
|
20
|
+
entity_days[entity][date_str] = {
|
|
21
|
+
clicks: (r['clicks'] || 0).to_i,
|
|
22
|
+
impressions: (r['impressions'] || 0).to_i,
|
|
23
|
+
position: (r['position'] || 0.0).to_f
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
all_dates = all_dates.uniq.sort
|
|
28
|
+
return empty_result(dimension) if all_dates.empty?
|
|
29
|
+
|
|
30
|
+
# Partition the dates into 4 chronological buckets (oldest to newest)
|
|
31
|
+
# total dates usually 28 (or whatever was queried)
|
|
32
|
+
chunk_size = (all_dates.size / 4.0).ceil
|
|
33
|
+
chunk_size = 1 if chunk_size < 1
|
|
34
|
+
|
|
35
|
+
w4_dates = all_dates[0...chunk_size] || []
|
|
36
|
+
w3_dates = all_dates[chunk_size...(chunk_size * 2)] || []
|
|
37
|
+
w2_dates = all_dates[(chunk_size * 2)...(chunk_size * 3)] || []
|
|
38
|
+
w1_dates = all_dates[(chunk_size * 3)..] || []
|
|
39
|
+
|
|
40
|
+
results = []
|
|
41
|
+
|
|
42
|
+
entity_days.each do |entity, days|
|
|
43
|
+
w4 = sum_window(days, w4_dates)
|
|
44
|
+
w3 = sum_window(days, w3_dates)
|
|
45
|
+
w2 = sum_window(days, w2_dates)
|
|
46
|
+
w1 = sum_window(days, w1_dates)
|
|
47
|
+
|
|
48
|
+
total_imp = w1[:impressions] + w2[:impressions] + w3[:impressions] + w4[:impressions]
|
|
49
|
+
total_clicks = w1[:clicks] + w2[:clicks] + w3[:clicks] + w4[:clicks]
|
|
50
|
+
|
|
51
|
+
next if total_imp < min_imp
|
|
52
|
+
|
|
53
|
+
# 7-day velocity: Compare W1 (latest 7d) vs W2 (prior 7d)
|
|
54
|
+
w1_clicks = w1[:clicks]
|
|
55
|
+
w2_clicks = w2[:clicks]
|
|
56
|
+
w1_imp = w1[:impressions]
|
|
57
|
+
w2_imp = w2[:impressions]
|
|
58
|
+
|
|
59
|
+
vel_7d_clicks_pct = calc_pct_change(w1_clicks, w2_clicks)
|
|
60
|
+
vel_7d_imp_pct = calc_pct_change(w1_imp, w2_imp)
|
|
61
|
+
|
|
62
|
+
# 14-day velocity: (W1 + W2) vs (W3 + W4)
|
|
63
|
+
h1_clicks = w1_clicks + w2_clicks
|
|
64
|
+
h2_clicks = w3[:clicks] + w4[:clicks]
|
|
65
|
+
h1_imp = w1_imp + w2_imp
|
|
66
|
+
h2_imp = w3[:impressions] + w4[:impressions]
|
|
67
|
+
|
|
68
|
+
vel_14d_clicks_pct = calc_pct_change(h1_clicks, h2_clicks)
|
|
69
|
+
vel_14d_imp_pct = calc_pct_change(h1_imp, h2_imp)
|
|
70
|
+
|
|
71
|
+
# 28-day Macro Velocity: W1 vs W4
|
|
72
|
+
vel_28d_clicks_pct = calc_pct_change(w1_clicks, w4[:clicks])
|
|
73
|
+
vel_28d_imp_pct = calc_pct_change(w1_imp, w4[:impressions])
|
|
74
|
+
|
|
75
|
+
# Historical linear regression slope across the 4 weeks:
|
|
76
|
+
# x = [1, 2, 3, 4], y = [w4_imp, w3_imp, w2_imp, w1_imp]
|
|
77
|
+
imp_series = [w4[:impressions], w3[:impressions], w2[:impressions], w1[:impressions]]
|
|
78
|
+
click_series = [w4[:clicks], w3[:clicks], w2[:clicks], w1[:clicks]]
|
|
79
|
+
pos_series = [w4[:position], w3[:position], w2[:position], w1[:position]].compact
|
|
80
|
+
|
|
81
|
+
imp_slope = calc_slope(imp_series)
|
|
82
|
+
click_slope = calc_slope(click_series)
|
|
83
|
+
pos_drift = (w1[:position] && w4[:position]) ? (w1[:position] - w4[:position]).round(1) : 0.0
|
|
84
|
+
|
|
85
|
+
# Classification
|
|
86
|
+
classification, severity = classify_decay(
|
|
87
|
+
vel_7d_imp_pct: vel_7d_imp_pct,
|
|
88
|
+
vel_14d_imp_pct: vel_14d_imp_pct,
|
|
89
|
+
vel_28d_imp_pct: vel_28d_imp_pct,
|
|
90
|
+
imp_slope: imp_slope,
|
|
91
|
+
pos_drift: pos_drift,
|
|
92
|
+
total_imp: total_imp,
|
|
93
|
+
w1: w1,
|
|
94
|
+
w4: w4
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Projected loss if trend continues for 30 days
|
|
98
|
+
projected_monthly_clicks_lost = 0
|
|
99
|
+
if click_slope.negative?
|
|
100
|
+
projected_monthly_clicks_lost = [(-click_slope * 4).round, w1_clicks * 4].min
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
prescription = formulate_prescription(
|
|
104
|
+
classification: classification,
|
|
105
|
+
pos_drift: pos_drift,
|
|
106
|
+
vel_7d_imp_pct: vel_7d_imp_pct,
|
|
107
|
+
vel_14d_imp_pct: vel_14d_imp_pct,
|
|
108
|
+
w1_imp: w1_imp,
|
|
109
|
+
w1_clicks: w1_clicks,
|
|
110
|
+
entity: entity,
|
|
111
|
+
dimension: dimension
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
results << {
|
|
115
|
+
entity: entity,
|
|
116
|
+
dimension: dimension,
|
|
117
|
+
classification: classification,
|
|
118
|
+
severity: severity,
|
|
119
|
+
total_impressions: total_imp,
|
|
120
|
+
total_clicks: total_clicks,
|
|
121
|
+
current_7d_impressions: w1_imp,
|
|
122
|
+
prior_7d_impressions: w2_imp,
|
|
123
|
+
current_7d_clicks: w1_clicks,
|
|
124
|
+
prior_7d_clicks: w2_clicks,
|
|
125
|
+
vel_7d_imp_pct: vel_7d_imp_pct,
|
|
126
|
+
vel_7d_clicks_pct: vel_7d_clicks_pct,
|
|
127
|
+
vel_14d_imp_pct: vel_14d_imp_pct,
|
|
128
|
+
vel_14d_clicks_pct: vel_14d_clicks_pct,
|
|
129
|
+
vel_28d_imp_pct: vel_28d_imp_pct,
|
|
130
|
+
vel_28d_clicks_pct: vel_28d_clicks_pct,
|
|
131
|
+
weekly_impressions: imp_series,
|
|
132
|
+
weekly_clicks: click_series,
|
|
133
|
+
impression_slope: imp_slope.round(2),
|
|
134
|
+
click_slope: click_slope.round(2),
|
|
135
|
+
current_position: w1[:position] ? w1[:position].round(1) : nil,
|
|
136
|
+
prior_position: w4[:position] ? w4[:position].round(1) : nil,
|
|
137
|
+
position_drift: pos_drift,
|
|
138
|
+
projected_monthly_clicks_lost: projected_monthly_clicks_lost,
|
|
139
|
+
prescription: prescription
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Sort: Critical decay first, then by negative slope
|
|
144
|
+
decaying_items = results.select { |r| %w[CRITICAL_COLLAPSE ACCELERATING_DECAY STEADY_EROSION].include?(r[:classification]) }
|
|
145
|
+
.sort_by { |r| [severity_weight(r[:severity]), r[:impression_slope]] }
|
|
146
|
+
|
|
147
|
+
surging_items = results.select { |r| r[:classification] == 'SURGING' }
|
|
148
|
+
.sort_by { |r| -r[:vel_14d_imp_pct] }
|
|
149
|
+
|
|
150
|
+
stable_items = results.select { |r| r[:classification] == 'STABLE' }
|
|
151
|
+
|
|
152
|
+
health_score = calculate_health_score(results)
|
|
153
|
+
|
|
154
|
+
{
|
|
155
|
+
dimension: dimension,
|
|
156
|
+
total_evaluated: results.size,
|
|
157
|
+
decaying_count: decaying_items.size,
|
|
158
|
+
surging_count: surging_items.size,
|
|
159
|
+
stable_count: stable_items.size,
|
|
160
|
+
health_score: health_score,
|
|
161
|
+
health_grade: grade_for_score(health_score),
|
|
162
|
+
decaying: decaying_items,
|
|
163
|
+
surging: surging_items,
|
|
164
|
+
date_range: {
|
|
165
|
+
oldest: all_dates.first,
|
|
166
|
+
newest: all_dates.last,
|
|
167
|
+
total_days: all_dates.size
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def self.sum_window(days_map, dates)
|
|
173
|
+
clicks = 0
|
|
174
|
+
impressions = 0
|
|
175
|
+
positions = []
|
|
176
|
+
weighted_pos_sum = 0.0
|
|
177
|
+
weighted_pos_imp = 0
|
|
178
|
+
|
|
179
|
+
dates.each do |d|
|
|
180
|
+
entry = days_map[d]
|
|
181
|
+
next unless entry
|
|
182
|
+
|
|
183
|
+
clicks += entry[:clicks]
|
|
184
|
+
impressions += entry[:impressions]
|
|
185
|
+
if entry[:position] && entry[:position] > 0
|
|
186
|
+
positions << entry[:position]
|
|
187
|
+
if entry[:impressions] > 0
|
|
188
|
+
weighted_pos_sum += (entry[:position] * entry[:impressions])
|
|
189
|
+
weighted_pos_imp += entry[:impressions]
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
avg_pos = if weighted_pos_imp > 0
|
|
195
|
+
weighted_pos_sum / weighted_pos_imp.to_f
|
|
196
|
+
elsif positions.any?
|
|
197
|
+
positions.sum / positions.size.to_f
|
|
198
|
+
else
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
{ clicks: clicks, impressions: impressions, position: avg_pos }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def self.calc_pct_change(curr, prior)
|
|
206
|
+
return 0.0 if prior.to_f.zero? && curr.to_f.zero?
|
|
207
|
+
return 100.0 if prior.to_f.zero? && curr.positive?
|
|
208
|
+
return -100.0 if curr.to_f.zero? && prior.positive?
|
|
209
|
+
|
|
210
|
+
(((curr - prior) / prior.to_f) * 100.0).round(1)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def self.calc_slope(series)
|
|
214
|
+
# x = [1, 2, 3, 4], y = series
|
|
215
|
+
n = series.size
|
|
216
|
+
return 0.0 if n < 2
|
|
217
|
+
|
|
218
|
+
x_coords = (1..n).to_a
|
|
219
|
+
x_mean = x_coords.sum / n.to_f
|
|
220
|
+
y_mean = series.sum / n.to_f
|
|
221
|
+
|
|
222
|
+
numerator = 0.0
|
|
223
|
+
denominator = 0.0
|
|
224
|
+
|
|
225
|
+
n.times do |i|
|
|
226
|
+
x_diff = x_coords[i] - x_mean
|
|
227
|
+
y_diff = series[i] - y_mean
|
|
228
|
+
numerator += (x_diff * y_diff)
|
|
229
|
+
denominator += (x_diff**2)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
return 0.0 if denominator.zero?
|
|
233
|
+
|
|
234
|
+
numerator / denominator
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def self.classify_decay(vel_7d_imp_pct:, vel_14d_imp_pct:, vel_28d_imp_pct:, imp_slope:, pos_drift:, total_imp:, w1:, w4:)
|
|
238
|
+
# pos_drift > 0 means current position number is higher (e.g. dropped from 5 to 9 => +4 drift)
|
|
239
|
+
if (vel_14d_imp_pct <= -50.0 && total_imp >= 20) || (w1[:impressions].zero? && w4[:impressions] >= 10)
|
|
240
|
+
['CRITICAL_COLLAPSE', 'CRITICAL']
|
|
241
|
+
elsif vel_7d_imp_pct <= -35.0 && vel_7d_imp_pct < (vel_14d_imp_pct - 15.0)
|
|
242
|
+
['ACCELERATING_DECAY', 'HIGH']
|
|
243
|
+
elsif imp_slope.negative? && (vel_14d_imp_pct <= -20.0 || pos_drift >= 3.0)
|
|
244
|
+
['STEADY_EROSION', 'MODERATE']
|
|
245
|
+
elsif vel_14d_imp_pct >= 25.0 || imp_slope >= 5.0
|
|
246
|
+
['SURGING', 'INFO']
|
|
247
|
+
else
|
|
248
|
+
['STABLE', 'LOW']
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def self.formulate_prescription(classification:, pos_drift:, vel_7d_imp_pct:, vel_14d_imp_pct:, w1_imp:, w1_clicks:, entity:, dimension:)
|
|
253
|
+
case classification
|
|
254
|
+
when 'CRITICAL_COLLAPSE'
|
|
255
|
+
if pos_drift >= 5.0
|
|
256
|
+
"🚨 Severe SERP Ranking Drop (+#{pos_drift} pos): Inspect for indexability errors, manual actions, or lost canonical tag."
|
|
257
|
+
elsif w1_imp.zero?
|
|
258
|
+
"💀 Zero Search Impressions in Past 7 Days: Run 'gsc inspect #{entity}' to verify Googlebot coverage."
|
|
259
|
+
else
|
|
260
|
+
"⚠️ High-Volume Collapse (#{vel_14d_imp_pct}% over 14d): Prioritize emergency content refresh and re-index via 'gsc index #{entity}'."
|
|
261
|
+
end
|
|
262
|
+
when 'ACCELERATING_DECAY'
|
|
263
|
+
"⚡ Rapid 7-Day Traffic Drop (#{vel_7d_imp_pct}%): Competitor surge detected. Enhance headline hook and verify schema."
|
|
264
|
+
when 'STEADY_EROSION'
|
|
265
|
+
if pos_drift >= 2.0
|
|
266
|
+
"📉 Gradual Position Drift (+#{pos_drift}): Update dated statistics, expand FAQ answers, and add 2-3 internal links."
|
|
267
|
+
else
|
|
268
|
+
"📉 Search Demand Erosion (#{vel_14d_imp_pct}%): Content freshness fatigue. Add 2026 insights and interactive widgets."
|
|
269
|
+
end
|
|
270
|
+
when 'SURGING'
|
|
271
|
+
"🚀 Surging Velocity (+#{vel_14d_imp_pct}%): Momentum window! Add related internal links to capture additional cluster queries."
|
|
272
|
+
else
|
|
273
|
+
"✅ Consistent Performance: Maintain current on-page content and monitor monthly."
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def self.severity_weight(sev)
|
|
278
|
+
case sev
|
|
279
|
+
when 'CRITICAL' then 1
|
|
280
|
+
when 'HIGH' then 2
|
|
281
|
+
when 'MODERATE' then 3
|
|
282
|
+
else 4
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def self.calculate_health_score(results)
|
|
287
|
+
return 100 if results.empty?
|
|
288
|
+
|
|
289
|
+
critical = results.count { |r| r[:severity] == 'CRITICAL' }
|
|
290
|
+
high = results.count { |r| r[:severity] == 'HIGH' }
|
|
291
|
+
moderate = results.count { |r| r[:severity] == 'MODERATE' }
|
|
292
|
+
|
|
293
|
+
score = 100 - (critical * 25) - (high * 15) - (moderate * 5)
|
|
294
|
+
[[score, 0].max, 100].min
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def self.grade_for_score(score)
|
|
298
|
+
case score
|
|
299
|
+
when 90..100 then 'A'
|
|
300
|
+
when 80..89 then 'B'
|
|
301
|
+
when 70..79 then 'C'
|
|
302
|
+
when 60..69 then 'D'
|
|
303
|
+
else 'F'
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def self.empty_result(dim)
|
|
308
|
+
{
|
|
309
|
+
dimension: dim,
|
|
310
|
+
total_evaluated: 0,
|
|
311
|
+
decaying_count: 0,
|
|
312
|
+
surging_count: 0,
|
|
313
|
+
stable_count: 0,
|
|
314
|
+
health_score: 100,
|
|
315
|
+
health_grade: 'A',
|
|
316
|
+
decaying: [],
|
|
317
|
+
surging: [],
|
|
318
|
+
date_range: {}
|
|
319
|
+
}
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|