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,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'date'
6
+ require 'time'
7
+
8
+ module GSC
9
+ class IndexingQueue
10
+ QUEUE_FILE = File.join(Config::CONFIG_DIR, 'indexing_queue.json')
11
+ DAILY_LIMIT = 200
12
+
13
+ attr_reader :state
14
+
15
+ def initialize(file_path = QUEUE_FILE)
16
+ @file_path = file_path
17
+ @state = load_state
18
+ end
19
+
20
+ def add_urls(urls)
21
+ normalized = Array(urls).map(&:to_s).map(&:strip).reject(&:empty?).uniq
22
+ # Only keep valid http(s) URLs
23
+ valid_urls = normalized.select { |u| u.start_with?('http://', 'https://') }
24
+
25
+ existing_pending = @state['pending'] || []
26
+ new_urls = valid_urls - existing_pending
27
+
28
+ @state['pending'] = existing_pending + new_urls
29
+ save_state
30
+ new_urls.size
31
+ end
32
+
33
+ def status
34
+ check_quota_reset!
35
+ used = @state['daily_quota_used'] || 0
36
+ remaining = [DAILY_LIMIT - used, 0].max
37
+
38
+ {
39
+ pending_count: (@state['pending'] || []).size,
40
+ submitted_count: (@state['submitted'] || []).size,
41
+ failed_count: (@state['failed'] || []).size,
42
+ daily_quota_limit: DAILY_LIMIT,
43
+ daily_quota_used: used,
44
+ daily_quota_remaining: remaining,
45
+ last_reset_date: @state['last_reset_date']
46
+ }
47
+ end
48
+
49
+ def clear(scope = :all)
50
+ if scope == :pending
51
+ @state['pending'] = []
52
+ else
53
+ @state['pending'] = []
54
+ @state['submitted'] = []
55
+ @state['failed'] = []
56
+ end
57
+ save_state
58
+ end
59
+
60
+ def process_batch(api, batch_size: 50, dry_run: false, delay_sec: 0.15)
61
+ check_quota_reset!
62
+ used = @state['daily_quota_used'] || 0
63
+ remaining_quota = [DAILY_LIMIT - used, 0].max
64
+
65
+ if remaining_quota <= 0 && !dry_run
66
+ return {
67
+ status: :quota_exhausted,
68
+ message: "Daily quota of #{DAILY_LIMIT} requests reached for today (#{@state['last_reset_date']}). Next reset at 00:00 UTC.",
69
+ processed: 0,
70
+ remaining_in_queue: (@state['pending'] || []).size
71
+ }
72
+ end
73
+
74
+ to_process_count = [batch_size.to_i, remaining_quota].min
75
+ urls = (@state['pending'] || []).shift(to_process_count)
76
+
77
+ if urls.empty?
78
+ return {
79
+ status: :queue_empty,
80
+ message: 'Indexing queue is empty. Use `gsc index-batch add <url|sitemap>` to enqueue URLs.',
81
+ processed: 0,
82
+ remaining_in_queue: 0
83
+ }
84
+ end
85
+
86
+ results = []
87
+ successful = 0
88
+ failed = 0
89
+
90
+ urls.each_with_index do |url, idx|
91
+ if dry_run
92
+ results << { url: url, status: 'DRY_RUN', ok: true }
93
+ successful += 1
94
+ next
95
+ end
96
+
97
+ res = api.publish_url(url, 'URL_UPDATED')
98
+ if res[:ok]
99
+ successful += 1
100
+ @state['submitted'] ||= []
101
+ @state['submitted'] << {
102
+ url: url,
103
+ status: res[:status],
104
+ submitted_at: Time.now.utc.iso8601
105
+ }
106
+ @state['daily_quota_used'] = (@state['daily_quota_used'] || 0) + 1
107
+ results << { url: url, status: 'SUCCESS', http_code: res[:status], ok: true }
108
+ else
109
+ failed += 1
110
+ @state['failed'] ||= []
111
+ @state['failed'] << {
112
+ url: url,
113
+ error: res[:data],
114
+ failed_at: Time.now.utc.iso8601
115
+ }
116
+ results << { url: url, status: 'FAILED', error: res[:data], ok: false }
117
+ end
118
+
119
+ sleep(delay_sec) if delay_sec > 0 && idx < urls.size - 1
120
+ end
121
+
122
+ # In dry_run, restore pending list so URLs aren't lost
123
+ if dry_run
124
+ @state['pending'] = urls + (@state['pending'] || [])
125
+ else
126
+ save_state
127
+ end
128
+
129
+ {
130
+ status: :success,
131
+ processed: urls.size,
132
+ successful: successful,
133
+ failed: failed,
134
+ dry_run: dry_run,
135
+ remaining_in_queue: (@state['pending'] || []).size,
136
+ daily_quota_remaining: dry_run ? remaining_quota : [DAILY_LIMIT - @state['daily_quota_used'], 0].max,
137
+ results: results
138
+ }
139
+ end
140
+
141
+ private
142
+
143
+ def check_quota_reset!
144
+ today = Date.today.to_s
145
+ if @state['last_reset_date'] != today
146
+ @state['last_reset_date'] = today
147
+ @state['daily_quota_used'] = 0
148
+ save_state
149
+ end
150
+ end
151
+
152
+ def load_state
153
+ return default_state unless File.exist?(@file_path)
154
+
155
+ data = JSON.parse(File.read(@file_path))
156
+ data.is_a?(Hash) ? data : default_state
157
+ rescue StandardError
158
+ default_state
159
+ end
160
+
161
+ def save_state
162
+ FileUtils.mkdir_p(File.dirname(@file_path))
163
+ File.write(@file_path, JSON.pretty_generate(@state))
164
+ rescue StandardError => e
165
+ # Silently handle disk write errors
166
+ end
167
+
168
+ def default_state
169
+ {
170
+ 'daily_quota_limit' => DAILY_LIMIT,
171
+ 'daily_quota_used' => 0,
172
+ 'last_reset_date' => Date.today.to_s,
173
+ 'pending' => [],
174
+ 'submitted' => [],
175
+ 'failed' => []
176
+ }
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+ require 'openssl'
7
+
8
+ module GSC
9
+ class IndexNow
10
+ ENDPOINT = 'https://api.indexnow.org/indexnow'
11
+
12
+ def self.generate_key
13
+ OpenSSL::Random.random_bytes(16).unpack1('H*')
14
+ end
15
+
16
+ def self.get_or_create_key
17
+ existing = Config.get('indexnow_key') || ENV['INDEXNOW_KEY']
18
+ return existing if existing && !existing.strip.empty?
19
+
20
+ new_key = generate_key
21
+ Config.set('indexnow_key', new_key)
22
+ new_key
23
+ end
24
+
25
+ def self.set_key(key)
26
+ clean = key.to_s.strip
27
+ Config.set('indexnow_key', clean)
28
+ clean
29
+ end
30
+
31
+ def self.submit(urls, key: nil, host: nil)
32
+ urls = Array(urls).map(&:to_s).map(&:strip).reject(&:empty?)
33
+ raise 'No URLs provided for IndexNow submission' if urls.empty?
34
+
35
+ first_uri = URI.parse(urls.first) rescue nil
36
+ detected_host = host || (first_uri ? first_uri.host : Config.default_domain)
37
+ raise 'Could not determine host for IndexNow submission. Please provide full URLs (e.g. https://example.com/page)' unless detected_host
38
+
39
+ detected_host = detected_host.sub(%r{^https?://}, '').sub(/^sc-domain:/, '').chomp('/')
40
+
41
+ active_key = key || get_or_create_key
42
+
43
+ payload = {
44
+ host: detected_host,
45
+ key: active_key,
46
+ keyLocation: "https://#{detected_host}/#{active_key}.txt",
47
+ urlList: urls
48
+ }
49
+
50
+ uri = URI(ENDPOINT)
51
+ http = Net::HTTP.new(uri.host, uri.port)
52
+ http.use_ssl = true
53
+ http.open_timeout = 10
54
+ http.read_timeout = 20
55
+
56
+ req = Net::HTTP::Post.new(uri.request_uri)
57
+ req['Content-Type'] = 'application/json; charset=utf-8'
58
+ req['User-Agent'] = 'gsc-cli IndexNow/1.0'
59
+ req.body = JSON.generate(payload)
60
+
61
+ res = http.request(req)
62
+
63
+ status_msg = case res.code.to_i
64
+ when 200 then 'OK (URLs submitted successfully)'
65
+ when 202 then 'Accepted (Key pending verification)'
66
+ when 400 then 'Bad Request (Invalid JSON or URL format)'
67
+ when 403 then "Forbidden (Key invalid or https://#{detected_host}/#{active_key}.txt missing)"
68
+ when 422 then 'Unprocessable Entity (URLs do not match host)'
69
+ when 429 then 'Too Many Requests'
70
+ else "HTTP #{res.code}"
71
+ end
72
+
73
+ {
74
+ success: [200, 202].include?(res.code.to_i),
75
+ http_code: res.code.to_i,
76
+ message: status_msg,
77
+ host: detected_host,
78
+ key: active_key,
79
+ key_location: "https://#{detected_host}/#{active_key}.txt",
80
+ submitted_urls: urls.size,
81
+ urls: urls
82
+ }
83
+ end
84
+
85
+ def self.submit_sitemap(sitemap_path_or_url, key: nil, limit: nil)
86
+ urls = SitemapLoader.resolve_urls(sitemap_path_or_url, quiet: true)
87
+ raise "No URLs found in sitemap: #{sitemap_path_or_url}" if urls.empty?
88
+
89
+ urls = urls.first(limit) if limit && limit.positive?
90
+ submit(urls, key: key)
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,188 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require 'date'
6
+
7
+ module GSC
8
+ class IntentShift
9
+ TRANSACTIONAL_MODIFIERS = %w[
10
+ buy order purchase discount coupon price pricing cost cheap deal shop store subscription checkout hire
11
+ ].freeze
12
+
13
+ COMMERCIAL_MODIFIERS = %w[
14
+ best top review reviews vs versus compare comparison alternative alternatives recommended software tool platform
15
+ ].freeze
16
+
17
+ INFORMATIONAL_MODIFIERS = %w[
18
+ how what why when where who guide tutorial tips steps learn ideas strategy example examples template explain
19
+ ].freeze
20
+
21
+ NAVIGATIONAL_MODIFIERS = %w[
22
+ login log-in signin sign-in portal account dashboard support helpdesk download
23
+ ].freeze
24
+
25
+ attr_reader :options, :api, :domain
26
+
27
+ def initialize(options = {}, api = nil, domain = nil)
28
+ @options = options
29
+ @api = api
30
+ @domain = domain.to_s.strip
31
+ end
32
+
33
+ def self.analyze(options = {}, api = nil, domain = nil)
34
+ new(options, api, domain).analyze
35
+ end
36
+
37
+ def analyze
38
+ rows = collect_rows
39
+ shifts = detect_intent_shifts(rows)
40
+ summarize_portfolio(shifts)
41
+ end
42
+
43
+ def self.classify_query(query, brand = nil)
44
+ q = query.to_s.downcase.strip
45
+
46
+ if brand && !brand.empty? && q.include?(brand.downcase)
47
+ return :navigational if NAVIGATIONAL_MODIFIERS.any? { |m| q.include?(m) }
48
+ end
49
+
50
+ return :navigational if NAVIGATIONAL_MODIFIERS.any? { |m| q.match?(/\b#{Regexp.escape(m)}\b/) }
51
+ return :transactional if TRANSACTIONAL_MODIFIERS.any? { |m| q.match?(/\b#{Regexp.escape(m)}\b/) }
52
+ return :commercial if COMMERCIAL_MODIFIERS.any? { |m| q.match?(/\b#{Regexp.escape(m)}\b/) }
53
+ return :informational if INFORMATIONAL_MODIFIERS.any? { |m| q.match?(/\b#{Regexp.escape(m)}\b/) }
54
+
55
+ :informational
56
+ end
57
+
58
+ def self.classify_page(url)
59
+ u = url.to_s.downcase
60
+ if u =~ %r{/(?:cart|checkout|pricing|buy|products?|shop|store|orders?)(?:/|$|\?|#)}
61
+ :transactional
62
+ elsif u =~ %r{/(?:blog|guides?|tutorials?|learn|how-to|articles?|docs|knowledge-base)(?:/|$|\?|#)}
63
+ :informational
64
+ elsif u =~ %r{/(?:comparison|compare|vs|alternatives?|best|reviews?)(?:/|$|\?|#)}
65
+ :commercial
66
+ elsif u =~ %r{/(?:login|portal|accounts?|dashboard|signin)(?:/|$|\?|#)}
67
+ :navigational
68
+ else
69
+ :hybrid
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def collect_rows
76
+ if @api
77
+ begin
78
+ days = (@options[:days] || 28).to_i
79
+ end_date = (Date.today - 2).strftime('%Y-%m-%d')
80
+ start_date = (Date.today - 2 - days).strftime('%Y-%m-%d')
81
+ res = @api.search_analytics(@domain, start_date: start_date, end_date: end_date, dimensions: %w[query page])
82
+ raw_rows = res['rows'] || []
83
+ if raw_rows.any?
84
+ return raw_rows.map do |r|
85
+ {
86
+ query: r['keys'][0],
87
+ page: r['keys'][1],
88
+ clicks: r['clicks'] || 0,
89
+ impressions: r['impressions'] || 0,
90
+ ctr: ((r['ctr'] || 0) * 100.0).round(2),
91
+ position: (r['position'] || 0).round(1)
92
+ }
93
+ end
94
+ end
95
+ rescue StandardError
96
+ # GSC query failed
97
+ end
98
+ end
99
+
100
+ []
101
+ end
102
+
103
+ def detect_intent_shifts(rows)
104
+ brand = @options[:brand] || @domain.split('.').first
105
+
106
+ shifts = []
107
+
108
+ rows.each do |row|
109
+ q_intent = self.class.classify_query(row[:query], brand)
110
+ p_intent = self.class.classify_page(row[:page])
111
+
112
+ mismatch = false
113
+ risk_level = :none
114
+ diagnosis = nil
115
+ prescription = nil
116
+
117
+ # Scenario 1: Informational/Commercial query ranking on a purely Transactional page
118
+ if (q_intent == :informational || q_intent == :commercial) && p_intent == :transactional
119
+ mismatch = true
120
+ risk_level = row[:position] > 10.0 ? :high : :medium
121
+ diagnosis = "Google expects #{q_intent.to_s.upcase} research content, but ranking URL is a TRANSACTIONAL #{File.basename(row[:page])} page."
122
+ prescription = "Publish a dedicated #{q_intent} guide/comparison landing page to capture Top 3 SERP intent instead of sending users to checkout/product page."
123
+
124
+ # Scenario 2: Transactional query ranking on an Informational blog post
125
+ elsif q_intent == :transactional && p_intent == :informational
126
+ mismatch = true
127
+ risk_level = :medium
128
+ diagnosis = "Users have HIGH BUYING INTENT (#{row[:query]}), but are landing on an INFORMATIONAL blog article."
129
+ prescription = "Embed prominent 1-click checkout widgets, pricing tables, and product CTAs directly above the fold in this blog post."
130
+
131
+ # Scenario 3: Commercial comparison query landing on generic pricing page
132
+ elsif q_intent == :commercial && p_intent == :hybrid
133
+ mismatch = true
134
+ risk_level = :low
135
+ diagnosis = "Comparison query landing on generic page."
136
+ prescription = "Deploy a structured vs/comparison matrix table."
137
+ end
138
+
139
+ shifts << {
140
+ query: row[:query],
141
+ page: row[:page],
142
+ clicks: row[:clicks],
143
+ impressions: row[:impressions],
144
+ position: row[:position],
145
+ ctr: row[:ctr],
146
+ query_intent: q_intent,
147
+ page_intent: p_intent,
148
+ has_mismatch: mismatch,
149
+ risk_level: risk_level,
150
+ diagnosis: diagnosis,
151
+ prescription: prescription
152
+ }
153
+ end
154
+
155
+ shifts
156
+ end
157
+
158
+ def summarize_portfolio(shifts)
159
+ total = shifts.size
160
+ mismatched = shifts.select { |s| s[:has_mismatch] }
161
+ high_risk = shifts.select { |s| s[:risk_level] == :high }
162
+
163
+ intent_counts = {
164
+ informational: shifts.count { |s| s[:query_intent] == :informational },
165
+ transactional: shifts.count { |s| s[:query_intent] == :transactional },
166
+ commercial: shifts.count { |s| s[:query_intent] == :commercial },
167
+ navigational: shifts.count { |s| s[:query_intent] == :navigational }
168
+ }
169
+
170
+ volatility_score = ((mismatched.size.to_f / [total, 1].max) * 100.0).round(1)
171
+
172
+ prescriptions = mismatched.map { |m| m[:prescription] }.compact.uniq
173
+ grade = total.zero? ? 'N/A' : (volatility_score > 40.0 ? 'HIGH RISK' : (volatility_score > 20.0 ? 'MODERATE' : 'OPTIMAL'))
174
+
175
+ {
176
+ domain: @domain,
177
+ total_queries_analyzed: total,
178
+ intent_distribution: intent_counts,
179
+ mismatched_queries_count: mismatched.size,
180
+ high_risk_shifts_count: high_risk.size,
181
+ portfolio_volatility_pct: volatility_score,
182
+ risk_grade: grade,
183
+ prescriptions: prescriptions,
184
+ shifts: shifts
185
+ }
186
+ end
187
+ end
188
+ end