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,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
|
|
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' }
|