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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/AUTH.md +4 -1
  3. data/README.md +448 -408
  4. data/bin/gsc +28067 -5661
  5. data/dist/gsc +29121 -5046
  6. data/lib/gsc/aio_hunter.rb +343 -0
  7. data/lib/gsc/answer_synthesizer.rb +157 -0
  8. data/lib/gsc/api.rb +53 -1
  9. data/lib/gsc/auth.rb +26 -0
  10. data/lib/gsc/brand_segmenter.rb +140 -0
  11. data/lib/gsc/cache_manager.rb +806 -0
  12. data/lib/gsc/cannibalization_analyzer.rb +141 -0
  13. data/lib/gsc/canonical_chains.rb +367 -0
  14. data/lib/gsc/citation_simulator.rb +339 -0
  15. data/lib/gsc/cli/aio_hunter.rb +154 -0
  16. data/lib/gsc/cli/analytics.rb +788 -0
  17. data/lib/gsc/cli/audit.rb +1976 -0
  18. data/lib/gsc/cli/base.rb +384 -0
  19. data/lib/gsc/cli/cache.rb +266 -0
  20. data/lib/gsc/cli/canonical.rb +223 -0
  21. data/lib/gsc/cli/citation_simulator.rb +152 -0
  22. data/lib/gsc/cli/dashboard.rb +354 -0
  23. data/lib/gsc/cli/doctor.rb +129 -0
  24. data/lib/gsc/cli/eeat.rb +125 -0
  25. data/lib/gsc/cli/ga4.rb +852 -0
  26. data/lib/gsc/cli/growth.rb +650 -0
  27. data/lib/gsc/cli/hreflang.rb +164 -0
  28. data/lib/gsc/cli/image_seo.rb +162 -0
  29. data/lib/gsc/cli/indexing.rb +458 -0
  30. data/lib/gsc/cli/intent_shift.rb +125 -0
  31. data/lib/gsc/cli/keyword_value.rb +134 -0
  32. data/lib/gsc/cli/keywords.rb +795 -0
  33. data/lib/gsc/cli/landing_roi.rb +308 -0
  34. data/lib/gsc/cli/low_ctr.rb +213 -0
  35. data/lib/gsc/cli/mobile_parity.rb +150 -0
  36. data/lib/gsc/cli/report.rb +100 -0
  37. data/lib/gsc/cli/rich_results.rb +172 -0
  38. data/lib/gsc/cli/schema_generate.rb +149 -0
  39. data/lib/gsc/cli/seasonal.rb +232 -0
  40. data/lib/gsc/cli/security.rb +153 -0
  41. data/lib/gsc/cli/setup.rb +1291 -0
  42. data/lib/gsc/cli/sitemap_tree.rb +143 -0
  43. data/lib/gsc/cli/skill_pack.rb +62 -0
  44. data/lib/gsc/cli/soft_404.rb +199 -0
  45. data/lib/gsc/cli/sparkline.rb +227 -0
  46. data/lib/gsc/cli/watchdog.rb +150 -0
  47. data/lib/gsc/cli/zombie_purger.rb +208 -0
  48. data/lib/gsc/cli.rb +707 -5265
  49. data/lib/gsc/cli_advanced.rb +987 -44
  50. data/lib/gsc/client.rb +17 -2
  51. data/lib/gsc/color.rb +16 -1
  52. data/lib/gsc/command_registry.rb +47 -9
  53. data/lib/gsc/config.rb +2 -2
  54. data/lib/gsc/ctr_curve.rb +115 -0
  55. data/lib/gsc/decay_predictor.rb +322 -0
  56. data/lib/gsc/doctor.rb +434 -0
  57. data/lib/gsc/eeat_auditor.rb +428 -0
  58. data/lib/gsc/entity_auditor.rb +229 -0
  59. data/lib/gsc/firewall_scanner.rb +733 -0
  60. data/lib/gsc/geo_auditor.rb +368 -0
  61. data/lib/gsc/google_trends.rb +8 -1
  62. data/lib/gsc/heading_validator.rb +283 -0
  63. data/lib/gsc/hreflang_validator.rb +412 -0
  64. data/lib/gsc/image_seo.rb +286 -0
  65. data/lib/gsc/indexing_queue.rb +179 -0
  66. data/lib/gsc/indexnow.rb +93 -0
  67. data/lib/gsc/intent_shift.rb +188 -0
  68. data/lib/gsc/internal_links.rb +153 -36
  69. data/lib/gsc/keyword_value.rb +191 -0
  70. data/lib/gsc/landing_roi.rb +195 -0
  71. data/lib/gsc/llms_generator.rb +343 -22
  72. data/lib/gsc/low_ctr_rewriter.rb +408 -0
  73. data/lib/gsc/mobile_parity.rb +222 -0
  74. data/lib/gsc/network_tracer.rb +8 -1
  75. data/lib/gsc/page_analyzer.rb +47 -7
  76. data/lib/gsc/prompts.rb +38 -29
  77. data/lib/gsc/questions_harvester.rb +178 -0
  78. data/lib/gsc/report_generator.rb +461 -0
  79. data/lib/gsc/rich_results.rb +388 -0
  80. data/lib/gsc/robots_checker.rb +46 -15
  81. data/lib/gsc/schema_generator.rb +788 -0
  82. data/lib/gsc/schema_validator.rb +36 -38
  83. data/lib/gsc/seasonal_predictor.rb +381 -0
  84. data/lib/gsc/security_scanner.rb +496 -0
  85. data/lib/gsc/serp_feature_detector.rb +359 -0
  86. data/lib/gsc/serp_preview.rb +108 -22
  87. data/lib/gsc/site_crawler.rb +113 -21
  88. data/lib/gsc/sitemap_loader.rb +15 -4
  89. data/lib/gsc/sitemap_tree.rb +301 -0
  90. data/lib/gsc/skill_pack.rb +195 -0
  91. data/lib/gsc/soft_404_analyzer.rb +385 -0
  92. data/lib/gsc/sparkline.rb +171 -0
  93. data/lib/gsc/speed_correlator.rb +416 -0
  94. data/lib/gsc/striking_playbook.rb +190 -0
  95. data/lib/gsc/title_optimizer.rb +420 -0
  96. data/lib/gsc/vault.rb +260 -0
  97. data/lib/gsc/version.rb +1 -1
  98. data/lib/gsc/watchdog.rb +235 -0
  99. data/lib/gsc/zombie_purger.rb +366 -0
  100. data/lib/gsc.rb +118 -0
  101. 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