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.
- checksums.yaml +4 -4
- data/README.md +410 -414
- 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,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
|
data/lib/gsc/indexnow.rb
ADDED
|
@@ -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
|