gsc-cli 2.1.0 → 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/README.md +410 -414
- data/bin/gsc +28067 -5661
- data/dist/gsc +29121 -5046
- data/lib/gsc/aio_hunter.rb +343 -0
- data/lib/gsc/answer_synthesizer.rb +157 -0
- data/lib/gsc/api.rb +53 -1
- data/lib/gsc/auth.rb +26 -0
- data/lib/gsc/brand_segmenter.rb +140 -0
- data/lib/gsc/cache_manager.rb +806 -0
- data/lib/gsc/cannibalization_analyzer.rb +141 -0
- data/lib/gsc/canonical_chains.rb +367 -0
- data/lib/gsc/citation_simulator.rb +339 -0
- data/lib/gsc/cli/aio_hunter.rb +154 -0
- data/lib/gsc/cli/analytics.rb +788 -0
- data/lib/gsc/cli/audit.rb +1976 -0
- data/lib/gsc/cli/base.rb +384 -0
- data/lib/gsc/cli/cache.rb +266 -0
- data/lib/gsc/cli/canonical.rb +223 -0
- data/lib/gsc/cli/citation_simulator.rb +152 -0
- data/lib/gsc/cli/dashboard.rb +354 -0
- data/lib/gsc/cli/doctor.rb +129 -0
- data/lib/gsc/cli/eeat.rb +125 -0
- data/lib/gsc/cli/ga4.rb +852 -0
- data/lib/gsc/cli/growth.rb +650 -0
- data/lib/gsc/cli/hreflang.rb +164 -0
- data/lib/gsc/cli/image_seo.rb +162 -0
- data/lib/gsc/cli/indexing.rb +458 -0
- data/lib/gsc/cli/intent_shift.rb +125 -0
- data/lib/gsc/cli/keyword_value.rb +134 -0
- data/lib/gsc/cli/keywords.rb +795 -0
- data/lib/gsc/cli/landing_roi.rb +308 -0
- data/lib/gsc/cli/low_ctr.rb +213 -0
- data/lib/gsc/cli/mobile_parity.rb +150 -0
- data/lib/gsc/cli/report.rb +100 -0
- data/lib/gsc/cli/rich_results.rb +172 -0
- data/lib/gsc/cli/schema_generate.rb +149 -0
- data/lib/gsc/cli/seasonal.rb +232 -0
- data/lib/gsc/cli/security.rb +153 -0
- data/lib/gsc/cli/setup.rb +1291 -0
- data/lib/gsc/cli/sitemap_tree.rb +143 -0
- data/lib/gsc/cli/skill_pack.rb +62 -0
- data/lib/gsc/cli/soft_404.rb +199 -0
- data/lib/gsc/cli/sparkline.rb +227 -0
- data/lib/gsc/cli/watchdog.rb +150 -0
- data/lib/gsc/cli/zombie_purger.rb +208 -0
- data/lib/gsc/cli.rb +707 -5265
- data/lib/gsc/cli_advanced.rb +987 -44
- data/lib/gsc/client.rb +17 -2
- data/lib/gsc/color.rb +16 -1
- data/lib/gsc/command_registry.rb +47 -9
- data/lib/gsc/config.rb +2 -2
- data/lib/gsc/ctr_curve.rb +115 -0
- data/lib/gsc/decay_predictor.rb +322 -0
- data/lib/gsc/doctor.rb +434 -0
- data/lib/gsc/eeat_auditor.rb +428 -0
- data/lib/gsc/entity_auditor.rb +229 -0
- data/lib/gsc/firewall_scanner.rb +733 -0
- data/lib/gsc/geo_auditor.rb +368 -0
- data/lib/gsc/google_trends.rb +8 -1
- data/lib/gsc/heading_validator.rb +283 -0
- data/lib/gsc/hreflang_validator.rb +412 -0
- data/lib/gsc/image_seo.rb +286 -0
- data/lib/gsc/indexing_queue.rb +179 -0
- data/lib/gsc/indexnow.rb +93 -0
- data/lib/gsc/intent_shift.rb +188 -0
- data/lib/gsc/internal_links.rb +153 -36
- data/lib/gsc/keyword_value.rb +191 -0
- data/lib/gsc/landing_roi.rb +195 -0
- data/lib/gsc/llms_generator.rb +343 -22
- data/lib/gsc/low_ctr_rewriter.rb +408 -0
- data/lib/gsc/mobile_parity.rb +222 -0
- data/lib/gsc/network_tracer.rb +8 -1
- data/lib/gsc/page_analyzer.rb +47 -7
- data/lib/gsc/prompts.rb +38 -29
- data/lib/gsc/questions_harvester.rb +178 -0
- data/lib/gsc/report_generator.rb +461 -0
- data/lib/gsc/rich_results.rb +388 -0
- data/lib/gsc/robots_checker.rb +46 -15
- data/lib/gsc/schema_generator.rb +788 -0
- data/lib/gsc/schema_validator.rb +36 -38
- data/lib/gsc/seasonal_predictor.rb +381 -0
- data/lib/gsc/security_scanner.rb +496 -0
- data/lib/gsc/serp_feature_detector.rb +359 -0
- data/lib/gsc/serp_preview.rb +108 -22
- data/lib/gsc/site_crawler.rb +113 -21
- data/lib/gsc/sitemap_loader.rb +15 -4
- data/lib/gsc/sitemap_tree.rb +301 -0
- data/lib/gsc/skill_pack.rb +195 -0
- data/lib/gsc/soft_404_analyzer.rb +385 -0
- data/lib/gsc/sparkline.rb +171 -0
- data/lib/gsc/speed_correlator.rb +416 -0
- data/lib/gsc/striking_playbook.rb +190 -0
- data/lib/gsc/title_optimizer.rb +420 -0
- data/lib/gsc/vault.rb +260 -0
- data/lib/gsc/version.rb +1 -1
- data/lib/gsc/watchdog.rb +235 -0
- data/lib/gsc/zombie_purger.rb +366 -0
- data/lib/gsc.rb +118 -0
- metadata +75 -1
|
@@ -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
|