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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/AUTH.md +205 -0
  3. data/FUNDING.md +120 -0
  4. data/README.md +463 -299
  5. data/bin/gsc +29158 -4921
  6. data/dist/gsc +29158 -4921
  7. data/lib/gsc/aio_hunter.rb +343 -0
  8. data/lib/gsc/answer_synthesizer.rb +157 -0
  9. data/lib/gsc/api.rb +53 -1
  10. data/lib/gsc/auth.rb +26 -0
  11. data/lib/gsc/backlinks_manager.rb +96 -0
  12. data/lib/gsc/brand_segmenter.rb +140 -0
  13. data/lib/gsc/cache_manager.rb +806 -0
  14. data/lib/gsc/cannibalization_analyzer.rb +141 -0
  15. data/lib/gsc/canonical_chains.rb +367 -0
  16. data/lib/gsc/citation_simulator.rb +339 -0
  17. data/lib/gsc/cli/aio_hunter.rb +154 -0
  18. data/lib/gsc/cli/analytics.rb +788 -0
  19. data/lib/gsc/cli/audit.rb +1976 -0
  20. data/lib/gsc/cli/base.rb +384 -0
  21. data/lib/gsc/cli/cache.rb +266 -0
  22. data/lib/gsc/cli/canonical.rb +223 -0
  23. data/lib/gsc/cli/citation_simulator.rb +152 -0
  24. data/lib/gsc/cli/dashboard.rb +354 -0
  25. data/lib/gsc/cli/doctor.rb +129 -0
  26. data/lib/gsc/cli/eeat.rb +125 -0
  27. data/lib/gsc/cli/ga4.rb +852 -0
  28. data/lib/gsc/cli/growth.rb +650 -0
  29. data/lib/gsc/cli/hreflang.rb +164 -0
  30. data/lib/gsc/cli/image_seo.rb +162 -0
  31. data/lib/gsc/cli/indexing.rb +458 -0
  32. data/lib/gsc/cli/intent_shift.rb +125 -0
  33. data/lib/gsc/cli/keyword_value.rb +134 -0
  34. data/lib/gsc/cli/keywords.rb +795 -0
  35. data/lib/gsc/cli/landing_roi.rb +308 -0
  36. data/lib/gsc/cli/low_ctr.rb +213 -0
  37. data/lib/gsc/cli/mobile_parity.rb +150 -0
  38. data/lib/gsc/cli/report.rb +100 -0
  39. data/lib/gsc/cli/rich_results.rb +172 -0
  40. data/lib/gsc/cli/schema_generate.rb +149 -0
  41. data/lib/gsc/cli/seasonal.rb +232 -0
  42. data/lib/gsc/cli/security.rb +153 -0
  43. data/lib/gsc/cli/setup.rb +1291 -0
  44. data/lib/gsc/cli/sitemap_tree.rb +143 -0
  45. data/lib/gsc/cli/skill_pack.rb +62 -0
  46. data/lib/gsc/cli/soft_404.rb +199 -0
  47. data/lib/gsc/cli/sparkline.rb +227 -0
  48. data/lib/gsc/cli/watchdog.rb +150 -0
  49. data/lib/gsc/cli/zombie_purger.rb +208 -0
  50. data/lib/gsc/cli.rb +706 -5111
  51. data/lib/gsc/cli_advanced.rb +1513 -0
  52. data/lib/gsc/client.rb +17 -2
  53. data/lib/gsc/color.rb +16 -1
  54. data/lib/gsc/command_registry.rb +47 -9
  55. data/lib/gsc/config.rb +11 -2
  56. data/lib/gsc/content_gap.rb +112 -0
  57. data/lib/gsc/ctr_curve.rb +115 -0
  58. data/lib/gsc/decay_predictor.rb +322 -0
  59. data/lib/gsc/doctor.rb +434 -0
  60. data/lib/gsc/eeat_auditor.rb +428 -0
  61. data/lib/gsc/entity_auditor.rb +229 -0
  62. data/lib/gsc/firewall_scanner.rb +733 -0
  63. data/lib/gsc/geo_auditor.rb +368 -0
  64. data/lib/gsc/google_suggest.rb +109 -0
  65. data/lib/gsc/google_trends.rb +8 -1
  66. data/lib/gsc/heading_validator.rb +283 -0
  67. data/lib/gsc/hreflang_validator.rb +412 -0
  68. data/lib/gsc/image_seo.rb +286 -0
  69. data/lib/gsc/indexing_queue.rb +179 -0
  70. data/lib/gsc/indexnow.rb +93 -0
  71. data/lib/gsc/intent_shift.rb +188 -0
  72. data/lib/gsc/internal_links.rb +249 -0
  73. data/lib/gsc/keyword_value.rb +191 -0
  74. data/lib/gsc/landing_roi.rb +195 -0
  75. data/lib/gsc/llms_generator.rb +425 -0
  76. data/lib/gsc/low_ctr_rewriter.rb +408 -0
  77. data/lib/gsc/mobile_parity.rb +222 -0
  78. data/lib/gsc/network_tracer.rb +93 -0
  79. data/lib/gsc/open_page_rank.rb +72 -0
  80. data/lib/gsc/page_analyzer.rb +47 -7
  81. data/lib/gsc/page_comparator.rb +108 -0
  82. data/lib/gsc/page_speed.rb +110 -0
  83. data/lib/gsc/prompts.rb +38 -29
  84. data/lib/gsc/questions_harvester.rb +178 -0
  85. data/lib/gsc/report_generator.rb +461 -0
  86. data/lib/gsc/rich_results.rb +388 -0
  87. data/lib/gsc/robots_checker.rb +114 -0
  88. data/lib/gsc/schema_generator.rb +788 -0
  89. data/lib/gsc/schema_validator.rb +120 -0
  90. data/lib/gsc/seasonal_predictor.rb +381 -0
  91. data/lib/gsc/security_scanner.rb +496 -0
  92. data/lib/gsc/serp_feature_detector.rb +359 -0
  93. data/lib/gsc/serp_preview.rb +152 -0
  94. data/lib/gsc/site_crawler.rb +113 -21
  95. data/lib/gsc/sitemap_loader.rb +15 -4
  96. data/lib/gsc/sitemap_tree.rb +301 -0
  97. data/lib/gsc/skill_pack.rb +195 -0
  98. data/lib/gsc/soft_404_analyzer.rb +385 -0
  99. data/lib/gsc/sparkline.rb +171 -0
  100. data/lib/gsc/speed_correlator.rb +416 -0
  101. data/lib/gsc/striking_playbook.rb +190 -0
  102. data/lib/gsc/title_optimizer.rb +420 -0
  103. data/lib/gsc/vault.rb +260 -0
  104. data/lib/gsc/version.rb +1 -1
  105. data/lib/gsc/watchdog.rb +235 -0
  106. data/lib/gsc/zombie_purger.rb +366 -0
  107. data/lib/gsc.rb +144 -0
  108. metadata +91 -2
@@ -0,0 +1,385 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'uri'
5
+ require 'net/http'
6
+ require 'json'
7
+ require 'time'
8
+ require 'thread'
9
+
10
+ module GSC
11
+ class Soft404Analyzer
12
+ ERROR_TITLE_PATTERNS = [
13
+ /404/i,
14
+ /not\s+found/i,
15
+ /page\s+not\s+found/i,
16
+ /error/i,
17
+ /oops/i,
18
+ /unavailable/i,
19
+ /doesn't\s+exist/i,
20
+ /does\s+not\s+exist/i,
21
+ /empty/i,
22
+ /out\s+of\s+stock/i
23
+ ].freeze
24
+
25
+ ERROR_BODY_PATTERNS = [
26
+ /page\s+you\s+are\s+looking\s+for\s+(cannot|can't|could\s+not)\s+be\s+found/i,
27
+ /the\s+page\s+you\s+requested\s+does\s+not\s+exist/i,
28
+ /we\s+couldn't\s+find\s+that\s+page/i,
29
+ /no\s+products?\s+found/i,
30
+ /there\s+are\s+no\s+products/i,
31
+ /this\s+product\s+is\s+unavailable/i,
32
+ /sorry,\s+we\s+couldn't\s+find/i,
33
+ /404\s+-\s+file\s+or\s+directory\s+not\s+found/i,
34
+ /item\s+not\s+available/i
35
+ ].freeze
36
+
37
+ attr_reader :target, :options
38
+
39
+ def initialize(target = nil, options = {})
40
+ if target.is_a?(Array)
41
+ @urls = target.map(&:to_s).reject(&:empty?)
42
+ @target = ''
43
+ else
44
+ @urls = []
45
+ @target = target.to_s.dup.force_encoding('UTF-8').scrub.strip
46
+ end
47
+ @options = options || {}
48
+ end
49
+
50
+ def self.analyze(target = nil, options = {})
51
+ new(target, options).analyze
52
+ end
53
+
54
+ def analyze
55
+ urls = resolve_urls_to_audit
56
+ return empty_target_result if urls.empty?
57
+
58
+ concurrency = (@options[:concurrency] || 5).to_i
59
+ concurrency = [[concurrency, 1].max, 15].min
60
+
61
+ results = audit_urls_concurrently(urls, concurrency)
62
+
63
+ diagnose_overall(results)
64
+ end
65
+
66
+ private
67
+
68
+ def resolve_urls_to_audit
69
+ return @urls if @urls.any?
70
+ return @options[:urls] if @options[:urls].is_a?(Array) && @options[:urls].any?
71
+
72
+ if @target.empty?
73
+ []
74
+ elsif File.exist?(@target)
75
+ File.readlines(@target, encoding: 'UTF-8').map(&:strip).reject { |l| l.empty? || l.start_with?('#') }
76
+ elsif @target =~ %r{^https?://}i
77
+ [@target]
78
+ elsif @target =~ /\.(com|org|net|io|app|co|dev|store|edu|gov|xyz|info|biz|me)$/i
79
+ clean_dom = @target.sub(%r{^https?://}i, '').chomp('/')
80
+ ["https://#{clean_dom}/", "https://#{clean_dom}/gsc-verify-nonexistent-404-probe"]
81
+ else
82
+ []
83
+ end
84
+ end
85
+
86
+ def empty_target_result
87
+ {
88
+ total_urls_audited: 0,
89
+ health_score: 100.0,
90
+ grade: 'A',
91
+ summary: {
92
+ healthy_count: 0,
93
+ soft_404_count: 0,
94
+ hard_404_count: 0,
95
+ thin_content_count: 0,
96
+ crawl_budget_waste_percent: 0.0
97
+ },
98
+ all_results: [],
99
+ prescriptions: ['Provide a target URL, file of URLs, or domain property to audit for soft-404 errors.']
100
+ }
101
+ end
102
+
103
+ def audit_urls_concurrently(urls, concurrency)
104
+ results = []
105
+ mutex = Mutex.new
106
+ queue = Queue.new
107
+ urls.each { |u| queue << u }
108
+
109
+ threads = Array.new(concurrency) do
110
+ Thread.new do
111
+ loop do
112
+ url = begin
113
+ queue.pop(true)
114
+ rescue ThreadError
115
+ nil
116
+ end
117
+ break unless url
118
+
119
+ res = audit_single_url(url)
120
+ mutex.synchronize { results << res }
121
+ end
122
+ end
123
+ end
124
+
125
+ threads.each(&:join)
126
+ results.sort_by { |r| r[:url] }
127
+ end
128
+
129
+ def audit_single_url(url)
130
+ uri = URI.parse(url) rescue nil
131
+ return { url: url, error: 'Invalid URL format', status: :invalid_url } unless uri
132
+
133
+ # If offline / mock check for tests or local files
134
+ if @options[:mock_results] && @options[:mock_results][url]
135
+ return @options[:mock_results][url]
136
+ end
137
+
138
+ begin
139
+ http = Net::HTTP.new(uri.host, uri.port)
140
+ http.use_ssl = (uri.scheme == 'https')
141
+ http.open_timeout = 5
142
+ http.read_timeout = 5
143
+
144
+ req = Net::HTTP::Get.new(uri.request_uri, {
145
+ 'User-Agent' => 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
146
+ })
147
+
148
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
149
+ response = http.request(req)
150
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
151
+
152
+ status_code = response.code.to_i
153
+ body = response.body.to_s.dup.force_encoding('UTF-8').scrub
154
+ headers = response.each_header.to_h
155
+
156
+ evaluate_response(url, status_code, body, headers, ((t1 - t0) * 1000).round(1))
157
+ rescue StandardError => e
158
+ # Real-time network error
159
+ network_error_result(url, e.message)
160
+ end
161
+ end
162
+
163
+ def evaluate_response(url, status_code, body, headers, latency_ms)
164
+ body_size = body.bytesize
165
+ text = strip_tags(body)
166
+ word_count = text.split.size
167
+
168
+ title = extract_title(body)
169
+ h1 = extract_h1(body)
170
+ meta_robots = headers['x-robots-tag'].to_s
171
+
172
+ # Soft-404 heuristics
173
+ is_hard_404 = (status_code == 404 || status_code == 410)
174
+ title_has_error = ERROR_TITLE_PATTERNS.any? { |pat| title =~ pat }
175
+ body_has_error_pattern = ERROR_BODY_PATTERNS.any? { |pat| body =~ pat }
176
+ is_thin_content = word_count < (@options[:threshold] || 100).to_i
177
+
178
+ # Google treats 200 OK with error title or error body as soft-404
179
+ is_soft_404 = (status_code == 200) && (title_has_error || body_has_error_pattern || (is_thin_content && word_count < 35))
180
+
181
+ # 302 redirecting to homepage is often soft-404
182
+ is_homepage_redirect = (status_code == 301 || status_code == 302) && (headers['location'] == '/' || headers['location'] == url.sub(%r{/[^/]+/?$}, '/'))
183
+
184
+ classification = if is_hard_404
185
+ :hard_404
186
+ elsif is_soft_404
187
+ :soft_404
188
+ elsif is_homepage_redirect
189
+ :soft_404_redirect
190
+ elsif is_thin_content
191
+ :thin_content_warning
192
+ else
193
+ :healthy_200
194
+ end
195
+
196
+ suggested_action = case classification
197
+ when :soft_404
198
+ "Return HTTP 404/410 or 301 redirect to relevant parent hub"
199
+ when :hard_404
200
+ "301 redirect to closely matched category or purge from sitemaps"
201
+ when :soft_404_redirect
202
+ "Redirect to exact topic match instead of generic homepage"
203
+ when :thin_content_warning
204
+ "Enrich content to >250 words or consolidate into topic hub"
205
+ else
206
+ "Optimal — verified healthy"
207
+ end
208
+
209
+ suggested_redirect = suggest_redirect_target(url)
210
+
211
+ stack = detect_tech_stack(headers, body)
212
+
213
+ {
214
+ url: url,
215
+ status_code: status_code,
216
+ latency_ms: latency_ms,
217
+ body_size_bytes: body_size,
218
+ word_count: word_count,
219
+ title: title,
220
+ h1: h1,
221
+ meta_robots: meta_robots,
222
+ classification: classification,
223
+ is_soft_404: (classification == :soft_404 || classification == :soft_404_redirect),
224
+ suggested_action: suggested_action,
225
+ suggested_redirect: suggested_redirect,
226
+ detected_stack: stack,
227
+ remediation_rules: generate_server_rules(url, suggested_redirect)
228
+ }
229
+ end
230
+
231
+ def network_error_result(url, err_msg)
232
+ suggested_redirect = suggest_redirect_target(url)
233
+
234
+ {
235
+ url: url,
236
+ status_code: 0,
237
+ latency_ms: 0.0,
238
+ body_size_bytes: 0,
239
+ word_count: 0,
240
+ title: "Connection Failed",
241
+ h1: "Network Error",
242
+ meta_robots: "",
243
+ classification: :unreachable,
244
+ is_soft_404: false,
245
+ suggested_action: "Verify DNS and server network accessibility",
246
+ suggested_redirect: suggested_redirect,
247
+ detected_stack: { framework: 'Universal Web', server: 'Unreachable', default_format: 'nginx' },
248
+ remediation_rules: generate_server_rules(url, suggested_redirect),
249
+ note: "Unreachable: #{err_msg}"
250
+ }
251
+ end
252
+
253
+ def diagnose_overall(results)
254
+ total = results.size
255
+ soft_404s = results.select { |r| r[:is_soft_404] }
256
+ hard_404s = results.select { |r| r[:classification] == :hard_404 }
257
+ thin_warnings = results.select { |r| r[:classification] == :thin_content_warning }
258
+ healthy = results.select { |r| r[:classification] == :healthy_200 }
259
+ detected_stack = results.map { |r| r[:detected_stack] }.compact.first
260
+
261
+ # Health score calculation (100 minus penalties for soft-404 and broken pages)
262
+ penalties = (soft_404s.size * 25.0) + (hard_404s.size * 15.0) + (thin_warnings.size * 5.0)
263
+ score = [100.0, [0.0, 100.0 - (penalties / [total, 1].max)].max].min.round(1)
264
+
265
+ grade = case score
266
+ when 90.0..100.0 then 'A'
267
+ when 80.0...90.0 then 'B'
268
+ when 70.0...80.0 then 'C'
269
+ when 60.0...70.0 then 'D'
270
+ else 'F'
271
+ end
272
+
273
+ {
274
+ target: @target,
275
+ timestamp: Time.now.utc.iso8601,
276
+ total_urls_audited: total,
277
+ health_score: score,
278
+ grade: grade,
279
+ detected_stack: detected_stack,
280
+ summary: {
281
+ healthy_count: healthy.size,
282
+ soft_404_count: soft_404s.size,
283
+ hard_404_count: hard_404s.size,
284
+ thin_content_count: thin_warnings.size,
285
+ crawl_budget_waste_percent: total > 0 ? ((soft_404s.size + hard_404s.size) * 100.0 / total).round(1) : 0.0
286
+ },
287
+ soft_404_urls: soft_404s,
288
+ all_results: results
289
+ }
290
+ end
291
+
292
+ def suggest_redirect_target(url)
293
+ uri = URI.parse(url) rescue nil
294
+ return '/' unless uri && uri.path
295
+
296
+ parts = uri.path.split('/').reject(&:empty?)
297
+ if parts.size > 1
298
+ "/#{parts[0...-1].join('/')}"
299
+ else
300
+ '/'
301
+ end
302
+ end
303
+
304
+ def detect_tech_stack(headers = {}, body = '')
305
+ server = (headers['server'] || '').downcase
306
+ powered_by = (headers['x-powered-by'] || '').downcase
307
+ body_str = body.to_s
308
+
309
+ if headers['x-sveltekit-page'] || body_str.include?('__sveltekit') || body_str.include?('_app/immutable')
310
+ { framework: 'SvelteKit', server: server.empty? ? 'Node/Edge' : server.capitalize, default_format: 'sveltekit' }
311
+ elsif body_str =~ /class=["'][^"']*astro-[^"']*["']/ || body_str.include?('data-astro-cid') || body_str =~ /<meta[^>]+content=["']Astro/i
312
+ { framework: 'Astro', server: server.empty? ? 'Static/SSR' : server.capitalize, default_format: 'astro' }
313
+ elsif body_str.include?('___gatsby') || body_str.include?('gatsby-script') || body_str.include?('id="___gatsby"')
314
+ { framework: 'Gatsby', server: server.empty? ? 'Static/SSR' : server.capitalize, default_format: 'gatsby' }
315
+ elsif powered_by.include?('next.js') || body_str.include?('__NEXT_DATA__') || body_str.include?('/_next/')
316
+ { framework: 'Next.js', server: server.empty? ? 'Vercel/Node' : server.capitalize, default_format: 'nextjs' }
317
+ elsif body_str.include?('assets.website-files.com') || body_str =~ /<meta[^>]+content=["']Webflow/i || body_str.include?('w-mod-')
318
+ { framework: 'Webflow', server: 'Webflow Hosting', default_format: 'webflow' }
319
+ elsif body_str.include?('cdn.shopify.com') || body_str.include?('Shopify.shop') || headers['x-shopify-stage']
320
+ { framework: 'Shopify', server: 'Shopify Cloud', default_format: 'shopify' }
321
+ elsif body_str.include?('wp-content') || body_str =~ /<meta[^>]+content=["']WordPress/i
322
+ { framework: 'WordPress', server: server.empty? ? 'PHP/Apache' : server.capitalize, default_format: 'htaccess' }
323
+ elsif server.include?('github.com')
324
+ { framework: 'GitHub Pages', server: 'GitHub Pages', default_format: 'github' }
325
+ elsif server.include?('caddy')
326
+ { framework: 'Caddy', server: 'Caddy Server', default_format: 'caddy' }
327
+ elsif server.include?('cloudflare')
328
+ { framework: 'Cloudflare Pages / Workers', server: 'Cloudflare', default_format: 'cloudflare' }
329
+ elsif server.include?('nginx')
330
+ { framework: 'Nginx', server: 'Nginx', default_format: 'nginx' }
331
+ elsif server.include?('apache')
332
+ { framework: 'Apache', server: 'Apache', default_format: 'htaccess' }
333
+ else
334
+ { framework: 'Universal Web', server: server.empty? ? 'HTTP' : server.capitalize, default_format: 'nginx' }
335
+ end
336
+ end
337
+
338
+ def generate_server_rules(url, destination)
339
+ uri = URI.parse(url) rescue nil
340
+ path = uri ? uri.path : url
341
+ path = "/#{path}" unless path.start_with?('/')
342
+
343
+ escaped_path = Regexp.escape(path).gsub('\-', '-')
344
+
345
+ {
346
+ nginx: "rewrite ^#{escaped_path}/?$ #{destination} permanent;",
347
+ caddy: "redir #{path} #{destination} 301",
348
+ htaccess: "Redirect 301 #{path} #{destination}",
349
+ cloudflare: "#{path} #{destination} 301",
350
+ cloudflare_workers: "if (url.pathname === '#{path}') return Response.redirect(new URL('#{destination}', request.url), 301);",
351
+ github_pages: "<meta http-equiv=\"refresh\" content=\"0; url=#{destination}\">\n<link rel=\"canonical\" href=\"#{destination}\">",
352
+ netlify: "#{path} #{destination} 301",
353
+ sveltekit: "if (event.url.pathname === '#{path}') redirect(301, '#{destination}');",
354
+ astro: "'#{path}': '#{destination}',",
355
+ gatsby: "createRedirect({ fromPath: '#{path}', toPath: '#{destination}', isPermanent: true });",
356
+ nextjs: "{ source: '#{path}', destination: '#{destination}', permanent: true },",
357
+ webflow: "Old Path: #{path} -> Redirect to Page: #{destination}",
358
+ shopify: "#{path},#{destination}",
359
+ vercel: { "source" => path, "destination" => destination, "permanent" => true }
360
+ }
361
+ end
362
+
363
+ def extract_title(html)
364
+ if html =~ %r{<title[^>]*>(.*?)</title>}im
365
+ $1.strip.gsub(/\s+/, ' ')
366
+ else
367
+ ''
368
+ end
369
+ end
370
+
371
+ def extract_h1(html)
372
+ if html =~ %r{<h1[^>]*>(.*?)</h1>}im
373
+ $1.strip.gsub(/<[^>]+>/, '').gsub(/\s+/, ' ')
374
+ else
375
+ ''
376
+ end
377
+ end
378
+
379
+ def strip_tags(html)
380
+ # Remove script, style tags
381
+ clean = html.gsub(%r{<(script|style)[^>]*>.*?</\1>}im, ' ')
382
+ clean.gsub(/<[^>]+>/, ' ').gsub(/\s+/, ' ').strip
383
+ end
384
+ end
385
+ end
@@ -0,0 +1,171 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require_relative 'color'
6
+
7
+ module GSC
8
+ class Sparkline
9
+ TICKS = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"].freeze
10
+
11
+ # Renders a single-line Unicode sparkline
12
+ # options:
13
+ # colored: true (gradient from red/trough to green/peak)
14
+ # invert: true (lower values get higher ticks, e.g. SERP position)
15
+ # max_points: limit number of values (resamples if larger)
16
+ def self.render(values, options = {})
17
+ vals = normalize_values(values)
18
+ return '' if vals.empty?
19
+
20
+ vals = resample(vals, options[:max_points]) if options[:max_points] && vals.size > options[:max_points]
21
+
22
+ min_val = vals.min
23
+ max_val = vals.max
24
+ range = max_val - min_val
25
+
26
+ chars = vals.map do |v|
27
+ idx = if range.zero?
28
+ TICKS.size / 2
29
+ else
30
+ pos = (v - min_val) / range.to_f
31
+ pos = 1.0 - pos if options[:invert]
32
+ [((pos * (TICKS.size - 1)).round), TICKS.size - 1].min
33
+ end
34
+
35
+ tick = TICKS[idx]
36
+ if options[:colored]
37
+ color_tick(tick, idx, TICKS.size)
38
+ else
39
+ tick
40
+ end
41
+ end
42
+
43
+ chars.join
44
+ end
45
+
46
+ # Renders a multi-line ASCII terminal chart with Y-axis labels and X-axis
47
+ def self.chart(values, options = {})
48
+ vals = normalize_values(values)
49
+ return '' if vals.empty?
50
+
51
+ height = [options[:height] || 5, 2].max
52
+ dates = options[:dates] || []
53
+
54
+ min_val = vals.min
55
+ max_val = vals.max
56
+ range = [max_val - min_val, 1.0].max
57
+
58
+ y_label_width = [max_val.round.to_s.length, min_val.round.to_s.length, 3].max
59
+
60
+ rows = []
61
+
62
+ # Render each chart line from top to bottom
63
+ (height - 1).downto(0) do |row_idx|
64
+ threshold = min_val + (range * (row_idx.to_f / (height - 1)))
65
+ label = row_idx == height - 1 ? max_val.round.to_s : (row_idx.zero? ? min_val.round.to_s : '')
66
+ y_axis = label.rjust(y_label_width) + ' ┤ '
67
+
68
+ line_chars = vals.map do |v|
69
+ normalized = (v - min_val) / range.to_f
70
+ char_level = (normalized * (height - 1)).round
71
+
72
+ if char_level == row_idx
73
+ Color.bold(Color.cyan('•'))
74
+ elsif char_level > row_idx
75
+ Color.cyan('│')
76
+ else
77
+ ' '
78
+ end
79
+ end
80
+
81
+ rows << "#{y_axis}#{line_chars.join}"
82
+ end
83
+
84
+ # X-axis line
85
+ axis_pad = ' '.rjust(y_label_width)
86
+ rows << "#{axis_pad} ┼─#{'─' * vals.size}"
87
+
88
+ # X-axis date milestones if available
89
+ if dates.any? && dates.size == vals.size
90
+ step = [vals.size / 4, 1].max
91
+ label_line = ' ' * vals.size
92
+ 0.step(vals.size - 1, step) do |i|
93
+ d_str = dates[i].to_s.sub(/^\d{4}-/, '') # MM-DD
94
+ label_line[i, d_str.length] = d_str if i + d_str.length <= label_line.length
95
+ end
96
+ rows << "#{axis_pad} #{label_line}"
97
+ end
98
+
99
+ rows.join("\n")
100
+ end
101
+
102
+ # Computes trend velocity and momentum
103
+ def self.momentum(values)
104
+ vals = normalize_values(values)
105
+ return { change_pct: 0.0, trend: :stable, velocity: 'FLAT' } if vals.size < 2
106
+
107
+ mid = vals.size / 2
108
+ first_half = vals[0...mid]
109
+ second_half = vals[mid..-1]
110
+
111
+ avg_first = first_half.sum.to_f / [first_half.size, 1].max
112
+ avg_second = second_half.sum.to_f / [second_half.size, 1].max
113
+
114
+ change_pct = avg_first.zero? ? (avg_second > 0 ? 100.0 : 0.0) : (((avg_second - avg_first) / avg_first) * 100.0).round(1)
115
+
116
+ trend = if change_pct >= 25.0
117
+ :surging
118
+ elsif change_pct >= 5.0
119
+ :rising
120
+ elsif change_pct <= -25.0
121
+ :collapsing
122
+ elsif change_pct <= -5.0
123
+ :decaying
124
+ else
125
+ :stable
126
+ end
127
+
128
+ velocity_badge = case trend
129
+ when :surging then '🚀 SURGING (+%+.1f%%)' % change_pct
130
+ when :rising then '📈 RISING (+%+.1f%%)' % change_pct
131
+ when :collapsing then '💥 COLLAPSING (%+.1f%%)' % change_pct
132
+ when :decaying then '📉 DECAYING (%+.1f%%)' % change_pct
133
+ else '➡️ STABLE (%+.1f%%)' % change_pct
134
+ end
135
+
136
+ {
137
+ change_pct: change_pct,
138
+ trend: trend,
139
+ velocity: velocity_badge,
140
+ start_avg: avg_first.round(1),
141
+ end_avg: avg_second.round(1)
142
+ }
143
+ end
144
+
145
+ private
146
+
147
+ def self.normalize_values(values)
148
+ Array(values).map { |v| v.is_a?(Hash) ? (v[:value] || v['value'] || 0) : v }.map(&:to_f)
149
+ end
150
+
151
+ def self.resample(vals, max_pts)
152
+ return vals if vals.size <= max_pts
153
+ step = vals.size.to_f / max_pts
154
+ (0...max_pts).map do |i|
155
+ idx = (i * step).round
156
+ vals[[idx, vals.size - 1].min]
157
+ end
158
+ end
159
+
160
+ def self.color_tick(tick, idx, total)
161
+ pct = idx.to_f / (total - 1)
162
+ if pct >= 0.75
163
+ Color.green(tick)
164
+ elsif pct >= 0.4
165
+ Color.yellow(tick)
166
+ else
167
+ Color.red(tick)
168
+ end
169
+ end
170
+ end
171
+ end