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,143 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require_relative 'base'
6
+ require_relative '../sitemap_tree'
7
+ require_relative '../color'
8
+
9
+ module GSC
10
+ class CLI
11
+ module SitemapTree
12
+ module_function
13
+
14
+ def run(command, target, extra, options, api = nil, site_url = nil, hostname = nil)
15
+ target_sitemap = target || options[:sitemap]
16
+
17
+ # Auto-detection if target is not specified
18
+ if target_sitemap.nil? || target_sitemap.empty?
19
+ local_candidates = [
20
+ 'public/sitemap.xml',
21
+ 'public/sitemap_index.xml',
22
+ 'dist/sitemap.xml',
23
+ 'sitemap.xml',
24
+ 'sitemap_index.xml'
25
+ ]
26
+ found_local = local_candidates.find { |f| File.exist?(f) }
27
+ if found_local
28
+ target_sitemap = found_local
29
+ elsif site_url || hostname
30
+ origin = site_url ? site_url.sub(/^sc-domain:/, 'https://').chomp('/') : "https://#{hostname}"
31
+ target_sitemap = "#{origin}/sitemap.xml"
32
+ elsif Config.default_domain
33
+ target_sitemap = "https://#{Config.default_domain}/sitemap.xml"
34
+ else
35
+ if options[:json]
36
+ puts JSON.pretty_generate({ error: 'Target sitemap XML URL or file required. Example: gsc sitemap-tree https://mysite.com/sitemap.xml' })
37
+ else
38
+ puts Color.c("❌ Error: Target sitemap XML URL or file required.", Color::RED, Color::BOLD)
39
+ puts "Example: gsc sitemap-tree https://mysite.com/sitemap.xml"
40
+ puts " gsc sitemap-tree ./sitemap.xml"
41
+ end
42
+ return
43
+ end
44
+ end
45
+
46
+ gsc_pages = []
47
+ if api && site_url
48
+ gsc_res = api.query_analytics(site_url, days: options[:days] || 28, dimensions: ['page'], row_limit: 250) rescue { ok: false }
49
+ if gsc_res[:ok]
50
+ gsc_pages = (gsc_res.dig(:data, 'rows') || []).map { |r| { url: r['keys'][0], clicks: r['clicks'], imp: r['impressions'] } }
51
+ end
52
+ end
53
+
54
+ auditor = GSC::SitemapTree.new(target_sitemap, options)
55
+ report = auditor.audit(gsc_pages)
56
+
57
+ if options[:json]
58
+ puts JSON.pretty_generate(report)
59
+ return
60
+ end
61
+
62
+ render_sitemap_tree(report, options)
63
+
64
+ if options[:csv]
65
+ csv_rows = report[:all_urls].map do |u|
66
+ [u, report[:root]]
67
+ end
68
+ Base.write_csv(options[:csv], %w[URL SitemapSource], csv_rows)
69
+ puts Color.cyan("📁 Exported #{report[:all_urls].size} sitemap URLs to #{options[:csv]}")
70
+ end
71
+ end
72
+
73
+ def render_sitemap_tree(report, options)
74
+ puts "\n" + Color.cyan("╔" + "═" * 78 + "╗")
75
+ puts Color.cyan("║") + Color.bold(" 🗺️ SITEMAP INDEX HIERARCHY & SPECIFICATION COMPLIANCE AUDITOR ") + Color.cyan("║")
76
+ puts Color.cyan("╚" + "═" * 78 + "╝")
77
+ puts Color.gray(" Root Sitemap: ") + Color.bold(report[:root].to_s)
78
+ puts Color.gray(" Structure: ") + (report[:is_index] ? Color.cyan("Sitemap Index (Multi-file hierarchy)") : Color.dim("Single Urlset"))
79
+ puts Color.gray(" Total Sitemaps: ") + Color.bold(report[:total_sitemaps].to_s)
80
+ puts Color.gray(" Total URLs: ") + Color.bold(report[:total_urls].to_s)
81
+
82
+ # Health Score
83
+ score_color = report[:health_score] >= 85 ? Color::GREEN : (report[:health_score] >= 60 ? Color::YELLOW : Color::RED)
84
+ puts Color.gray(" Compliance Score: ") + Color.c("#{report[:health_score]}/100", score_color, Color::BOLD)
85
+ puts Color.cyan("─" * 80)
86
+
87
+ # Violations
88
+ if report[:violations].any?
89
+ puts "\n" + Color.bold("🚨 SITEMAP PROTOCOL VIOLATIONS:")
90
+ report[:violations].each do |v|
91
+ puts " ✗ [#{v[:type].to_s.upcase}] #{v[:sitemap]}: #{Color.red(v[:message])}"
92
+ end
93
+ else
94
+ puts "\n" + Color.green("✓ Zero protocol violations! Conforms to Google 50,000 URLs & 50MB limits.")
95
+ end
96
+
97
+ # GSC Coverage
98
+ cov = report[:coverage]
99
+ if cov && cov[:total_gsc_pages] && cov[:total_gsc_pages] > 0
100
+ cov_color = cov[:coverage_pct] >= 90.0 ? Color::GREEN : Color::YELLOW
101
+ puts "\n" + Color.bold("📈 GOOGLE SEARCH CONSOLE SITEMAP COVERAGE:")
102
+ puts " • Coverage Ratio: " + Color.c("#{cov[:coverage_pct]}%", cov_color, Color::BOLD) + " (#{cov[:included_in_sitemap]}/#{cov[:total_gsc_pages]} ranking pages listed)"
103
+ if cov[:missing_from_sitemap] > 0
104
+ puts " • Missing From Sitemap: " + Color.yellow("#{cov[:missing_from_sitemap]} pages") + Color.gray(" (ranking in GSC but omitted from sitemap)")
105
+ cov[:missing_sample].first(3).each do |m|
106
+ puts " ↳ " + Color.dim(m)
107
+ end
108
+ end
109
+ end
110
+
111
+ # Visual ASCII Tree
112
+ puts "\n" + Color.bold("🌳 SITEMAP ARCHITECTURE TREE:")
113
+ print_tree_node(report[:tree], "", true)
114
+ puts Color.cyan("═" * 80) + "\n"
115
+ end
116
+
117
+ def print_tree_node(node, prefix, is_last)
118
+ branch = is_last ? "└── " : "├── "
119
+ icon = node[:type] == :sitemapindex ? "📁 " : "📄 "
120
+
121
+ lastmod_label = if node[:latest_lastmod]
122
+ node[:is_stale] ? Color.yellow("[Stale: #{node[:latest_lastmod]}]") : Color.gray("[Updated: #{node[:latest_lastmod]}]")
123
+ else
124
+ Color.gray("[No lastmod]")
125
+ end
126
+
127
+ count_label = Color.bold("#{node[:url_count] || 0} URLs")
128
+ size_kb = ((node[:size_bytes] || 0) / 1024.0).round(1)
129
+ size_label = Color.dim("(#{size_kb} KB)")
130
+
131
+ puts "#{prefix}#{branch}#{icon}#{Color.bold(node[:url])} #{count_label} #{size_label} #{lastmod_label}"
132
+
133
+ if node[:children] && node[:children].any?
134
+ new_prefix = prefix + (is_last ? " " : "│ ")
135
+ node[:children].each_with_index do |child, idx|
136
+ child_is_last = (idx == node[:children].size - 1)
137
+ print_tree_node(child, new_prefix, child_is_last)
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,62 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require_relative 'base'
6
+ require_relative '../skill_pack'
7
+ require_relative '../color'
8
+
9
+ module GSC
10
+ class CLI
11
+ module SkillPack
12
+ module_function
13
+
14
+ def run(command, target, extra, options, api = nil, site_url = nil, hostname = nil)
15
+ target_dir = target || (extra && extra.first)
16
+
17
+ puts "📦 Packaging autonomous AI Agent Skill specification for #{Color.cyan(target_dir || options[:target] || 'all agent platforms')}..." unless options[:json]
18
+
19
+ res = GSC::SkillPack.package(options, target_dir)
20
+
21
+ if options[:json]
22
+ puts JSON.pretty_generate(res)
23
+ return
24
+ end
25
+
26
+ render_terminal(res)
27
+ end
28
+
29
+ def render_terminal(res)
30
+ puts "\n#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}"
31
+ puts "#{Color.bold("🤖 AUTONOMOUS AI AGENT SKILL PACKAGING & INTEGRATION")}"
32
+ puts "#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}"
33
+
34
+ puts " • Skill Package Name: #{Color.bold(res[:skill_name])} (v#{res[:version]})"
35
+ puts " • Execution Recipes Packaged: #{Color.bold(res[:recipes_count].to_s)} autonomous playbooks"
36
+ puts " • Mode: #{res[:dry_run] ? Color.yellow("DRY RUN (Preview Only)") : Color.green("INSTALLED & ACTIVE")}"
37
+
38
+ v = res[:verification]
39
+ status_color = v[:status] == 'READY_FOR_AI_AGENTS' ? Color.green(v[:status]) : Color.red(v[:status])
40
+ puts " • AI Agent Execution Health: [ #{Color.bold(status_color)} ]"
41
+ puts " • Target Binary Executable: #{v[:binary_path]}"
42
+ puts "#{Color.bold("───────────────────────────────────────────────────────────────────────────")}\n"
43
+
44
+ if res[:installed_locations].any?
45
+ puts "#{Color.bold("INSTALLED AGENT SKILL LOCATIONS:")}\n"
46
+ res[:installed_locations].each do |loc|
47
+ puts " ✅ #{Color.bold(loc[:platform])}:"
48
+ puts " 📄 SKILL.md: #{Color.cyan(loc[:skill_path])}"
49
+ puts " 📋 recipes.json: #{Color.cyan(loc[:recipe_path])}\n"
50
+ end
51
+ end
52
+
53
+ puts "#{Color.bold("───────────────────────────────────────────────────────────────────────────")}"
54
+ puts "#{Color.bold("AI AGENT USAGE TRIGGER PROMPT:")}"
55
+ puts " Copy and paste into your AI agent or IDE (Cursor, Claude, Antigravity, Windsurf, Codex):"
56
+ puts Color.cyan(" \"Inspect our SEO health for my active domain using `gsc audit --json`")
57
+ puts Color.cyan(" and diagnose any striking distance keyword opportunities with `gsc kw-value --json`.\"")
58
+ puts "#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}\n"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,199 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require_relative 'base'
6
+ require_relative '../soft_404_analyzer'
7
+ require_relative '../color'
8
+
9
+ module GSC
10
+ class CLI
11
+ module Soft404
12
+ module_function
13
+
14
+ def run(command, target, extra, options, api = nil, site_url = nil, hostname = nil)
15
+ target_url = target || (extra && extra.first) || hostname || Config.default_domain
16
+ if target_url.nil? || target_url.to_s.strip.empty?
17
+ if options[:json]
18
+ puts JSON.pretty_generate({ error: 'Target URL or domain required. Example: gsc soft-404 https://mydomain.com/page' })
19
+ else
20
+ puts Color.c("❌ Error: Target URL or domain required.", Color::RED, Color::BOLD)
21
+ puts "Example: gsc soft-404 https://mydomain.com/page"
22
+ puts " gsc soft-404 mydomain.com"
23
+ end
24
+ return
25
+ end
26
+
27
+ puts "🔍 Auditing URLs for Soft-404 errors, thin content & HTTP status code mismatches..." unless options[:json]
28
+
29
+ res = GSC::Soft404Analyzer.analyze(target_url, options)
30
+
31
+ if options[:json]
32
+ puts JSON.pretty_generate(res)
33
+ return
34
+ end
35
+
36
+ if options[:csv]
37
+ export_csv(res)
38
+ return
39
+ end
40
+
41
+ render_terminal(res, options, extra)
42
+ end
43
+
44
+ def render_terminal(res, options, extra = [])
45
+ sum = res[:summary]
46
+
47
+ grade_color = case res[:grade]
48
+ when 'A' then Color.green("#{res[:health_score]}/100 (Grade #{res[:grade]})")
49
+ when 'B', 'C' then Color.yellow("#{res[:health_score]}/100 (Grade #{res[:grade]})")
50
+ else Color.red("#{res[:health_score]}/100 (Grade #{res[:grade]})")
51
+ end
52
+
53
+ puts "\n#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}"
54
+ puts "#{Color.bold("🚫 SOFT-404 & BROKEN URL DIAGNOSTIC ENGINE")}"
55
+ puts "#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}"
56
+
57
+ puts " • Soft-404 Health Score: [ #{Color.bold(grade_color)} ]"
58
+ puts " • Crawl Budget Waste: #{Color.bold(Color.red("#{sum[:crawl_budget_waste_percent]}%"))} of crawled pages"
59
+ puts " • URLs Audited: #{Color.bold(res[:total_urls_audited].to_s)} total"
60
+ puts " • Verified Healthy (200 OK): #{Color.green(sum[:healthy_count].to_s)}"
61
+ puts " • Soft-404 Errors Flagged: #{Color.bold(Color.red(sum[:soft_404_count].to_s))}"
62
+ puts " • Hard 404/410 Broken: #{Color.yellow(sum[:hard_404_count].to_s)}"
63
+ puts " • Thin Content Warnings: #{Color.cyan(sum[:thin_content_count].to_s)}"
64
+ if res[:detected_stack] && res[:detected_stack][:framework] != 'Universal Web'
65
+ puts " • Detected Environment: #{Color.bold(Color.cyan("#{res[:detected_stack][:framework]} (#{res[:detected_stack][:server]})"))}"
66
+ end
67
+ puts "#{Color.bold("───────────────────────────────────────────────────────────────────────────")}\n"
68
+
69
+ puts format("%-45s | %-5s | %-18s | %-6s | %-16s", "URL", "HTTP", "Classification", "Words", "Suggested Target")
70
+ puts "#{Color.bold("─" * 105)}"
71
+
72
+ res[:all_results].each do |item|
73
+ u_trunc = item[:url].length > 43 ? "#{item[:url][0..40]}..." : item[:url]
74
+ code_s = item[:status_code].to_s
75
+
76
+ class_badge = case item[:classification]
77
+ when :healthy_200 then Color.green("HEALTHY")
78
+ when :soft_404 then Color.red("🚨 SOFT-404")
79
+ when :soft_404_redirect then Color.red("⚠️ REDIRECT-404")
80
+ when :hard_404 then Color.yellow("HARD 404")
81
+ when :thin_content_warning then Color.cyan("THIN CONTENT")
82
+ else item[:classification].to_s.upcase
83
+ end
84
+
85
+ puts format("%-45s | %-5s | %-27s | %-6d | %-16s",
86
+ u_trunc, code_s, class_badge, item[:word_count], item[:suggested_redirect])
87
+ end
88
+
89
+ fix_urls = res[:all_results].select { |r| r[:is_soft_404] || r[:classification] == :hard_404 || r[:classification] == :soft_404_redirect }
90
+
91
+ if fix_urls.any? || options[:fix]
92
+ puts "\n#{Color.bold("───────────────────────────────────────────────────────────────────────────")}"
93
+ puts "#{Color.bold("1-CLICK SERVER REWRITE RULES FOR 404 & REDIRECT RECOVERY:")}"
94
+
95
+ if fix_urls.empty?
96
+ puts " (All audited URLs are verified healthy 200 OK. No redirect rules required.)"
97
+ else
98
+ fix_param = options[:fix].is_a?(String) ? options[:fix] : nil
99
+ valid_fmts = %w[
100
+ nginx caddy htaccess apache nextjs next vercel sveltekit svelte astro
101
+ webflow shopify cloudflare cloudflare-pages pages cf-workers workers worker
102
+ netlify _redirects github github-pages gh-pages gatsby all
103
+ ]
104
+ extra_fmt = (extra && extra.first.to_s) if valid_fmts.include?(extra&.first.to_s.downcase)
105
+
106
+ fmt = (options[:format] || fix_param || extra_fmt || 'nginx').to_s.downcase
107
+
108
+ case fmt
109
+ when 'caddy'
110
+ puts " # Caddy (Caddyfile) 301 Redirect Rules:"
111
+ fix_urls.first(10).each { |s| puts Color.cyan(" #{s[:remediation_rules][:caddy]}") }
112
+ when 'sveltekit', 'svelte'
113
+ puts " // SvelteKit hooks.server.ts (or +page.server.ts):"
114
+ fix_urls.first(10).each { |s| puts Color.cyan(" #{s[:remediation_rules][:sveltekit]}") }
115
+ when 'astro'
116
+ puts " // Astro (astro.config.mjs) redirects map:"
117
+ puts " redirects: {"
118
+ fix_urls.first(10).each { |s| puts Color.cyan(" #{s[:remediation_rules][:astro]}") }
119
+ puts " }"
120
+ when 'gatsby'
121
+ puts " // Gatsby (gatsby-node.js -> createPages):"
122
+ fix_urls.first(10).each { |s| puts Color.cyan(" #{s[:remediation_rules][:gatsby]}") }
123
+ when 'cloudflare', 'cloudflare-pages', 'pages', 'netlify', '_redirects'
124
+ puts " # Cloudflare Pages / Netlify _redirects file:"
125
+ fix_urls.first(10).each { |s| puts Color.cyan(" #{s[:remediation_rules][:cloudflare]}") }
126
+ when 'cloudflare-workers', 'workers', 'worker', 'cf-workers'
127
+ puts " // Cloudflare Workers (worker.js / ts):"
128
+ fix_urls.first(10).each { |s| puts Color.cyan(" #{s[:remediation_rules][:cloudflare_workers]}") }
129
+ when 'github', 'github-pages', 'gh-pages'
130
+ puts " <!-- GitHub Pages / Static HTML Redirect: -->"
131
+ fix_urls.first(10).each { |s| puts Color.cyan(" #{s[:remediation_rules][:github_pages]}") }
132
+ when 'webflow'
133
+ puts " # Webflow 301 Redirects (Project Settings -> Publishing -> 301 Redirects):"
134
+ fix_urls.first(10).each { |s| puts Color.cyan(" #{s[:remediation_rules][:webflow]}") }
135
+ when 'shopify'
136
+ puts " # Shopify URL Redirects (Import CSV via Online Store -> Navigation):"
137
+ puts " Redirect from,Redirect to"
138
+ fix_urls.first(10).each { |s| puts Color.cyan(" #{s[:remediation_rules][:shopify]}") }
139
+ when 'nextjs', 'next'
140
+ puts " // Next.js (next.config.js) redirects array:"
141
+ fix_urls.first(10).each { |s| puts Color.cyan(" #{s[:remediation_rules][:nextjs]}") }
142
+ when 'vercel'
143
+ puts " // vercel.json redirects array:"
144
+ fix_urls.first(10).each { |s| puts Color.cyan(" #{JSON.generate(s[:remediation_rules][:vercel])},") }
145
+ when 'htaccess', 'apache', 'wordpress'
146
+ puts " # Apache / WordPress .htaccess 301 Redirect Rules:"
147
+ fix_urls.first(10).each { |s| puts Color.cyan(" #{s[:remediation_rules][:htaccess]}") }
148
+ when 'all'
149
+ puts " # --- Nginx ---"
150
+ fix_urls.first(2).each { |s| puts Color.cyan(" #{s[:remediation_rules][:nginx]}") }
151
+ puts "\n # --- Caddy ---"
152
+ fix_urls.first(2).each { |s| puts Color.cyan(" #{s[:remediation_rules][:caddy]}") }
153
+ puts "\n # --- Cloudflare Pages / Netlify (_redirects) ---"
154
+ fix_urls.first(2).each { |s| puts Color.cyan(" #{s[:remediation_rules][:cloudflare]}") }
155
+ puts "\n // --- Cloudflare Workers ---"
156
+ fix_urls.first(2).each { |s| puts Color.cyan(" #{s[:remediation_rules][:cloudflare_workers]}") }
157
+ puts "\n // --- SvelteKit (hooks.server.ts) ---"
158
+ fix_urls.first(2).each { |s| puts Color.cyan(" #{s[:remediation_rules][:sveltekit]}") }
159
+ puts "\n // --- Next.js (next.config.js) ---"
160
+ fix_urls.first(2).each { |s| puts Color.cyan(" #{s[:remediation_rules][:nextjs]}") }
161
+ puts "\n // --- Astro (astro.config.mjs) ---"
162
+ fix_urls.first(2).each { |s| puts Color.cyan(" #{s[:remediation_rules][:astro]}") }
163
+ puts "\n // --- Gatsby (gatsby-node.js) ---"
164
+ fix_urls.first(2).each { |s| puts Color.cyan(" #{s[:remediation_rules][:gatsby]}") }
165
+ puts "\n <!-- --- GitHub Pages HTML Redirect --- -->"
166
+ fix_urls.first(2).each { |s| puts Color.cyan(" #{s[:remediation_rules][:github_pages]}") }
167
+ puts "\n # --- Webflow (Site Settings 301) ---"
168
+ fix_urls.first(2).each { |s| puts Color.cyan(" #{s[:remediation_rules][:webflow]}") }
169
+ puts "\n # --- Shopify CSV ---"
170
+ fix_urls.first(2).each { |s| puts Color.cyan(" #{s[:remediation_rules][:shopify]}") }
171
+ else
172
+ puts " # Nginx 301 Permanent Redirect Rules:"
173
+ fix_urls.first(10).each { |s| puts Color.cyan(" #{s[:remediation_rules][:nginx]}") }
174
+ end
175
+
176
+ puts "\n💡 #{Color.gray("Tip: Generate for any stack: --fix caddy | --fix sveltekit | --fix astro | --fix cloudflare | --fix workers | --fix github | --fix gatsby | --fix webflow | --fix shopify | --fix nextjs | --fix all")}"
177
+ end
178
+ end
179
+
180
+ puts "\n#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}\n"
181
+ end
182
+
183
+ def export_csv(res)
184
+ require 'csv'
185
+ puts "url,status_code,classification,word_count,suggested_redirect,suggested_action"
186
+ res[:all_results].each do |item|
187
+ puts [
188
+ item[:url],
189
+ item[:status_code],
190
+ item[:classification],
191
+ item[:word_count],
192
+ item[:suggested_redirect],
193
+ item[:suggested_action]
194
+ ].to_csv
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,227 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require 'date'
6
+ require_relative 'base'
7
+ require_relative '../sparkline'
8
+ require_relative '../color'
9
+
10
+ module GSC
11
+ class CLI
12
+ module Sparkline
13
+ module_function
14
+
15
+ def run(command, target, extra, options, api = nil, site_url = nil, hostname = nil)
16
+ days = (options[:days] || 28).to_i
17
+ metric_filter = (options[:metric] || 'all').to_s.downcase
18
+
19
+ # 1. Mode A: Raw numbers passed via --data or comma-separated target
20
+ if options[:data] || (target && target.match?(/^\d+(?:,\s*\d+)+$/))
21
+ raw_data = (options[:data] || target).split(',').map(&:strip).map(&:to_f)
22
+ render_raw_data(raw_data, options)
23
+ return
24
+ end
25
+
26
+ # 2. Mode B: Live Search Console query over time
27
+ filter_query = nil
28
+ filter_page = nil
29
+
30
+ if target && target.start_with?('/')
31
+ filter_page = target
32
+ elsif target && target.match?(%r{^https?://})
33
+ filter_page = target
34
+ elsif target && !target.empty?
35
+ filter_query = target
36
+ end
37
+
38
+ # Also check extra arguments if multi-word query was given
39
+ if extra && extra.is_a?(String) && !extra.empty?
40
+ filter_query = "#{filter_query} #{extra}".strip
41
+ end
42
+
43
+ daily_rows = fetch_daily_analytics(api, site_url, days, filter_query, filter_page)
44
+
45
+ if daily_rows.empty?
46
+ msg = "No daily time-series performance data found for #{site_url || 'current domain'} over the past #{days} days."
47
+ if options[:json]
48
+ puts JSON.pretty_generate({ error: msg })
49
+ else
50
+ puts Color.yellow("⚠️ Notice: #{msg}")
51
+ end
52
+ return
53
+ end
54
+
55
+ result = process_time_series(daily_rows, site_url, days, filter_query, filter_page, options)
56
+
57
+ if options[:json]
58
+ puts JSON.pretty_generate(result)
59
+ return
60
+ end
61
+
62
+ render_terminal(result, metric_filter, options)
63
+
64
+ if options[:csv]
65
+ export_csv(result, options[:csv])
66
+ end
67
+ end
68
+
69
+ def fetch_daily_analytics(api, site_url, days, query, page)
70
+ return [] unless api && site_url
71
+
72
+ filters = []
73
+ filters << { dimension: 'query', operator: 'contains', expression: query } if query
74
+ filters << { dimension: 'page', operator: 'contains', expression: page } if page
75
+
76
+ res = api.query_analytics(site_url, days: days, dimensions: ['date'], filters: filters, row_limit: 500)
77
+ if res[:ok]
78
+ (res.dig(:data, 'rows') || []).sort_by { |r| r['keys'].first }
79
+ else
80
+ []
81
+ end
82
+ rescue StandardError
83
+ []
84
+ end
85
+
86
+ def process_time_series(rows, site_url, days, query, page, options)
87
+ dates = rows.map { |r| r['keys'].first }
88
+ clicks = rows.map { |r| (r['clicks'] || 0).to_i }
89
+ impressions = rows.map { |r| (r['impressions'] || 0).to_i }
90
+ positions = rows.map { |r| (r['position'] || 0.0).round(1) }
91
+ ctrs = rows.map { |r| (r['ctr'] ? r['ctr'] * 100.0 : 0.0).round(2) }
92
+
93
+ height = [options[:height] || 5, 2].max
94
+
95
+ metrics = {
96
+ clicks: {
97
+ label: 'Clicks',
98
+ values: clicks,
99
+ total: clicks.sum,
100
+ average: (clicks.sum.to_f / [clicks.size, 1].max).round(1),
101
+ sparkline: GSC::Sparkline.render(clicks, colored: true),
102
+ chart: GSC::Sparkline.chart(clicks, height: height, dates: dates),
103
+ momentum: GSC::Sparkline.momentum(clicks)
104
+ },
105
+ impressions: {
106
+ label: 'Impressions',
107
+ values: impressions,
108
+ total: impressions.sum,
109
+ average: (impressions.sum.to_f / [impressions.size, 1].max).round(1),
110
+ sparkline: GSC::Sparkline.render(impressions, colored: true),
111
+ chart: GSC::Sparkline.chart(impressions, height: height, dates: dates),
112
+ momentum: GSC::Sparkline.momentum(impressions)
113
+ },
114
+ position: {
115
+ label: 'Avg Position',
116
+ values: positions,
117
+ average: (positions.sum.to_f / [positions.size, 1].max).round(1),
118
+ sparkline: GSC::Sparkline.render(positions, invert: true, colored: true),
119
+ chart: GSC::Sparkline.chart(positions, height: height, dates: dates),
120
+ momentum: GSC::Sparkline.momentum(positions)
121
+ },
122
+ ctr: {
123
+ label: 'CTR (%)',
124
+ values: ctrs,
125
+ average: (ctrs.sum.to_f / [ctrs.size, 1].max).round(2),
126
+ sparkline: GSC::Sparkline.render(ctrs, colored: true),
127
+ chart: GSC::Sparkline.chart(ctrs, height: height, dates: dates),
128
+ momentum: GSC::Sparkline.momentum(ctrs)
129
+ }
130
+ }
131
+
132
+ {
133
+ site_url: site_url,
134
+ days: days,
135
+ filter_query: query,
136
+ filter_page: page,
137
+ data_points: rows.size,
138
+ start_date: dates.first,
139
+ end_date: dates.last,
140
+ dates: dates,
141
+ metrics: metrics
142
+ }
143
+ end
144
+
145
+ def render_terminal(res, metric_filter, options)
146
+ target_desc = if res[:filter_query]
147
+ "Query: \"#{res[:filter_query]}\""
148
+ elsif res[:filter_page]
149
+ "Page: #{res[:filter_page]}"
150
+ else
151
+ "Site-wide Performance"
152
+ end
153
+
154
+ puts "\n#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}"
155
+ puts "#{Color.bold("📈 VISUAL TREND SPARKLINES & TRAJECTORY CHARTS (#{res[:days]} Days)")}"
156
+ puts "#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}"
157
+ puts " • Target Scope: #{Color.cyan(target_desc)}"
158
+ puts " • Time Window: #{res[:start_date]} to #{res[:end_date]} (#{res[:data_points]} daily points)"
159
+ puts "#{Color.bold("───────────────────────────────────────────────────────────────────────────")}\n"
160
+
161
+ # 1. Primary ASCII Visual Chart (Clicks or chosen metric)
162
+ primary_key = metric_filter == 'all' ? :clicks : metric_filter.to_sym
163
+ primary_m = res[:metrics][primary_key] || res[:metrics][:clicks]
164
+
165
+ puts "#{Color.bold("DAILY #{primary_m[:label].upcase} TRAJECTORY (ASCII TERMINAL CHART):")}\n"
166
+ puts primary_m[:chart]
167
+ puts "\n#{Color.bold("───────────────────────────────────────────────────────────────────────────")}"
168
+
169
+ # 2. Comparative Metrics Table with Sparklines & Velocity
170
+ puts "#{Color.bold("30-DAY METRIC SUMMARY & MOMENTUM VECTOR:")}\n\n"
171
+ printf " %-14s %-20s %-12s %-10s %s\n", "Metric", "Sparkline (28d)", "Avg / Day", "Total", "Momentum Rate"
172
+ puts " ---------------------------------------------------------------------------------------"
173
+
174
+ res[:metrics].each do |key, m|
175
+ next if metric_filter != 'all' && metric_filter != key.to_s
176
+
177
+ total_display = m[:total] ? m[:total].to_s : "—"
178
+ avg_display = key == :ctr ? "#{m[:average]}%" : (key == :position ? "Pos #{m[:average]}" : m[:average].to_s)
179
+ mom_str = m[:momentum][:velocity]
180
+
181
+ printf " %-14s [ %s ] %-12s %-10s %s\n", m[:label], m[:sparkline], avg_display, total_display, mom_str
182
+ end
183
+
184
+ puts "\n#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}\n"
185
+ end
186
+
187
+ def render_raw_data(data, options)
188
+ spark = GSC::Sparkline.render(data, colored: true)
189
+ chart = GSC::Sparkline.chart(data, height: options[:height] || 5)
190
+ mom = GSC::Sparkline.momentum(data)
191
+
192
+ if options[:json]
193
+ puts JSON.pretty_generate({
194
+ points: data.size,
195
+ values: data,
196
+ min: data.min,
197
+ max: data.max,
198
+ average: (data.sum / data.size.to_f).round(2),
199
+ sparkline: spark,
200
+ momentum: mom
201
+ })
202
+ return
203
+ end
204
+
205
+ puts "\n#{Color.bold("ASCII DATA CHART (#{data.size} points):")}\n"
206
+ puts chart
207
+ puts "\nSparkline: [ #{spark} ] | Momentum: #{mom[:velocity]}\n"
208
+ end
209
+
210
+ def export_csv(res, file_path)
211
+ dates = res[:dates]
212
+ clicks = res[:metrics][:clicks][:values]
213
+ impressions = res[:metrics][:impressions][:values]
214
+ positions = res[:metrics][:position][:values]
215
+ ctrs = res[:metrics][:ctr][:values]
216
+
217
+ headers = %w[Date Clicks Impressions Position CTR]
218
+ rows = dates.each_with_index.map do |d, i|
219
+ [d, clicks[i] || 0, impressions[i] || 0, positions[i] || 0.0, ctrs[i] || 0.0]
220
+ end
221
+
222
+ Base.write_csv(file_path, headers, rows)
223
+ puts Color.cyan("📁 Successfully exported daily sparkline points to #{file_path}")
224
+ end
225
+ end
226
+ end
227
+ end