gsc-cli 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +410 -414
  3. data/bin/gsc +28067 -5661
  4. data/dist/gsc +29121 -5046
  5. data/lib/gsc/aio_hunter.rb +343 -0
  6. data/lib/gsc/answer_synthesizer.rb +157 -0
  7. data/lib/gsc/api.rb +53 -1
  8. data/lib/gsc/auth.rb +26 -0
  9. data/lib/gsc/brand_segmenter.rb +140 -0
  10. data/lib/gsc/cache_manager.rb +806 -0
  11. data/lib/gsc/cannibalization_analyzer.rb +141 -0
  12. data/lib/gsc/canonical_chains.rb +367 -0
  13. data/lib/gsc/citation_simulator.rb +339 -0
  14. data/lib/gsc/cli/aio_hunter.rb +154 -0
  15. data/lib/gsc/cli/analytics.rb +788 -0
  16. data/lib/gsc/cli/audit.rb +1976 -0
  17. data/lib/gsc/cli/base.rb +384 -0
  18. data/lib/gsc/cli/cache.rb +266 -0
  19. data/lib/gsc/cli/canonical.rb +223 -0
  20. data/lib/gsc/cli/citation_simulator.rb +152 -0
  21. data/lib/gsc/cli/dashboard.rb +354 -0
  22. data/lib/gsc/cli/doctor.rb +129 -0
  23. data/lib/gsc/cli/eeat.rb +125 -0
  24. data/lib/gsc/cli/ga4.rb +852 -0
  25. data/lib/gsc/cli/growth.rb +650 -0
  26. data/lib/gsc/cli/hreflang.rb +164 -0
  27. data/lib/gsc/cli/image_seo.rb +162 -0
  28. data/lib/gsc/cli/indexing.rb +458 -0
  29. data/lib/gsc/cli/intent_shift.rb +125 -0
  30. data/lib/gsc/cli/keyword_value.rb +134 -0
  31. data/lib/gsc/cli/keywords.rb +795 -0
  32. data/lib/gsc/cli/landing_roi.rb +308 -0
  33. data/lib/gsc/cli/low_ctr.rb +213 -0
  34. data/lib/gsc/cli/mobile_parity.rb +150 -0
  35. data/lib/gsc/cli/report.rb +100 -0
  36. data/lib/gsc/cli/rich_results.rb +172 -0
  37. data/lib/gsc/cli/schema_generate.rb +149 -0
  38. data/lib/gsc/cli/seasonal.rb +232 -0
  39. data/lib/gsc/cli/security.rb +153 -0
  40. data/lib/gsc/cli/setup.rb +1291 -0
  41. data/lib/gsc/cli/sitemap_tree.rb +143 -0
  42. data/lib/gsc/cli/skill_pack.rb +62 -0
  43. data/lib/gsc/cli/soft_404.rb +199 -0
  44. data/lib/gsc/cli/sparkline.rb +227 -0
  45. data/lib/gsc/cli/watchdog.rb +150 -0
  46. data/lib/gsc/cli/zombie_purger.rb +208 -0
  47. data/lib/gsc/cli.rb +707 -5265
  48. data/lib/gsc/cli_advanced.rb +987 -44
  49. data/lib/gsc/client.rb +17 -2
  50. data/lib/gsc/color.rb +16 -1
  51. data/lib/gsc/command_registry.rb +47 -9
  52. data/lib/gsc/config.rb +2 -2
  53. data/lib/gsc/ctr_curve.rb +115 -0
  54. data/lib/gsc/decay_predictor.rb +322 -0
  55. data/lib/gsc/doctor.rb +434 -0
  56. data/lib/gsc/eeat_auditor.rb +428 -0
  57. data/lib/gsc/entity_auditor.rb +229 -0
  58. data/lib/gsc/firewall_scanner.rb +733 -0
  59. data/lib/gsc/geo_auditor.rb +368 -0
  60. data/lib/gsc/google_trends.rb +8 -1
  61. data/lib/gsc/heading_validator.rb +283 -0
  62. data/lib/gsc/hreflang_validator.rb +412 -0
  63. data/lib/gsc/image_seo.rb +286 -0
  64. data/lib/gsc/indexing_queue.rb +179 -0
  65. data/lib/gsc/indexnow.rb +93 -0
  66. data/lib/gsc/intent_shift.rb +188 -0
  67. data/lib/gsc/internal_links.rb +153 -36
  68. data/lib/gsc/keyword_value.rb +191 -0
  69. data/lib/gsc/landing_roi.rb +195 -0
  70. data/lib/gsc/llms_generator.rb +343 -22
  71. data/lib/gsc/low_ctr_rewriter.rb +408 -0
  72. data/lib/gsc/mobile_parity.rb +222 -0
  73. data/lib/gsc/network_tracer.rb +8 -1
  74. data/lib/gsc/page_analyzer.rb +47 -7
  75. data/lib/gsc/prompts.rb +38 -29
  76. data/lib/gsc/questions_harvester.rb +178 -0
  77. data/lib/gsc/report_generator.rb +461 -0
  78. data/lib/gsc/rich_results.rb +388 -0
  79. data/lib/gsc/robots_checker.rb +46 -15
  80. data/lib/gsc/schema_generator.rb +788 -0
  81. data/lib/gsc/schema_validator.rb +36 -38
  82. data/lib/gsc/seasonal_predictor.rb +381 -0
  83. data/lib/gsc/security_scanner.rb +496 -0
  84. data/lib/gsc/serp_feature_detector.rb +359 -0
  85. data/lib/gsc/serp_preview.rb +108 -22
  86. data/lib/gsc/site_crawler.rb +113 -21
  87. data/lib/gsc/sitemap_loader.rb +15 -4
  88. data/lib/gsc/sitemap_tree.rb +301 -0
  89. data/lib/gsc/skill_pack.rb +195 -0
  90. data/lib/gsc/soft_404_analyzer.rb +385 -0
  91. data/lib/gsc/sparkline.rb +171 -0
  92. data/lib/gsc/speed_correlator.rb +416 -0
  93. data/lib/gsc/striking_playbook.rb +190 -0
  94. data/lib/gsc/title_optimizer.rb +420 -0
  95. data/lib/gsc/vault.rb +260 -0
  96. data/lib/gsc/version.rb +1 -1
  97. data/lib/gsc/watchdog.rb +235 -0
  98. data/lib/gsc/zombie_purger.rb +366 -0
  99. data/lib/gsc.rb +118 -0
  100. metadata +75 -1
@@ -0,0 +1,343 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'uri'
5
+ require 'net/http'
6
+ require 'json'
7
+ require 'time'
8
+ require_relative 'serp_feature_detector'
9
+ require_relative 'answer_synthesizer'
10
+ require_relative 'google_suggest'
11
+
12
+ module GSC
13
+ class AioHunter
14
+ attr_reader :target, :options, :api, :site_url
15
+
16
+ def initialize(target = nil, options = {}, api = nil, site_url = nil)
17
+ @target = target.to_s.dup.force_encoding('UTF-8').scrub.strip
18
+ @options = options || {}
19
+ @api = api
20
+ @site_url = site_url
21
+ end
22
+
23
+ def self.analyze(target = nil, options = {}, api = nil, site_url = nil)
24
+ new(target, options, api, site_url).analyze
25
+ end
26
+
27
+ def analyze
28
+ if single_query_mode?
29
+ analyze_query(@target)
30
+ else
31
+ analyze_portfolio
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def single_query_mode?
38
+ return false if @target.empty?
39
+ return false if @target =~ /\.(com|org|net|io|app|co|dev|store)$/i
40
+ return false if @target =~ %r{^https?://}i
41
+ true
42
+ end
43
+
44
+ def analyze_query(query)
45
+ intent = classify_intent(query)
46
+ trigger_prob = calculate_aio_probability(query, intent)
47
+ ctr_suppression = calculate_ctr_suppression(trigger_prob)
48
+
49
+ # Check target domain ranking & citation eligibility from GSC if available
50
+ user_domain = extract_clean_domain(@options[:domain] || @site_url || '')
51
+ ranking_data = fetch_query_ranking(query, user_domain)
52
+
53
+ domain_ranked = !ranking_data.nil?
54
+ domain_pos = domain_ranked ? ranking_data[:position] : nil
55
+ domain_top3 = domain_ranked && domain_pos <= 3.0
56
+
57
+ citation_gap = user_domain.empty? ? 0 : (domain_top3 ? 0 : [100, (trigger_prob * 1.1).round].min)
58
+ capture_recipe = synthesize_capture_recipe(query, intent)
59
+
60
+ {
61
+ mode: :query,
62
+ query: query,
63
+ target_domain: user_domain.empty? ? nil : user_domain,
64
+ timestamp: Time.now.utc.iso8601,
65
+ intent: intent,
66
+ aio_presence: {
67
+ detected: trigger_prob >= 50,
68
+ probability_percent: trigger_prob,
69
+ status: aio_status_badge(trigger_prob),
70
+ ctr_suppression_estimate: "#{ctr_suppression}%"
71
+ },
72
+ citation_analysis: {
73
+ domain_evaluated: !user_domain.empty?,
74
+ domain_ranked: domain_ranked,
75
+ ranking_position: domain_pos,
76
+ top_3_prime_candidate: domain_top3,
77
+ citation_gap_index: citation_gap,
78
+ eligibility_status: domain_top3 ? "HIGH ELIGIBILITY (Ranks Pos #{domain_pos})" : (domain_ranked ? "STRIKING DISTANCE (Pos #{domain_pos})" : "UNRANKED / UNVERIFIED")
79
+ },
80
+ opportunity_score: calculate_opportunity_score(trigger_prob, domain_top3, ranking_data ? ranking_data[:impressions] : nil),
81
+ capture_recipe: capture_recipe
82
+ }
83
+ end
84
+
85
+ def analyze_portfolio
86
+ queries = fetch_gsc_queries
87
+
88
+ domain = extract_clean_domain(@target.empty? ? (@options[:domain] || @site_url || 'portfolio') : @target)
89
+
90
+ limit = (@options[:limit] || 25).to_i
91
+ min_imp = (@options[:min_imp] || 10).to_i
92
+
93
+ filtered = queries.select { |q| q[:impressions].to_i >= min_imp }
94
+
95
+ analyzed_items = filtered.map do |row|
96
+ q = row[:query]
97
+ intent = classify_intent(q)
98
+ prob = calculate_aio_probability(q, intent)
99
+ suppression = calculate_ctr_suppression(prob)
100
+ pos = row[:position].to_f.round(1)
101
+ top3 = pos <= 3.0
102
+ opp_score = calculate_opportunity_score(prob, top3, row[:impressions].to_i)
103
+
104
+ {
105
+ query: q,
106
+ clicks: row[:clicks].to_i,
107
+ impressions: row[:impressions].to_i,
108
+ position: pos,
109
+ ctr: (row[:ctr].to_f * 100.0).round(2),
110
+ intent: intent,
111
+ aio_probability: prob,
112
+ ctr_suppression: suppression,
113
+ top_3_prime: top3,
114
+ opportunity_score: opp_score,
115
+ recipe_summary: synthesize_short_recipe(intent)
116
+ }
117
+ end
118
+
119
+ analyzed_items.sort_by! { |item| -item[:opportunity_score] }
120
+ top_items = analyzed_items.first(limit)
121
+
122
+ total_queries = analyzed_items.size
123
+ aio_triggering = analyzed_items.count { |i| i[:aio_probability] >= 50 }
124
+ currently_cited = analyzed_items.count { |i| i[:top_3_prime] && i[:aio_probability] >= 50 }
125
+ high_threat_queries = analyzed_items.count { |i| !i[:top_3_prime] && i[:aio_probability] >= 60 }
126
+
127
+ {
128
+ mode: :portfolio,
129
+ domain: domain,
130
+ total_queries_audited: total_queries,
131
+ summary: {
132
+ total_monitored_queries: total_queries,
133
+ aio_triggering_queries: aio_triggering,
134
+ aio_penetration_rate: total_queries.zero? ? 0.0 : ((aio_triggering.to_f / total_queries) * 100.0).round(1),
135
+ currently_cited_count: currently_cited,
136
+ high_threat_uncited_count: high_threat_queries,
137
+ portfolio_aio_vulnerability_index: total_queries.zero? ? 0.0 : ((high_threat_queries.to_f / total_queries) * 100.0).round(1)
138
+ },
139
+ opportunities: top_items
140
+ }
141
+ end
142
+
143
+ def fetch_query_ranking(query, domain)
144
+ return nil unless @api && !domain.empty?
145
+
146
+ begin
147
+ days = (@options[:days] || 28).to_i
148
+ end_date = (Date.today - 2).strftime('%Y-%m-%d')
149
+ start_date = (Date.today - 2 - days).strftime('%Y-%m-%d')
150
+ property = @site_url || "sc-domain:#{domain}"
151
+ res = @api.query_search_analytics(property, start_date: start_date, end_date: end_date, dimensions: %w[query])
152
+ rows = res['rows'] || []
153
+ match = rows.find { |r| r['keys'][0].to_s.downcase == query.downcase }
154
+ if match
155
+ {
156
+ clicks: match['clicks'] || 0,
157
+ impressions: match['impressions'] || 0,
158
+ position: (match['position'] || 0).round(1)
159
+ }
160
+ end
161
+ rescue StandardError
162
+ nil
163
+ end
164
+ end
165
+
166
+ def fetch_gsc_queries
167
+ if @api && @site_url
168
+ begin
169
+ days = (@options[:days] || 30).to_i
170
+ res = @api.query_search_analytics(
171
+ @site_url,
172
+ start_date: (Date.today - days).strftime('%Y-%m-%d'),
173
+ end_date: Date.today.strftime('%Y-%m-%d'),
174
+ dimensions: ['query'],
175
+ row_limit: 100
176
+ )
177
+ if res && res['rows']
178
+ return res['rows'].map do |r|
179
+ {
180
+ query: r['keys'].first.to_s,
181
+ clicks: r['clicks'] || 0,
182
+ impressions: r['impressions'] || 0,
183
+ ctr: r['ctr'] || 0.0,
184
+ position: r['position'] || 0.0
185
+ }
186
+ end
187
+ end
188
+ rescue StandardError
189
+ # GSC query failed
190
+ end
191
+ end
192
+
193
+ []
194
+ end
195
+
196
+ def calculate_aio_probability(query, intent)
197
+ score = 20 # Baseline
198
+
199
+ case intent
200
+ when :informational_how_to
201
+ score += 65 # High Gemini AIO trigger rate
202
+ when :informational_definition
203
+ score += 60
204
+ when :commercial_comparison
205
+ score += 50
206
+ when :commercial_best
207
+ score += 45
208
+ when :navigational
209
+ score -= 15
210
+ when :transactional
211
+ score -= 10
212
+ end
213
+
214
+ # Interrogative keywords check
215
+ if query =~ /\b(how|what|why|when|where|who|can|should|does|which)\b/i
216
+ score += 15
217
+ end
218
+
219
+ # Length bonus: complex conversational queries trigger AIO far more often
220
+ words_count = query.split.size
221
+ score += 10 if words_count >= 5
222
+
223
+ [[score, 98].min, 5].max
224
+ end
225
+
226
+ def calculate_ctr_suppression(prob)
227
+ if prob >= 80
228
+ 45 # 45% click suppression
229
+ elsif prob >= 60
230
+ 35
231
+ elsif prob >= 40
232
+ 20
233
+ else
234
+ 5
235
+ end
236
+ end
237
+
238
+ def classify_intent(query)
239
+ q = query.downcase
240
+ if q =~ /\b(how to|steps|fix|guide|tutorial|setup)\b/i
241
+ :informational_how_to
242
+ elsif q =~ /\b(what is|what are|definition|meaning|explained)\b/i
243
+ :informational_definition
244
+ elsif q =~ /\b(vs|versus|difference between|compared to|comparison)\b/i
245
+ :commercial_comparison
246
+ elsif q =~ /\b(best|top|review|reviews|alternatives)\b/i
247
+ :commercial_best
248
+ elsif q =~ /\b(buy|cheap|discount|coupon|download|order)\b/i
249
+ :transactional
250
+ else
251
+ :navigational
252
+ end
253
+ end
254
+
255
+ def calculate_opportunity_score(prob, cited, imp = nil)
256
+ # High AIO presence + high impressions + currently not cited = highest opportunity score
257
+ base = prob * 0.6
258
+ vol_factor = imp ? [40.0, Math.log10([imp.to_i, 10].max) * 10.0].min : 30.0
259
+ uncited_multiplier = cited ? 0.4 : 1.0
260
+
261
+ [100, ((base + vol_factor) * uncited_multiplier).round].min
262
+ end
263
+
264
+ def synthesize_capture_recipe(query, intent)
265
+ case intent
266
+ when :informational_how_to
267
+ {
268
+ strategy: "Direct Ordered List & Steps Schema",
269
+ direct_answer_draft: "To #{query.sub(/^how to /i, '')}, follow these 4 steps: (1) Audit current baseline performance, (2) Apply targeted responsive optimizations, (3) Inline critical assets, and (4) Verify with real-user Core Web Vitals telemetry.",
270
+ recommended_schema: "HowTo (with step.itemListElement)",
271
+ recommended_heading: "How to #{query.sub(/^how to /i, '').capitalize} (4-Step Technical Protocol)",
272
+ content_elements: [
273
+ "Immediate 35-50 word direct answer paragraph above fold",
274
+ "Numbered ordered list (<ol>) with clear actionable bold prefixes",
275
+ "Step-by-step HowTo JSON-LD schema markup"
276
+ ]
277
+ }
278
+ when :informational_definition
279
+ subject = query.sub(/^what is (a |an )?/i, '').strip
280
+ {
281
+ strategy: "Authoritative Definitional Snippet",
282
+ direct_answer_draft: "#{subject.capitalize} refers to a core methodology and operational framework designed to deliver measurable performance improvements, structural efficiency, and verified outcome reliability.",
283
+ recommended_schema: "DefinedTerm / Article / FAQPage",
284
+ recommended_heading: "What is #{subject.capitalize}? Definition & Architecture",
285
+ content_elements: [
286
+ "Exact is-a definitional statement in first sentence (<45 words)",
287
+ "One verifiable numeric statistic or performance benchmark",
288
+ "itemprop='description' or Schema.org Answer markup"
289
+ ]
290
+ }
291
+ when :commercial_comparison
292
+ {
293
+ strategy: "Direct Markdown Comparison Matrix",
294
+ direct_answer_draft: "When evaluating #{query}, key trade-offs center on operational speed, architectural flexibility, and long-term maintenance overhead across competing alternatives.",
295
+ recommended_schema: "Table / Comparison Article Schema",
296
+ recommended_heading: "#{query.capitalize}: Feature & Performance Comparison",
297
+ content_elements: [
298
+ "Clear side-by-side comparison table (HTML <table> or GFM)",
299
+ "Summary paragraph stating winner for specific use cases",
300
+ "Bullet list highlighting pros & cons per option"
301
+ ]
302
+ }
303
+ else
304
+ {
305
+ strategy: "FAQPage Structured Question & Direct Quote",
306
+ direct_answer_draft: "#{query.capitalize} delivers essential workflow automation for digital teams, maximizing ROI through unified analytics and instant developer CLI operations.",
307
+ recommended_schema: "FAQPage / SoftwareApplication",
308
+ recommended_heading: "Understanding #{query.capitalize}",
309
+ content_elements: [
310
+ "High-contrast bulleted takeaways",
311
+ "Schema.org FAQPage block targeting exact query syntax",
312
+ "Verified author credentials (E-E-A-T) citation badge"
313
+ ]
314
+ }
315
+ end
316
+ end
317
+
318
+ def synthesize_short_recipe(intent)
319
+ case intent
320
+ when :informational_how_to then "Inject 4-step ordered list + HowTo schema"
321
+ when :informational_definition then "Add 40-word definitional snippet above fold"
322
+ when :commercial_comparison then "Add side-by-side comparison table"
323
+ when :commercial_best then "Add structured pros/cons list & rating schema"
324
+ else "Add FAQPage Q&A block"
325
+ end
326
+ end
327
+
328
+ def aio_status_badge(prob)
329
+ if prob >= 75
330
+ "🚨 HIGH PROBABILITY (Active AIO SERP)"
331
+ elsif prob >= 50
332
+ "⚠️ MODERATE PROBABILITY (Likely AIO)"
333
+ else
334
+ "✅ LOW PROBABILITY (Traditional Organic SERP)"
335
+ end
336
+ end
337
+
338
+ def extract_clean_domain(url_or_domain)
339
+ d = url_or_domain.to_s.sub(%r{^https?://}i, '').sub(%r{/.*$}, '').strip
340
+ d.sub(%r{^www\.}i, '')
341
+ end
342
+ end
343
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module GSC
6
+ class AnswerSynthesizer
7
+ attr_reader :query, :brand, :intent_type
8
+
9
+ def self.synthesize(query, options = {})
10
+ new(query, options).generate
11
+ end
12
+
13
+ def initialize(query, options = {})
14
+ @query = query.to_s.strip
15
+ @brand = options[:brand] || options[:brand_name] || nil
16
+ @explicit_type = options[:type]&.to_s&.downcase
17
+ @intent_type = detect_intent_type
18
+ end
19
+
20
+ def generate
21
+ direct_answer = build_direct_answer
22
+ key_points = build_key_points
23
+ info_gain_stat = build_info_gain_stat
24
+ html_snippet = build_html_snippet(direct_answer, key_points)
25
+ schema_jsonld = build_schema_jsonld(direct_answer)
26
+
27
+ word_count = direct_answer.split(/\s+/).size
28
+ citability_score = calculate_citability(word_count)
29
+
30
+ {
31
+ query: @query,
32
+ brand: @brand,
33
+ intent_type: @intent_type,
34
+ word_count: word_count,
35
+ citability_score: citability_score,
36
+ target_optimal_range: '40–60 words',
37
+ direct_answer: direct_answer,
38
+ key_points: key_points,
39
+ information_gain_stat: info_gain_stat,
40
+ html_markup: html_snippet,
41
+ schema_jsonld: schema_jsonld
42
+ }
43
+ end
44
+
45
+ private
46
+
47
+ def detect_intent_type
48
+ return @explicit_type if %w[definition steps comparison faq].include?(@explicit_type)
49
+
50
+ q = @query.downcase
51
+ if q.include?(' vs ') || q.include?(' versus ') || q.include?('difference between')
52
+ 'comparison'
53
+ elsif q.start_with?('how to', 'how do', 'steps to', 'guide to')
54
+ 'steps'
55
+ elsif q.start_with?('why', 'can', 'should', 'is', 'does')
56
+ 'faq'
57
+ else
58
+ 'definition'
59
+ end
60
+ end
61
+
62
+ def build_direct_answer
63
+ title_q = titleize(@query.sub(/^(what is|what are|how to|guide to)\s+/i, ''))
64
+
65
+ case @intent_type
66
+ when 'steps'
67
+ "To optimize #{title_q}, teams systematically audit existing performance bottlenecks, configure native client-side caching, and eliminate blocking third-party render scripts. Implementing direct responsive preloading directly accelerates Core Web Vitals while preserving 100% data integrity."
68
+ when 'comparison'
69
+ "When evaluating #{title_q}, the primary distinction lies in latency, maintenance overhead, and conversion impact. Modern specialized architectures deliver up to 3x faster response times and eliminate ongoing subscription bloat compared to fragmented legacy multi-plugin solutions."
70
+ when 'faq'
71
+ "Yes, #{@query.downcase.capitalize} directly enhances overall search visibility and customer retention by resolving critical technical friction points. Streamlined workflow execution ensures search crawlers and users experience immediate, zero-latency content delivery."
72
+ else # definition
73
+ "#{title_q} is a high-impact optimization standard designed to streamline user workflows, eliminate conversion drop-offs, and maximize digital throughput. By replacing fragmented manual processes with automated native execution, brands achieve measurable efficiency gains, reduced server latency, and sustained competitive ranking advantage."
74
+ end
75
+ end
76
+
77
+ def build_key_points
78
+ title_q = titleize(@query.sub(/^(what is|what are|how to|guide to)\s+/i, ''))
79
+
80
+ case @intent_type
81
+ when 'steps'
82
+ [
83
+ "**1. Audit Current State:** Measure baseline latency, indexation depth, and drop-off points.",
84
+ "**2. Eliminate Script Bloat:** Defer non-critical JavaScript and purge unused legacy CSS rules.",
85
+ "**3. Deploy Native Caching:** Enable client-side prefetching and CDN edge acceleration.",
86
+ "**4. Monitor Live Metrics:** Track Google Search Console impressions and GA4 engagement in real-time."
87
+ ]
88
+ when 'comparison'
89
+ [
90
+ "**Architecture:** Unified execution layer vs. fragmented multi-tool overhead.",
91
+ "**Performance:** Streamlined operational response times compared to legacy pipelines.",
92
+ "**Cost & Efficiency:** Predictable resource allocation with reduced operational complexity.",
93
+ "**Reliability:** High-availability design with direct observability and minimal failure surface."
94
+ ]
95
+ else
96
+ [
97
+ "**Efficiency:** Eliminates unnecessary execution layers and accelerates operational delivery.",
98
+ "**Maintainability:** Establishes standardized protocols without recurring manual intervention.",
99
+ "**Measurable Impact:** Delivers verified improvements across target engagement and core performance indicators.",
100
+ "**Standards Compliant:** Fully adheres to modern technical and semantic specification guidelines."
101
+ ]
102
+ end
103
+ end
104
+
105
+ def build_info_gain_stat
106
+ title_q = titleize(@query.sub(/^(what is|what are|how to|guide to)\s+/i, ''))
107
+ "[Empirical Benchmark Directive]: Embed proprietary study data for #{title_q} to provide unique information gain for LLMs."
108
+ end
109
+
110
+ def build_html_snippet(direct_answer, key_points)
111
+ points_html = key_points.map { |kp| " <li>#{format_markdown_bold(kp)}</li>" }.join("\n")
112
+
113
+ <<~HTML.strip
114
+ <!-- Google Featured Snippet & AI Direct Answer Block -->
115
+ <section class="direct-answer-snippet" itemscope itemtype="https://schema.org/WebPage">
116
+ <p class="answer-lead" itemprop="description">
117
+ <strong>#{titleize(@query)}:</strong> #{direct_answer}
118
+ </p>
119
+ <ul class="answer-key-points">
120
+ #{points_html}
121
+ </ul>
122
+ </section>
123
+ HTML
124
+ end
125
+
126
+ def build_schema_jsonld(direct_answer)
127
+ {
128
+ "@context" => "https://schema.org",
129
+ "@type" => "Question",
130
+ "name" => @query.end_with?('?') ? @query : "#{@query}?",
131
+ "acceptedAnswer" => {
132
+ "@type" => "Answer",
133
+ "text" => direct_answer
134
+ }
135
+ }
136
+ end
137
+
138
+ def calculate_citability(word_count)
139
+ # 40-60 words is the peak citability range for Google Featured Snippets & LLMs
140
+ if word_count.between?(40, 58)
141
+ 95
142
+ elsif word_count.between?(35, 65)
143
+ 88
144
+ else
145
+ 75
146
+ end
147
+ end
148
+
149
+ def format_markdown_bold(str)
150
+ str.gsub(/\*\*(.*?)\*\*/, '<strong>\1</strong>')
151
+ end
152
+
153
+ def titleize(str)
154
+ str.to_s.split(/\s+/).map(&:capitalize).join(' ')
155
+ end
156
+ end
157
+ end
data/lib/gsc/api.rb CHANGED
@@ -43,6 +43,56 @@ def query_analytics(site_url, days: 30, dimensions: ['query'], row_limit: 50, st
43
43
  res.merge(start_date: start_d, end_date: end_d)
44
44
  end
45
45
 
46
+ # Unified search_analytics interface supporting string/symbol rows access and property prefix fallback
47
+ def search_analytics(site_url, start_date: nil, end_date: nil, days: 30, dimensions: ['query'], row_limit: 5000, start_row: 0, filters: nil)
48
+ raw_site = site_url.to_s.strip
49
+ target_site = if raw_site.start_with?('sc-domain:', 'http://', 'https://')
50
+ raw_site
51
+ else
52
+ "sc-domain:#{raw_site}"
53
+ end
54
+
55
+ res = query_analytics(
56
+ target_site,
57
+ days: days,
58
+ dimensions: dimensions,
59
+ row_limit: row_limit,
60
+ start_row: start_row,
61
+ start_date: start_date,
62
+ end_date: end_date,
63
+ filters: filters
64
+ )
65
+
66
+ # Fallback to URL-prefix if sc-domain returns empty or forbidden
67
+ if (!res[:ok] || (res.dig(:data, 'rows') || []).empty?) && target_site.start_with?('sc-domain:')
68
+ clean_dom = target_site.sub(/^sc-domain:/, '')
69
+ fallback_res = query_analytics(
70
+ "https://#{clean_dom}/",
71
+ days: days,
72
+ dimensions: dimensions,
73
+ row_limit: row_limit,
74
+ start_row: start_row,
75
+ start_date: start_date,
76
+ end_date: end_date,
77
+ filters: filters
78
+ )
79
+ res = fallback_res if fallback_res[:ok] && (fallback_res.dig(:data, 'rows') || []).any?
80
+ end
81
+
82
+ rows = res.dig(:data, 'rows') || []
83
+ {
84
+ 'rows' => rows,
85
+ :rows => rows,
86
+ :data => res[:data] || { 'rows' => rows },
87
+ 'data' => res[:data] || { 'rows' => rows },
88
+ :ok => res[:ok],
89
+ :status => res[:status],
90
+ :start_date => res[:start_date],
91
+ :end_date => res[:end_date]
92
+ }
93
+ end
94
+ alias_method :query_search_analytics, :search_analytics
95
+
46
96
  # Fetch all analytics rows across pagination (handles >25,000 queries via startRow)
47
97
  def query_all_analytics(site_url, days: 30, dimensions: ['query'], start_date: nil, end_date: nil, max_total: nil)
48
98
  all_rows = []
@@ -70,6 +120,8 @@ def query_all_analytics(site_url, days: 30, dimensions: ['query'], start_date: n
70
120
  break if max_total && all_rows.size >= max_total
71
121
 
72
122
  start_row += chunk_size
123
+ # Google Search Console Search Analytics API hard limit: startRow cannot exceed 25,000
124
+ break if start_row >= 25000
73
125
  end
74
126
 
75
127
  {
@@ -210,7 +262,7 @@ end
210
262
  res = @client.post(endpoint, body)
211
263
  map = {}
212
264
  if res[:ok]
213
- (res[:data]['rows'] || []).each do |r|
265
+ (res.dig(:data, 'rows') || []).each do |r|
214
266
  title = r.dig('dimensionValues', 0, 'value')
215
267
  path = r.dig('dimensionValues', 1, 'value')
216
268
  map[title] ||= path if title && path
data/lib/gsc/auth.rb CHANGED
@@ -50,6 +50,32 @@ module GSC
50
50
  found
51
51
  end
52
52
 
53
+ def self.find_service_account(custom_path = nil, domain = nil)
54
+ # 1. Check custom path first if provided
55
+ if custom_path && File.file?(File.expand_path(custom_path))
56
+ json = JSON.parse(File.read(File.expand_path(custom_path))) rescue nil
57
+ return { data: json, source: custom_path, type: 'file' } if json && json['client_email']
58
+ end
59
+
60
+ # 2. Check Agency Vault for domain or active domain
61
+ target_dom = domain || Config.default_domain
62
+ if target_dom
63
+ vault_key = Vault.key_for_domain(target_dom)
64
+ if vault_key && vault_key['client_email']
65
+ return { data: vault_key, source: "vault:#{target_dom}", type: 'vault' }
66
+ end
67
+ end
68
+
69
+ # 3. Check filesystem key candidates
70
+ key_file = find_key(custom_path)
71
+ if key_file
72
+ json = JSON.parse(File.read(key_file)) rescue nil
73
+ return { data: json, source: key_file, type: 'file' } if json && json['client_email']
74
+ end
75
+
76
+ nil
77
+ end
78
+
53
79
  def self.fetch_access_token(service_account)
54
80
  now = Time.now.to_i
55
81
  header = { alg: 'RS256', typ: 'JWT' }