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,150 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require_relative 'base'
6
+ require_relative '../watchdog'
7
+ require_relative '../color'
8
+
9
+ module GSC
10
+ class CLI
11
+ module Watchdog
12
+ module_function
13
+
14
+ def run(command, target, extra, options, api = nil, site_url = nil, hostname = nil)
15
+ target_domain = target || hostname || (Config.default_domain rescue nil)
16
+ if target_domain.nil? || target_domain.to_s.strip.empty?
17
+ if options[:json]
18
+ puts JSON.pretty_generate({ error: 'Target domain required. Example: gsc watch mysite.com or gsc use mysite.com' })
19
+ else
20
+ puts Color.c("❌ Error: Target domain required. Example: gsc watch mysite.com or gsc use mysite.com", Color::RED, Color::BOLD)
21
+ end
22
+ return
23
+ end
24
+ target_domain = target_domain.sub(%r{^https?://}, '').sub(%r{/.*$}, '')
25
+
26
+ watchdog = GSC::Watchdog.new(options, api, target_domain)
27
+
28
+ if options[:cron]
29
+ puts watchdog.generate_crontab_entry
30
+ return
31
+ elsif options[:launchd]
32
+ puts watchdog.generate_launchd_plist
33
+ return
34
+ elsif options[:systemd]
35
+ puts watchdog.generate_systemd_unit
36
+ return
37
+ end
38
+
39
+ puts "🛰️ Inspecting SERP rank anomalies and traffic drift for #{Color.cyan(target_domain)}..." unless options[:json]
40
+
41
+ if options[:daemon]
42
+ run_daemon_loop(watchdog, options)
43
+ else
44
+ summary = watchdog.check
45
+ if options[:json]
46
+ puts JSON.pretty_generate(summary)
47
+ return
48
+ end
49
+ render_terminal(summary)
50
+
51
+ if options[:csv]
52
+ export_csv(summary, options[:csv])
53
+ end
54
+ end
55
+ end
56
+
57
+ def run_daemon_loop(watchdog, options)
58
+ interval = (options[:interval] || 3600).to_i
59
+ puts Color.green("🚀 SERP Watchdog Daemon active. Polling every #{interval}s (Press Ctrl+C to terminate)...")
60
+
61
+ trap('INT') do
62
+ puts "\n" + Color.yellow("🛑 Shutting down SERP Watchdog Daemon gracefully...")
63
+ exit 0
64
+ end
65
+
66
+ loop do
67
+ summary = watchdog.check
68
+ render_terminal(summary)
69
+ sleep interval
70
+ end
71
+ end
72
+
73
+ def render_terminal(summary)
74
+ puts "\n#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}"
75
+ puts "#{Color.bold("🛰️ CONTINUOUS SERP WATCHDOG & RANK VOLATILITY MONITOR")}"
76
+ puts "#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}"
77
+
78
+ status_color = case summary[:status]
79
+ when 'SERP HEALTH OPTIMAL' then Color.green(summary[:status])
80
+ when 'WARNINGS DETECTED' then Color.yellow(summary[:status])
81
+ else Color.red(summary[:status])
82
+ end
83
+
84
+ puts " • Monitored Domain: #{Color.bold(summary[:domain])}"
85
+ puts " • System Health Status: [ #{Color.bold(status_color)} ]"
86
+ puts " • Monitored Search Queries: #{Color.bold(summary[:monitored_queries_count].to_s)} keywords"
87
+ puts " • Critical Rank Drops: #{summary[:critical_alerts_count] > 0 ? Color.red(summary[:critical_alerts_count].to_s) : Color.green("0")}"
88
+ puts " • CTR & Visibility Warnings: #{summary[:warning_alerts_count] > 0 ? Color.yellow(summary[:warning_alerts_count].to_s) : Color.green("0")}"
89
+ puts " • Last Checked Timestamp: #{summary[:checked_at]}"
90
+
91
+ if summary[:webhook_dispatched]
92
+ puts " • Webhook Alert Dispatch: #{Color.green("✅ Webhook successfully posted")}"
93
+ end
94
+
95
+ puts "#{Color.bold("───────────────────────────────────────────────────────────────────────────")}\n"
96
+
97
+ if summary[:alerts].empty?
98
+ puts Color.green(" ✅ No rank drops or traffic anomalies detected across monitored search queries.")
99
+ puts " 💡 Tip: To run automatically every 6 hours in crontab, run:"
100
+ puts Color.cyan(" gsc watch #{summary[:domain]} --cron >> my_crontab")
101
+ puts "\n#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}\n"
102
+ return
103
+ end
104
+
105
+ puts "#{Color.bold("ACTIVE RANK DROPS & TRAFFIC DRIFT ANOMALIES:")}\n\n"
106
+ printf " %-30s %-12s %-16s %s\n", "Search Query", "Current Pos", "Prev Pos", "Anomaly Severity"
107
+ puts " ---------------------------------------------------------------------------"
108
+
109
+ summary[:alerts].each do |a|
110
+ sev_badge = case a[:severity]
111
+ when :critical then Color.red("🚨 CRITICAL")
112
+ when :warning then Color.yellow("⚠️ WARNING")
113
+ else Color.cyan("ℹ️ INFO")
114
+ end
115
+
116
+ q_str = a[:query].length > 28 ? "#{a[:query][0..25]}..." : a[:query]
117
+ printf " %-30s Pos %-8s Pos %-12s %s\n", q_str, a[:current_position], a[:previous_position], sev_badge
118
+ puts " #{Color.yellow("↳")} #{a[:message]}"
119
+ end
120
+
121
+ puts "\n#{Color.bold("───────────────────────────────────────────────────────────────────────────")}"
122
+ puts "#{Color.bold("AUTOMATED BACKGROUND SCHEDULING INTEGRATIONS:")}"
123
+ puts " • Crontab: gsc watch #{summary[:domain]} --cron"
124
+ puts " • macOS: gsc watch #{summary[:domain]} --launchd > ~/Library/LaunchAgents/com.gsc.watchdog.plist"
125
+ puts " • Linux: gsc watch #{summary[:domain]} --systemd > /etc/systemd/system/gsc-watch.service"
126
+ puts "#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}\n"
127
+ end
128
+
129
+ def export_csv(summary, file_path)
130
+ headers = %w[Query CurrentPosition PreviousPosition CurrentClicks PreviousClicks CurrentCTR PreviousCTR HasAlert AnomalyMessage]
131
+ rows = summary[:telemetry].map do |t|
132
+ alert = summary[:alerts].find { |a| a[:query] == t[:query] }
133
+ [
134
+ t[:query],
135
+ t[:current_position],
136
+ t[:previous_position],
137
+ t[:current_clicks],
138
+ t[:previous_clicks],
139
+ t[:current_ctr],
140
+ t[:previous_ctr],
141
+ alert ? 'YES' : 'NO',
142
+ alert ? alert[:message] : ''
143
+ ]
144
+ end
145
+ Base.write_csv(file_path, headers, rows)
146
+ puts Color.cyan("📁 Successfully exported Watchdog telemetry to #{file_path}")
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,208 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require 'uri'
6
+ require_relative 'base'
7
+ require_relative '../zombie_purger'
8
+ require_relative '../sitemap_loader'
9
+ require_relative '../color'
10
+
11
+ module GSC
12
+ class CLI
13
+ module ZombiePurger
14
+ module_function
15
+
16
+ def run(command, target, extra, options, api = nil, site_url = nil, hostname = nil, https_origin = nil)
17
+ target_inv = target || options[:inventory] || options[:sitemap]
18
+ urls = resolve_inventory(target_inv, https_origin, options)
19
+ if urls.empty?
20
+ msg = "No URLs found to audit. Please specify a sitemap or URL inventory file: gsc zombies <sitemap.xml|urls.txt>"
21
+ if options[:json]
22
+ puts JSON.pretty_generate({ error: msg })
23
+ else
24
+ puts Color.red("❌ Error: #{msg}")
25
+ end
26
+ return
27
+ end
28
+
29
+ puts "🔍 Auditing #{Color.cyan(urls.size.to_s)} URLs against Search Console performance..." unless options[:json] || options[:format]
30
+
31
+ gsc_data = fetch_gsc_pages(api, site_url, options)
32
+
33
+ result = GSC::ZombiePurger.audit(urls, gsc_data, options)
34
+
35
+ # Handle explicit rule format exports
36
+ if options[:format]
37
+ handle_format_export(result, options)
38
+ return
39
+ end
40
+
41
+ if options[:json]
42
+ puts JSON.pretty_generate(result)
43
+ return
44
+ end
45
+
46
+ render_terminal(result)
47
+
48
+ if options[:csv]
49
+ export_csv(result, options[:csv])
50
+ end
51
+ end
52
+
53
+ def resolve_inventory(target, https_origin, options)
54
+ # 1. If target is a file that exists on local disk
55
+ if target && File.exist?(target)
56
+ if target.downcase.end_with?('.xml') || target.downcase.end_with?('.gz')
57
+ return SitemapLoader.resolve_urls(target, https_origin)
58
+ else
59
+ return File.readlines(target).map(&:strip).reject { |l| l.empty? || l.start_with?('#') }
60
+ end
61
+ end
62
+
63
+ # 2. If target is a remote sitemap URL
64
+ if target && target.match?(%r{^https?://})
65
+ return SitemapLoader.resolve_urls(target, https_origin)
66
+ end
67
+
68
+ # 3. If target is a local path relative to current working directory
69
+ if target && (target.include?('/') || target.include?('.'))
70
+ expanded = File.expand_path(target)
71
+ if File.exist?(expanded)
72
+ return target.end_with?('.xml') ? SitemapLoader.resolve_urls(expanded, https_origin) : File.readlines(expanded).map(&:strip).reject(&:empty?)
73
+ end
74
+ end
75
+
76
+ # 4. Auto-detect public/sitemap.xml or sitemap.xml
77
+ %w[public/sitemap.xml sitemap.xml public/sitemaps.xml].each do |candidate|
78
+ if File.exist?(candidate)
79
+ puts "📄 Auto-detected local sitemap: #{Color.cyan(candidate)}" unless options[:json] || options[:format]
80
+ return SitemapLoader.resolve_urls(candidate, https_origin)
81
+ end
82
+ end
83
+
84
+ # 5. If origin is present, attempt fetching https_origin/sitemap.xml
85
+ if https_origin
86
+ remote_sitemap = "#{https_origin.sub(%r{/$}, '')}/sitemap.xml"
87
+ remote_urls = SitemapLoader.resolve_urls(remote_sitemap, https_origin)
88
+ return remote_urls if remote_urls.any?
89
+ end
90
+
91
+ []
92
+ end
93
+
94
+ def fetch_gsc_pages(api, site_url, options)
95
+ return [] unless api && site_url
96
+
97
+ days = (options[:days] || 90).to_i
98
+ res = api.query_analytics(site_url, days: days, dimensions: ['page'], row_limit: 5000)
99
+ if res[:ok]
100
+ res.dig(:data, 'rows') || []
101
+ else
102
+ puts Color.yellow("⚠️ Search Analytics notice: #{res[:data]} (operating on zero-baseline)") unless options[:json] || options[:format]
103
+ []
104
+ end
105
+ rescue StandardError => e
106
+ puts Color.yellow("⚠️ Could not fetch GSC live rows (#{e.message}); using local baseline") unless options[:json] || options[:format]
107
+ []
108
+ end
109
+
110
+ def handle_format_export(result, options)
111
+ case options[:format].to_s.downcase
112
+ when 'nginx'
113
+ puts result.dig(:server_rules, :nginx)
114
+ when 'htaccess', 'apache'
115
+ puts result.dig(:server_rules, :htaccess)
116
+ when 'redirects', 'netlify', 'cloudflare', '_redirects'
117
+ puts result.dig(:server_rules, :redirects)
118
+ when 'meta', 'robots'
119
+ puts result.dig(:server_rules, :meta_robots)
120
+ else
121
+ puts Color.red("Unknown format '#{options[:format]}'. Choose: nginx, htaccess, redirects, meta")
122
+ end
123
+ end
124
+
125
+ def render_terminal(res)
126
+ puts "\n#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}"
127
+ puts "#{Color.bold("🧟 ZOMBIE CONTENT PURGE & CRAWL BUDGET OPTIMIZER")}"
128
+ puts "#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}"
129
+
130
+ grade_color = case res[:grade]
131
+ when 'A' then Color.green(res[:grade])
132
+ when 'B' then Color.cyan(res[:grade])
133
+ when 'C' then Color.yellow(res[:grade])
134
+ else Color.red(res[:grade])
135
+ end
136
+
137
+ puts " • Crawl Efficiency Grade: [ #{Color.bold(grade_color)} ] (Health Score: #{res[:health_score]}/100)"
138
+ puts " • Crawl Equity Dilution Index (CEDI): #{Color.bold("#{res[:cedi]}%")} (#{res[:zombie_count]} of #{res[:total_inventory]} URLs)"
139
+ puts " • Active Impressions Window: #{res[:days]} Days (Threshold: <= #{res[:threshold]} impressions)"
140
+ puts " • Estimated Googlebot Crawl Waste: #{Color.red(res[:wasted_crawls_monthly].to_s)} crawls/mo (~#{res[:wasted_crawls_annually]} /year)"
141
+ puts "#{Color.bold("───────────────────────────────────────────────────────────────────────────")}"
142
+
143
+ brk = res[:action_breakdown]
144
+ puts " Triage Recommendations:"
145
+ puts " 🗑️ Purge 410 (Dead / Thin): #{Color.bold(brk[:purge_410].to_s)} URLs (Immediate drop from Google index)"
146
+ puts " 🔀 Redirect 301 (Pillar Transfer): #{Color.bold(brk[:redirect_301].to_s)} URLs (Consolidate topic authority)"
147
+ puts " 📦 Consolidate / Rewrite: #{Color.bold(brk[:consolidate].to_s)} URLs (Merge into evergreen canonical)"
148
+ puts " 🛡️ Noindex (Utility / Legal): #{Color.bold(brk[:noindex].to_s)} URLs (Preserve user access, stop crawl waste)"
149
+ puts "#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}\n"
150
+
151
+ if res[:zombies].empty?
152
+ puts Color.green("🎉 Zero zombie pages detected! All inventory URLs are actively receiving search impressions.\n")
153
+ return
154
+ end
155
+
156
+ puts "#{Color.bold("PRIORITIZED ZOMBIE PAGES TO PURGE / REDIRECT:")}\n\n"
157
+ printf " %-36s %-16s %s\n", "URL Path", "Action", "Prescription / Target"
158
+ puts " ---------------------------------------------------------------------------------------"
159
+
160
+ res[:zombies].each do |z|
161
+ short_path = z[:path].length > 34 ? "#{z[:path][0..31]}..." : z[:path]
162
+
163
+ action_badge = case z[:action]
164
+ when :purge_410 then Color.red("[PURGE 410]")
165
+ when :redirect_301 then Color.yellow("[REDIRECT 301]")
166
+ when :noindex then Color.cyan("[NOINDEX]")
167
+ else Color.gray("[CONSOLIDATE]")
168
+ end
169
+
170
+ target_info = if z[:action] == :redirect_301 && z[:target_url]
171
+ t_path = URI.parse(z[:target_url]).path rescue z[:target_url]
172
+ "-> #{t_path}"
173
+ else
174
+ z[:rationale]
175
+ end
176
+ short_target = target_info.length > 55 ? "#{target_info[0..52]}..." : target_info
177
+
178
+ printf " %-36s %-25s %s\n", short_path, action_badge, short_target
179
+ end
180
+
181
+ puts "\n#{Color.bold("───────────────────────────────────────────────────────────────────────────")}"
182
+ puts "💡 #{Color.bold("1-Click Server Purge Exports:")}"
183
+ puts " • Generate Nginx configuration: #{Color.cyan("gsc zombies --format nginx")}"
184
+ puts " • Generate Apache .htaccess rules: #{Color.cyan("gsc zombies --format htaccess")}"
185
+ puts " • Generate Netlify/Cloudflare rules: #{Color.cyan("gsc zombies --format redirects")}"
186
+ puts " • Export full CSV audit report: #{Color.cyan("gsc zombies --csv zombies.csv")}"
187
+ puts "#{Color.bold("───────────────────────────────────────────────────────────────────────────")}\n"
188
+ end
189
+
190
+ def export_csv(res, file_path)
191
+ headers = %w[URL Path Action ActionLabel Impressions TargetURL Rationale]
192
+ rows = res[:zombies].map do |z|
193
+ [
194
+ z[:url],
195
+ z[:path],
196
+ z[:action].to_s,
197
+ z[:action_label],
198
+ z[:impressions],
199
+ z[:target_url] || '',
200
+ z[:rationale]
201
+ ]
202
+ end
203
+ Base.write_csv(file_path, headers, rows)
204
+ puts Color.cyan("📁 Successfully exported #{rows.size} zombie audit records to #{file_path}")
205
+ end
206
+ end
207
+ end
208
+ end