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,458 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'cgi'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require_relative 'base'
|
|
7
|
+
require_relative '../indexing_queue' if File.exist?(File.expand_path('../indexing_queue.rb', __dir__))
|
|
8
|
+
require_relative '../sitemap_loader' if File.exist?(File.expand_path('../sitemap_loader.rb', __dir__))
|
|
9
|
+
require_relative '../indexnow' if File.exist?(File.expand_path('../indexnow.rb', __dir__))
|
|
10
|
+
|
|
11
|
+
module GSC
|
|
12
|
+
class CLI
|
|
13
|
+
module Indexing
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def run(command, target, extra, options, api, site_url, hostname, https_origin)
|
|
17
|
+
case command
|
|
18
|
+
when 'inspect', 'i'
|
|
19
|
+
handle_inspect(target, api, site_url, hostname, options)
|
|
20
|
+
when 'inspect-sitemap'
|
|
21
|
+
handle_inspect_sitemap(target, api, site_url, hostname, https_origin, options)
|
|
22
|
+
when 'index', 'idx'
|
|
23
|
+
handle_index(target, api, hostname, options)
|
|
24
|
+
when 'index-batch', 'queue', 'batch-index'
|
|
25
|
+
handle_index_batch(target, extra, api, https_origin, options)
|
|
26
|
+
when 'remove', 'rm'
|
|
27
|
+
handle_remove(target, api, hostname, options)
|
|
28
|
+
when 'status'
|
|
29
|
+
handle_status(target, api, hostname, options)
|
|
30
|
+
when 'sitemaps-list', 'sitemaps'
|
|
31
|
+
handle_sitemaps_list(api, site_url, options)
|
|
32
|
+
when 'sitemaps-submit'
|
|
33
|
+
handle_sitemaps_submit(target, api, site_url, https_origin, options)
|
|
34
|
+
when 'auto', 'index-sitemap'
|
|
35
|
+
handle_index_sitemap(target, api, hostname, https_origin, options)
|
|
36
|
+
when 'indexnow', 'in'
|
|
37
|
+
handle_indexnow(target, extra, options)
|
|
38
|
+
when 'indexnow-sitemap', 'ins'
|
|
39
|
+
handle_indexnow_sitemap(target, options)
|
|
40
|
+
else
|
|
41
|
+
raise "Unknown indexing command: #{command}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def handle_inspect(target, api, site_url, hostname, options)
|
|
46
|
+
Base.require_target!(target, "inspect https://#{hostname}/page", options)
|
|
47
|
+
puts "🔍 Inspecting URL via Search Console API:\n #{Color.c(target, Color::CYAN)} (Property: #{site_url})\n" unless options[:json]
|
|
48
|
+
res = api.inspect_url(target, site_url)
|
|
49
|
+
if options[:json]
|
|
50
|
+
r = res.dig(:data, 'inspectionResult', 'indexStatusResult') || {}
|
|
51
|
+
rich_test_url = "https://search.google.com/test/rich-results?url=#{CGI.escape(target)}"
|
|
52
|
+
gsc_inspect_url = "https://search.google.com/search-console/inspect?resource_id=#{CGI.escape(site_url)}&id=#{CGI.escape(target)}"
|
|
53
|
+
puts JSON.pretty_generate(r.merge('url' => target, 'ok' => res[:ok], 'status' => res[:status], 'google_rich_results_test_url' => rich_test_url, 'gsc_web_inspection_url' => gsc_inspect_url))
|
|
54
|
+
elsif res[:ok]
|
|
55
|
+
r = res.dig(:data, 'inspectionResult', 'indexStatusResult')
|
|
56
|
+
if r
|
|
57
|
+
verdict_color = (r['verdict'] == 'PASS') ? Color::GREEN : Color::YELLOW
|
|
58
|
+
puts Color.c('📋 INDEXATION DIAGNOSTICS:', Color::BOLD)
|
|
59
|
+
puts " Verdict: #{Color.c(r['verdict'], verdict_color, Color::BOLD)}"
|
|
60
|
+
puts " Coverage Status: #{r['coverageState'] || 'Unknown'}"
|
|
61
|
+
puts " Indexing Allowed: #{r['indexingState'] == 'INDEXING_ALLOWED' ? Color.c('ALLOWED', Color::GREEN) : Color.c(r['indexingState'] || 'NO', Color::RED)}"
|
|
62
|
+
puts " Robots.txt: #{r['robotsTxtState'] == 'ALLOWED' ? Color.c('ALLOWED', Color::GREEN) : Color.c(r['robotsTxtState'] || 'BLOCKED', Color::RED)}"
|
|
63
|
+
puts " Last Crawled: #{Color.c(r['lastCrawlTime'] || 'Never / Not yet recorded', Color::CYAN)}"
|
|
64
|
+
puts " User Canonical: #{r['userCanonical'] || 'None specified'}"
|
|
65
|
+
puts " Google Canonical: #{r['googleCanonical'] || 'None assigned'}"
|
|
66
|
+
|
|
67
|
+
rich_test_url = "https://search.google.com/test/rich-results?url=#{CGI.escape(target)}"
|
|
68
|
+
gsc_inspect_url = "https://search.google.com/search-console/inspect?resource_id=#{CGI.escape(site_url)}&id=#{CGI.escape(target)}"
|
|
69
|
+
puts "\n" + Color.c('🔗 OFFICIAL GOOGLE VERIFICATION TOOLS:', Color::BOLD)
|
|
70
|
+
puts " Rich Results Test: #{Color.c(rich_test_url, Color::CYAN)}"
|
|
71
|
+
puts " GSC Web Inspection: #{Color.c(gsc_inspect_url, Color::CYAN)}"
|
|
72
|
+
if options[:open]
|
|
73
|
+
puts Color.green("\n🚀 Opening Google Rich Results Test in your browser...")
|
|
74
|
+
Base.open_in_browser(rich_test_url)
|
|
75
|
+
else
|
|
76
|
+
puts Color.gray("\n 💡 Tip: Pass '--open' (or '-o') to launch the Rich Results Test in your browser.")
|
|
77
|
+
end
|
|
78
|
+
else
|
|
79
|
+
puts JSON.pretty_generate(res[:data])
|
|
80
|
+
end
|
|
81
|
+
else
|
|
82
|
+
puts Color.c("❌ Inspection Error (#{res[:status]}): #{res[:data]}", Color::RED)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def handle_inspect_sitemap(target, api, site_url, hostname, https_origin, options)
|
|
87
|
+
puts "📥 Loading sitemap for bulk inspection on #{Color.c(hostname, Color::CYAN)}..." unless options[:json]
|
|
88
|
+
urls = SitemapLoader.resolve_urls(target, https_origin, quiet: options[:json])
|
|
89
|
+
puts "🔍 Bulk inspecting #{Color.c(urls.size.to_s, Color::BOLD)} URLs via Search Console API (#{site_url})...\n" unless options[:json]
|
|
90
|
+
|
|
91
|
+
results = []
|
|
92
|
+
pass_count = 0
|
|
93
|
+
queue_count = 0
|
|
94
|
+
error_count = 0
|
|
95
|
+
|
|
96
|
+
unless options[:json]
|
|
97
|
+
puts Color.c(" # | Status | Last Crawl | Coverage Status | URL", Color::DIM)
|
|
98
|
+
puts " ------------------------------------------------------------------------------------------------"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
urls.each_with_index do |u, idx|
|
|
102
|
+
idx_str = (idx + 1).to_s.rjust(4)
|
|
103
|
+
res = api.inspect_url(u, site_url)
|
|
104
|
+
|
|
105
|
+
if res[:ok]
|
|
106
|
+
r = res.dig(:data, 'inspectionResult', 'indexStatusResult')
|
|
107
|
+
verdict = r ? r['verdict'] : 'UNKNOWN'
|
|
108
|
+
coverage = ((r && r['coverageState']) || 'Unknown').ljust(35)
|
|
109
|
+
last_cr = (r && r['lastCrawlTime']) ? r['lastCrawlTime'].split('T').first : 'Never '
|
|
110
|
+
|
|
111
|
+
if verdict == 'PASS'
|
|
112
|
+
pass_count += 1
|
|
113
|
+
puts " #{idx_str} | #{Color.c('✅ PASS', Color::GREEN)} | #{last_cr} | #{coverage} | #{u}" unless options[:json]
|
|
114
|
+
else
|
|
115
|
+
queue_count += 1
|
|
116
|
+
puts " #{idx_str} | #{Color.c('⏳ QUEUE', Color::YELLOW)} | #{last_cr} | #{coverage} | #{u}" unless options[:json]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
results << {
|
|
120
|
+
url: u,
|
|
121
|
+
verdict: verdict,
|
|
122
|
+
coverage: r&.dig('coverageState') || 'Unknown',
|
|
123
|
+
last_crawl: r&.dig('lastCrawlTime') || 'Never',
|
|
124
|
+
robots_txt: r&.dig('robotsTxtState') || 'Unknown'
|
|
125
|
+
}
|
|
126
|
+
else
|
|
127
|
+
error_count += 1
|
|
128
|
+
puts " #{idx_str} | #{Color.c('❌ ERROR', Color::RED)} | ---------- | HTTP #{res[:status].to_s.ljust(30)} | #{u}" unless options[:json]
|
|
129
|
+
results << { url: u, verdict: 'ERROR', error: "HTTP #{res[:status]}" }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
sleep(options[:delay] / 1000.0) if options[:delay] > 0
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
if options[:json]
|
|
136
|
+
puts JSON.pretty_generate({ total: urls.size, indexedPass: pass_count, pendingQueue: queue_count, errors: error_count, urls: results })
|
|
137
|
+
else
|
|
138
|
+
puts "\n#{Color::BOLD}══════════════════════════════════════════════════════════════#{Color::RESET}"
|
|
139
|
+
puts "#{Color::BOLD}📊 BULK INDEXATION SUMMARY (#{urls.size} URLs)#{Color::RESET}"
|
|
140
|
+
puts "#{Color::BOLD}══════════════════════════════════════════════════════════════#{Color::RESET}"
|
|
141
|
+
puts " ✅ Indexed on Google (PASS): #{Color.c(pass_count.to_s, Color::GREEN, Color::BOLD)} (#{'%.1f' % ((pass_count.to_f / urls.size) * 100)}%)"
|
|
142
|
+
puts " ⏳ Pending / Queue: #{Color.c(queue_count.to_s, Color::YELLOW, Color::BOLD)} (#{'%.1f' % ((queue_count.to_f / urls.size) * 100)}%)"
|
|
143
|
+
puts " ❌ Errors: #{Color.c(error_count.to_s, Color::RED, Color::BOLD)}" if error_count > 0
|
|
144
|
+
puts "#{Color::BOLD}══════════════════════════════════════════════════════════════#{Color::RESET}\n"
|
|
145
|
+
|
|
146
|
+
if options[:csv]
|
|
147
|
+
Base.write_csv(options[:csv], %w[URL Verdict Coverage LastCrawl RobotsTxt], results.map(&:values))
|
|
148
|
+
puts Color.c("📁 Exported audit table to #{options[:csv]}\n", Color::CYAN)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def handle_index(target, api, hostname, options)
|
|
154
|
+
Base.require_target!(target, "index https://#{hostname}/new-page", options)
|
|
155
|
+
puts "🚀 Sending #{Color::BOLD}URL_UPDATED#{Color::RESET} indexing request for:\n #{Color.c(target, Color::CYAN)}" unless options[:json]
|
|
156
|
+
return if Base.dry_run?(options)
|
|
157
|
+
|
|
158
|
+
res = api.publish_url(target, 'URL_UPDATED')
|
|
159
|
+
if options[:json]
|
|
160
|
+
puts JSON.pretty_generate(res[:data] || { ok: res[:ok], status: res[:status] })
|
|
161
|
+
elsif res[:ok]
|
|
162
|
+
puts Color.c("\n✅ Success! Google Indexing API accepted the URL (HTTP 200).", Color::GREEN)
|
|
163
|
+
notify_time = res.dig(:data, 'urlNotificationMetadata', 'latestUpdate', 'notifyTime') || Time.now.iso8601
|
|
164
|
+
puts " Notify Time: #{notify_time}"
|
|
165
|
+
puts Color.c("\nGooglebot will prioritize crawling this URL shortly.", Color::DIM)
|
|
166
|
+
else
|
|
167
|
+
puts Color.c("\n❌ Indexing API Error (#{res[:status]}): #{res[:data]}", Color::RED)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def handle_index_batch(target, extra, api, https_origin, options)
|
|
172
|
+
queue = GSC::IndexingQueue.new
|
|
173
|
+
action = target.to_s.strip.downcase
|
|
174
|
+
|
|
175
|
+
case action
|
|
176
|
+
when 'add', 'enqueue'
|
|
177
|
+
input_source = extra || target
|
|
178
|
+
urls = []
|
|
179
|
+
if input_source && (input_source.end_with?('.xml') || input_source.include?('sitemap'))
|
|
180
|
+
puts "📥 Extracting URLs from XML sitemap: #{Color.c(input_source, Color::CYAN)}..." unless options[:json]
|
|
181
|
+
urls = SitemapLoader.resolve_urls(input_source, https_origin, quiet: options[:json])
|
|
182
|
+
elsif input_source && File.exist?(input_source)
|
|
183
|
+
puts "📥 Reading URLs from local file: #{Color.c(input_source, Color::CYAN)}..." unless options[:json]
|
|
184
|
+
urls = File.readlines(input_source, chomp: true).map(&:strip).reject(&:empty?)
|
|
185
|
+
elsif input_source && input_source.start_with?('http://', 'https://')
|
|
186
|
+
urls = [input_source]
|
|
187
|
+
elsif !$stdin.tty?
|
|
188
|
+
urls = $stdin.readlines.map(&:strip).reject(&:empty?)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
added = queue.add_urls(urls)
|
|
192
|
+
st = queue.status
|
|
193
|
+
if options[:json]
|
|
194
|
+
puts JSON.pretty_generate({ added: added, queue_status: st })
|
|
195
|
+
else
|
|
196
|
+
puts Color.c("✅ Successfully enqueued #{added} new URLs into the indexing queue.", Color::GREEN)
|
|
197
|
+
puts " Pending in queue: #{Color.c(st[:pending_count].to_s, Color::CYAN)}"
|
|
198
|
+
puts " Daily quota remaining: #{Color.c(st[:daily_quota_remaining].to_s, Color::YELLOW)} / #{st[:daily_quota_limit]}"
|
|
199
|
+
puts "\nTo process pending queue, run: #{Color.c('gsc index-batch run', Color::CYAN)}"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
when 'run', 'process', 'execute'
|
|
203
|
+
batch_size = options[:batch_size] || 50
|
|
204
|
+
puts "🚀 Processing Google Indexing queue (Batch size: #{batch_size}, Dry run: #{options[:dry_run] || false})...\n" unless options[:json]
|
|
205
|
+
|
|
206
|
+
res = queue.process_batch(api, batch_size: batch_size, dry_run: options[:dry_run])
|
|
207
|
+
if options[:json]
|
|
208
|
+
puts JSON.pretty_generate(res)
|
|
209
|
+
else
|
|
210
|
+
if res[:status] == :queue_empty
|
|
211
|
+
puts Color.c("ℹ️ #{res[:message]}", Color::YELLOW)
|
|
212
|
+
elsif res[:status] == :quota_exhausted
|
|
213
|
+
puts Color.c("⚠️ #{res[:message]}", Color::YELLOW, Color::BOLD)
|
|
214
|
+
else
|
|
215
|
+
puts Color.c("✅ Batch processed: #{res[:successful]} succeeded, #{res[:failed]} failed.", res[:failed] > 0 ? Color::YELLOW : Color::GREEN)
|
|
216
|
+
puts " Remaining in queue: #{Color.c(res[:remaining_in_queue].to_s, Color::CYAN)}"
|
|
217
|
+
puts " Today's quota remaining: #{Color.c(res[:daily_quota_remaining].to_s, Color::YELLOW)} / 200 URLs"
|
|
218
|
+
if res[:results].any? && !options[:dry_run]
|
|
219
|
+
puts "\nRecent Submissions:"
|
|
220
|
+
res[:results].first(5).each do |r|
|
|
221
|
+
status_col = r[:ok] ? Color.c('✔ 200 OK', Color::GREEN) : Color.c("✖ #{r[:error]}", Color::RED)
|
|
222
|
+
puts " • #{r[:url]} [#{status_col}]"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
when 'clear', 'reset'
|
|
229
|
+
queue.clear(:all)
|
|
230
|
+
if options[:json]
|
|
231
|
+
puts JSON.pretty_generate({ ok: true, message: 'Queue cleared' })
|
|
232
|
+
else
|
|
233
|
+
puts Color.c("🗑️ Indexing queue cleared.", Color::GREEN)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
else
|
|
237
|
+
st = queue.status
|
|
238
|
+
if options[:json]
|
|
239
|
+
puts JSON.pretty_generate(st)
|
|
240
|
+
else
|
|
241
|
+
puts "📋 #{Color::BOLD}Google Indexing API Batch Queue Status#{Color::RESET}\n"
|
|
242
|
+
puts " Pending URLs: #{Color.c(st[:pending_count].to_s, Color::CYAN, Color::BOLD)}"
|
|
243
|
+
puts " Submitted Today: #{Color.c(st[:daily_quota_used].to_s, Color::MAGENTA)}"
|
|
244
|
+
puts " Daily Quota Remaining: #{Color.c("#{st[:daily_quota_remaining]} / #{st[:daily_quota_limit]} URLs", Color::YELLOW, Color::BOLD)}"
|
|
245
|
+
puts " Total Ever Submitted: #{Color.c(st[:submitted_count].to_s, Color::GRAY)}"
|
|
246
|
+
puts " Total Failed: #{Color.c(st[:failed_count].to_s, Color::GRAY)}"
|
|
247
|
+
puts " Quota Date: #{st[:last_reset_date]} (Resets daily at 00:00 UTC)"
|
|
248
|
+
puts "\nCommands:"
|
|
249
|
+
puts " • #{Color.c('gsc index-batch add <url|sitemap.xml|file.txt>', Color::CYAN)} - Enqueue URLs"
|
|
250
|
+
puts " • #{Color.c('gsc index-batch run [--batch-size 50] [--dry-run]', Color::CYAN)} - Process queue"
|
|
251
|
+
puts " • #{Color.c('gsc index-batch clear', Color::CYAN)} - Reset queue"
|
|
252
|
+
puts ""
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def handle_remove(target, api, hostname, options)
|
|
258
|
+
Base.require_target!(target, "remove https://#{hostname}/page", options)
|
|
259
|
+
puts "🗑️ Sending #{Color::BOLD}URL_DELETED#{Color::RESET} request for:\n #{Color.c(target, Color::RED)}" unless options[:json]
|
|
260
|
+
return if Base.dry_run?(options)
|
|
261
|
+
|
|
262
|
+
res = api.publish_url(target, 'URL_DELETED')
|
|
263
|
+
if options[:json]
|
|
264
|
+
puts JSON.pretty_generate(res[:data] || { ok: res[:ok], status: res[:status] })
|
|
265
|
+
elsif res[:ok]
|
|
266
|
+
puts Color.c("\n✅ Success! Google Indexing API recorded URL deletion.", Color::GREEN)
|
|
267
|
+
else
|
|
268
|
+
puts Color.c("\n❌ Indexing API Error (#{res[:status]}): #{res[:data]}", Color::RED)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def handle_status(target, api, hostname, options)
|
|
273
|
+
Base.require_target!(target, "status https://#{hostname}/page", options)
|
|
274
|
+
puts "🔍 Querying Indexing API metadata for:\n #{Color.c(target, Color::CYAN)}\n" unless options[:json]
|
|
275
|
+
res = api.get_url_status(target)
|
|
276
|
+
if options[:json]
|
|
277
|
+
puts JSON.pretty_generate(res[:data])
|
|
278
|
+
elsif res[:ok]
|
|
279
|
+
puts Color.c("✅ Latest Notification Record:", Color::GREEN)
|
|
280
|
+
puts JSON.pretty_generate(res[:data])
|
|
281
|
+
else
|
|
282
|
+
puts Color.c("❌ Error (#{res[:status]}): #{res[:data]}", Color::RED)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def handle_sitemaps_list(api, site_url, options)
|
|
287
|
+
puts "📑 Fetching submitted sitemaps for #{Color.c(site_url, Color::CYAN)}...\n" unless options[:json]
|
|
288
|
+
res = api.list_sitemaps(site_url)
|
|
289
|
+
if options[:json]
|
|
290
|
+
puts JSON.pretty_generate(res[:data] || {})
|
|
291
|
+
elsif res[:ok]
|
|
292
|
+
sitemaps = res.dig(:data, 'sitemap') || []
|
|
293
|
+
if sitemaps.empty?
|
|
294
|
+
puts Color.c("ℹ️ No sitemaps currently submitted in Search Console for this property.", Color::YELLOW)
|
|
295
|
+
else
|
|
296
|
+
puts "#{Color::BOLD}Type | Last Downloaded | Errors | Warnings | Path#{Color::RESET}"
|
|
297
|
+
puts "--------------------------------------------------------------------------------"
|
|
298
|
+
sitemaps.each do |sm|
|
|
299
|
+
type = (sm['type'] || 'sitemap').ljust(12)
|
|
300
|
+
last_dl = sm['lastDownloaded'] ? sm['lastDownloaded'].split('T').first : 'Never '
|
|
301
|
+
errors = (sm['errors'] || 0).to_s.ljust(6)
|
|
302
|
+
warns = (sm['warnings'] || 0).to_s.ljust(8)
|
|
303
|
+
puts "#{type} | #{last_dl} | #{errors} | #{warns} | #{Color.c(sm['path'], Color::CYAN)}"
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
puts
|
|
307
|
+
else
|
|
308
|
+
puts Color.c("❌ Error (#{res[:status]}): #{res[:data]}", Color::RED)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def handle_sitemaps_submit(target, api, site_url, https_origin, options)
|
|
313
|
+
sitemap_url = target || "#{https_origin}/sitemap.xml"
|
|
314
|
+
puts "📤 Submitting sitemap to Google Search Console:\n #{Color.c(sitemap_url, Color::CYAN)} (Property: #{site_url})\n" unless options[:json]
|
|
315
|
+
res = api.submit_sitemap(site_url, sitemap_url)
|
|
316
|
+
if options[:json]
|
|
317
|
+
puts JSON.pretty_generate({ ok: res[:ok], status: res[:status], sitemap: sitemap_url })
|
|
318
|
+
elsif res[:ok] || res[:status] == 204
|
|
319
|
+
puts Color.c("✅ Success! Sitemap registered with Google Search Console.", Color::GREEN)
|
|
320
|
+
else
|
|
321
|
+
puts Color.c("❌ Failed to submit sitemap (#{res[:status]}): #{res[:data]}", Color::RED)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def handle_index_sitemap(target, api, hostname, https_origin, options)
|
|
326
|
+
puts "📥 Loading XML sitemap for #{Color.c(hostname, Color::CYAN)}..." unless options[:json]
|
|
327
|
+
urls = SitemapLoader.resolve_urls(target, https_origin, quiet: options[:json])
|
|
328
|
+
puts "📋 Found #{Color.c(urls.size.to_s, Color::BOLD)} URLs. Submitting batch to Google Indexing API...\n" unless options[:json]
|
|
329
|
+
|
|
330
|
+
success_count = 0
|
|
331
|
+
fail_count = 0
|
|
332
|
+
results = []
|
|
333
|
+
|
|
334
|
+
urls.each_with_index do |u, idx|
|
|
335
|
+
progress = "[#{idx + 1}/#{urls.size}]".rjust(urls.size.to_s.length + 3)
|
|
336
|
+
print " #{Color.c(progress, Color::GRAY)} #{u} " unless options[:json]
|
|
337
|
+
|
|
338
|
+
if options[:dry_run]
|
|
339
|
+
puts Color.c('[DRY RUN]', Color::YELLOW) unless options[:json]
|
|
340
|
+
success_count += 1
|
|
341
|
+
results << { url: u, status: 'dry_run' }
|
|
342
|
+
next
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
res = api.publish_url(u, 'URL_UPDATED')
|
|
346
|
+
if res[:ok]
|
|
347
|
+
success_count += 1
|
|
348
|
+
puts Color.c('✔ OK', Color::GREEN) unless options[:json]
|
|
349
|
+
results << { url: u, status: 'ok', notifyTime: res.dig(:data, 'urlNotificationMetadata', 'latestUpdate', 'notifyTime') }
|
|
350
|
+
else
|
|
351
|
+
fail_count += 1
|
|
352
|
+
msg = res.dig(:data, 'error', 'message') || "HTTP #{res[:status]}"
|
|
353
|
+
puts Color.c("✖ Failed (#{msg})", Color::RED) unless options[:json]
|
|
354
|
+
results << { url: u, status: 'failed', error: msg }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
sleep(options[:delay] / 1000.0) if options[:delay] > 0
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
if options[:json]
|
|
361
|
+
puts JSON.pretty_generate({ total: urls.size, success: success_count, failed: fail_count, results: results })
|
|
362
|
+
else
|
|
363
|
+
Base.print_batch_summary(success_count, fail_count, urls.size)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def handle_indexnow(target, extra, options)
|
|
368
|
+
if target == 'key' || target == 'connect'
|
|
369
|
+
if extra && !extra.strip.empty?
|
|
370
|
+
key = IndexNow.set_key(extra)
|
|
371
|
+
puts Color.c("✅ Successfully set IndexNow API key to: #{key}", Color::GREEN, Color::BOLD)
|
|
372
|
+
else
|
|
373
|
+
key = IndexNow.get_or_create_key
|
|
374
|
+
host = Config.default_domain || 'yourdomain.com'
|
|
375
|
+
puts "🔑 #{Color::BOLD}INDEXNOW API KEY & HOST SETUP:#{Color::RESET}"
|
|
376
|
+
puts " • Active Key: #{Color.c(key, Color::CYAN, Color::BOLD)}"
|
|
377
|
+
puts " • Host Verification: #{Color.c("https://#{host}/#{key}.txt", Color::BLUE)}"
|
|
378
|
+
puts " • Required Content: #{Color.c(key, Color::BOLD)}"
|
|
379
|
+
puts "\n💡 #{Color.c('Verification Tip:', Color::YELLOW)} Create a plain text file at the root of your web server named '#{key}.txt' with '#{key}' as the content."
|
|
380
|
+
end
|
|
381
|
+
return
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
if target == 'sitemap' || target.to_s.end_with?('.xml')
|
|
385
|
+
sitemap_target = (target == 'sitemap') ? extra : target
|
|
386
|
+
handle_indexnow_sitemap(sitemap_target, options)
|
|
387
|
+
return
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
target_url = target || options[:url]
|
|
391
|
+
unless target_url
|
|
392
|
+
if Config.default_domain
|
|
393
|
+
target_url = "https://#{Config.default_domain}"
|
|
394
|
+
else
|
|
395
|
+
raise 'Please provide a URL to index (e.g. gsc indexnow https://example.com/new-page) or connect a default domain.'
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
urls = [target_url]
|
|
400
|
+
urls << extra if extra && extra.start_with?('http')
|
|
401
|
+
|
|
402
|
+
key = options[:key] || IndexNow.get_or_create_key
|
|
403
|
+
res = IndexNow.submit(urls, key: key)
|
|
404
|
+
|
|
405
|
+
if options[:json]
|
|
406
|
+
puts JSON.pretty_generate(res)
|
|
407
|
+
return
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
411
|
+
puts "⚡ #{Color::BOLD}INDEXNOW MULTI-SEARCH ENGINE SUBMISSION:#{Color::RESET}"
|
|
412
|
+
puts "─" * 75
|
|
413
|
+
status_color = res[:success] ? Color::GREEN : Color::RED
|
|
414
|
+
puts "Status : #{Color.c(res[:message], status_color, Color::BOLD)}"
|
|
415
|
+
puts "Submitted URLs : #{Color.c(res[:submitted_urls].to_s, Color::CYAN, Color::BOLD)}"
|
|
416
|
+
puts "Host : #{res[:host]}"
|
|
417
|
+
puts "Key Location : #{res[:key_location]}"
|
|
418
|
+
puts "Engines Notified: #{Color.c('Microsoft Bing, Yandex, Seznam, Naver', Color::BOLD)}"
|
|
419
|
+
puts "─" * 75
|
|
420
|
+
|
|
421
|
+
urls.each_with_index do |u, idx|
|
|
422
|
+
puts " #{idx + 1}. #{Color.c(u, Color::CYAN)}"
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
unless res[:success]
|
|
426
|
+
puts "\n#{Color.c('⚠️ Note:', Color::YELLOW)} Ensure you have created #{res[:key_location]} with content: #{res[:key]}"
|
|
427
|
+
end
|
|
428
|
+
puts ""
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def handle_indexnow_sitemap(target, options)
|
|
432
|
+
sitemap = target || options[:sitemap] || 'sitemap.xml'
|
|
433
|
+
key = options[:key] || IndexNow.get_or_create_key
|
|
434
|
+
limit = options[:limit] ? options[:limit].to_i : nil
|
|
435
|
+
|
|
436
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
437
|
+
puts "📄 #{Color::BOLD}BATCH INDEXNOW SITEMAP SUBMISSION:#{Color::RESET} #{Color.c(sitemap, Color::CYAN)}"
|
|
438
|
+
puts "─" * 75
|
|
439
|
+
|
|
440
|
+
res = IndexNow.submit_sitemap(sitemap, key: key, limit: limit)
|
|
441
|
+
|
|
442
|
+
if options[:json]
|
|
443
|
+
puts JSON.pretty_generate(res)
|
|
444
|
+
return
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
status_color = res[:success] ? Color::GREEN : Color::RED
|
|
448
|
+
puts "Status : #{Color.c(res[:message], status_color, Color::BOLD)}"
|
|
449
|
+
puts "URLs Submitted : #{Color.c(res[:submitted_urls].to_s, Color::GREEN, Color::BOLD)}"
|
|
450
|
+
puts "Host : #{res[:host]}"
|
|
451
|
+
puts "Engines Notified: #{Color.c('Microsoft Bing, Yandex, Seznam, Naver', Color::BOLD)}"
|
|
452
|
+
puts "─" * 75
|
|
453
|
+
puts "✨ All URLs pushed to IndexNow instant crawl queue."
|
|
454
|
+
puts ""
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'json'
|
|
5
|
+
require_relative 'base'
|
|
6
|
+
require_relative '../intent_shift'
|
|
7
|
+
require_relative '../color'
|
|
8
|
+
|
|
9
|
+
module GSC
|
|
10
|
+
class CLI
|
|
11
|
+
module IntentShift
|
|
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 intent-shift mysite.com or gsc use mysite.com' })
|
|
19
|
+
else
|
|
20
|
+
puts Color.c("❌ Error: Target domain required. Example: gsc intent-shift 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
|
+
puts "🎯 Tracking search intent drift & landing page mismatches for #{Color.cyan(target_domain)}..." unless options[:json]
|
|
27
|
+
|
|
28
|
+
res = GSC::IntentShift.analyze(options, api, target_domain)
|
|
29
|
+
|
|
30
|
+
if options[:json]
|
|
31
|
+
puts JSON.pretty_generate(res)
|
|
32
|
+
return
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
render_terminal(res)
|
|
36
|
+
|
|
37
|
+
if options[:csv]
|
|
38
|
+
export_csv(res, options[:csv])
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def render_terminal(res)
|
|
43
|
+
puts "\n#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}"
|
|
44
|
+
puts "#{Color.bold("🎯 SEARCH INTENT SHIFT & LANDING PAGE MISMATCH TRACKER")}"
|
|
45
|
+
puts "#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}"
|
|
46
|
+
|
|
47
|
+
risk_color = case res[:risk_grade]
|
|
48
|
+
when 'OPTIMAL' then Color.green(res[:risk_grade])
|
|
49
|
+
when 'MODERATE' then Color.yellow(res[:risk_grade])
|
|
50
|
+
else Color.red(res[:risk_grade])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
puts " • Intent Volatility Risk: [ #{Color.bold(risk_color)} ] #{res[:portfolio_volatility_pct]}% queries mismatched"
|
|
54
|
+
puts " • Total Analyzed Search Queries: #{Color.bold(res[:total_queries_analyzed].to_s)} queries"
|
|
55
|
+
puts " • High-Risk Intent Penalties: #{res[:high_risk_shifts_count] > 0 ? Color.red(res[:high_risk_shifts_count].to_s) : Color.green("0")}"
|
|
56
|
+
puts "#{Color.bold("───────────────────────────────────────────────────────────────────────────")}"
|
|
57
|
+
|
|
58
|
+
d = res[:intent_distribution]
|
|
59
|
+
puts " Portfolio Intent Distribution:"
|
|
60
|
+
puts " ℹ️ Informational Intent: #{Color.bold(d[:informational].to_s)} queries"
|
|
61
|
+
puts " 🛒 Transactional Buyer Intent: #{Color.bold(d[:transactional].to_s)} queries"
|
|
62
|
+
puts " ⚖️ Commercial Investigation: #{Color.bold(d[:commercial].to_s)} queries"
|
|
63
|
+
puts " 🧭 Navigational Brand Intent: #{Color.bold(d[:navigational].to_s)} queries"
|
|
64
|
+
puts "#{Color.bold("───────────────────────────────────────────────────────────────────────────")}\n"
|
|
65
|
+
|
|
66
|
+
if res[:shifts].empty?
|
|
67
|
+
puts " ℹ️ No search query intent shift data found for #{res[:domain]}."
|
|
68
|
+
puts " Connect Google Search Console credentials via `gsc setup` or specify a verified property with `--domain <domain>`."
|
|
69
|
+
puts "\n#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}\n"
|
|
70
|
+
return
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
puts "#{Color.bold("QUERY INTENT VS LANDING PAGE MATRIX:")}\n\n"
|
|
74
|
+
printf " %-30s %-8s %-8s %-9s %-12s %s\n", "Search Query", "Q-Intent", "P-Intent", "Position", "Status", "Diagnosis"
|
|
75
|
+
puts " --------------------------------------------------------------------------------------------------"
|
|
76
|
+
|
|
77
|
+
res[:shifts].each do |s|
|
|
78
|
+
q_str = s[:query].length > 28 ? "#{s[:query][0..25]}..." : s[:query]
|
|
79
|
+
status_str = case s[:risk_level]
|
|
80
|
+
when :high then Color.red("🚨 HIGH RISK")
|
|
81
|
+
when :medium then Color.yellow("⚠️ MISMATCH")
|
|
82
|
+
when :low then Color.cyan("ℹ️ MINOR")
|
|
83
|
+
else Color.green("✅ ALIGNED")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
diag = s[:diagnosis] || "Intent aligns with landing page experience"
|
|
87
|
+
short_diag = diag.length > 36 ? "#{diag[0..33]}..." : diag
|
|
88
|
+
|
|
89
|
+
printf " %-30s %-8s %-8s Pos %-5s %-12s %s\n", q_str, s[:query_intent].to_s[0..3].upcase, s[:page_intent].to_s[0..4].upcase, s[:position], status_str, short_diag
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
puts "\n#{Color.bold("───────────────────────────────────────────────────────────────────────────")}"
|
|
93
|
+
|
|
94
|
+
if res[:prescriptions].any?
|
|
95
|
+
puts "#{Color.bold("ALGORITHMIC INTENT RECOVERY ROADMAP:")}"
|
|
96
|
+
res[:prescriptions].each_with_index do |p, idx|
|
|
97
|
+
puts " #{idx + 1}. 💡 #{p}"
|
|
98
|
+
end
|
|
99
|
+
puts
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
puts "#{Color.bold("═══════════════════════════════════════════════════════════════════════════")}\n"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def export_csv(res, file_path)
|
|
106
|
+
headers = %w[Query QueryIntent Page PageIntent Position Impressions Clicks Risk Diagnosis]
|
|
107
|
+
rows = res[:shifts].map do |s|
|
|
108
|
+
[
|
|
109
|
+
s[:query],
|
|
110
|
+
s[:query_intent].to_s,
|
|
111
|
+
s[:page],
|
|
112
|
+
s[:page_intent].to_s,
|
|
113
|
+
s[:position],
|
|
114
|
+
s[:impressions],
|
|
115
|
+
s[:clicks],
|
|
116
|
+
s[:risk_level].to_s,
|
|
117
|
+
s[:diagnosis] || ''
|
|
118
|
+
]
|
|
119
|
+
end
|
|
120
|
+
Base.write_csv(file_path, headers, rows)
|
|
121
|
+
puts Color.cyan("📁 Successfully exported intent shift data to #{file_path}")
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|