gsc-cli 2.1.0 → 2.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AUTH.md +4 -1
- data/README.md +448 -408
- 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,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
|