gsc-cli 2.0.2 → 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/AUTH.md +205 -0
- data/FUNDING.md +120 -0
- data/README.md +463 -299
- data/bin/gsc +29158 -4921
- data/dist/gsc +29158 -4921
- 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/backlinks_manager.rb +96 -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 +706 -5111
- data/lib/gsc/cli_advanced.rb +1513 -0
- 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 +11 -2
- data/lib/gsc/content_gap.rb +112 -0
- 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_suggest.rb +109 -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 +249 -0
- data/lib/gsc/keyword_value.rb +191 -0
- data/lib/gsc/landing_roi.rb +195 -0
- data/lib/gsc/llms_generator.rb +425 -0
- data/lib/gsc/low_ctr_rewriter.rb +408 -0
- data/lib/gsc/mobile_parity.rb +222 -0
- data/lib/gsc/network_tracer.rb +93 -0
- data/lib/gsc/open_page_rank.rb +72 -0
- data/lib/gsc/page_analyzer.rb +47 -7
- data/lib/gsc/page_comparator.rb +108 -0
- data/lib/gsc/page_speed.rb +110 -0
- 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 +114 -0
- data/lib/gsc/schema_generator.rb +788 -0
- data/lib/gsc/schema_validator.rb +120 -0
- 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 +152 -0
- 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 +144 -0
- metadata +91 -2
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
require_relative '../google_trends' if File.exist?(File.expand_path('../google_trends.rb', __dir__))
|
|
6
|
+
require_relative '../keyword_planner' if File.exist?(File.expand_path('../keyword_planner.rb', __dir__))
|
|
7
|
+
require_relative '../keywords_everywhere' if File.exist?(File.expand_path('../keywords_everywhere.rb', __dir__))
|
|
8
|
+
require_relative '../google_suggest' if File.exist?(File.expand_path('../google_suggest.rb', __dir__))
|
|
9
|
+
|
|
10
|
+
module GSC
|
|
11
|
+
class CLI
|
|
12
|
+
module Keywords
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def run(command, target, extra, options)
|
|
16
|
+
case command
|
|
17
|
+
when 'trends', 'tr', 'google-trends', 'gtrends'
|
|
18
|
+
handle_trends(target, options)
|
|
19
|
+
when 'planner', 'kp', 'keywords'
|
|
20
|
+
if target == 'import'
|
|
21
|
+
handle_planner_import(extra, options)
|
|
22
|
+
elsif target && (File.exist?(target) || target.end_with?('.csv', '.tsv', '.md'))
|
|
23
|
+
handle_planner_import(target, options)
|
|
24
|
+
else
|
|
25
|
+
handle_planner_expand(target, options)
|
|
26
|
+
end
|
|
27
|
+
when 'planner-import', 'pi', 'import', 'imp'
|
|
28
|
+
handle_planner_import(target, options)
|
|
29
|
+
when 'saved', 'research', 'saved-keywords', 'sv'
|
|
30
|
+
handle_saved(target, extra, options)
|
|
31
|
+
when 'check', 'chk'
|
|
32
|
+
handle_saved('check', target, options)
|
|
33
|
+
when 'ke', 'keywordseverywhere', 'keywords-everywhere', 'k'
|
|
34
|
+
if target == 'credits' || target == 'account'
|
|
35
|
+
handle_ke_credits(options)
|
|
36
|
+
elsif target == 'connect' || target == 'setup'
|
|
37
|
+
handle_connect_ke(extra)
|
|
38
|
+
else
|
|
39
|
+
handle_ke(target, options)
|
|
40
|
+
end
|
|
41
|
+
when 'ke-credits', 'credits'
|
|
42
|
+
handle_ke_credits(options)
|
|
43
|
+
when 'suggest', 'autocomplete'
|
|
44
|
+
handle_suggest(target, options)
|
|
45
|
+
when 'questions', 'paa', 'intent-questions'
|
|
46
|
+
handle_questions(target, options)
|
|
47
|
+
else
|
|
48
|
+
raise "Unknown keyword command: #{command}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def handle_trends(keyword, options)
|
|
53
|
+
geo = options[:geo] || 'US'
|
|
54
|
+
time = options[:time] || '5y'
|
|
55
|
+
puts Base::BANNER unless options[:json] || options[:in_dashboard]
|
|
56
|
+
puts "📈 Querying Google Trends for: #{Color.c(keyword, Color::CYAN)} (Geo: #{geo.empty? ? 'Worldwide' : geo}, Time: #{time})...\n" unless options[:json]
|
|
57
|
+
|
|
58
|
+
res = GoogleTrends.fetch(keyword, geo: geo, time: time)
|
|
59
|
+
unless res[:ok]
|
|
60
|
+
if options[:json]
|
|
61
|
+
puts JSON.pretty_generate({ error: res[:error] })
|
|
62
|
+
else
|
|
63
|
+
puts Color.c("❌ Google Trends Error: #{res[:error]}", Color::RED)
|
|
64
|
+
end
|
|
65
|
+
return
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if options[:json]
|
|
69
|
+
puts JSON.pretty_generate(res)
|
|
70
|
+
return
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
puts "#{Color::BOLD}Search Term:#{Color::RESET} #{Color.c(res[:keyword], Color::CYAN, Color::BOLD)}"
|
|
74
|
+
puts "#{Color::BOLD}Region / Geo:#{Color::RESET} #{res[:geo]}"
|
|
75
|
+
puts "#{Color::BOLD}Timeframe:#{Color::RESET} #{res[:time]}"
|
|
76
|
+
puts "#{Color::BOLD}Trend Velocity:#{Color::RESET} #{Color.c(res[:velocity_badge], Color::GREEN, Color::BOLD)}"
|
|
77
|
+
puts "#{Color::BOLD}Peak Demand:#{Color::RESET} #{Color.c("#{res[:peak_score]}/100", Color::YELLOW)} (#{res[:peak_date]})"
|
|
78
|
+
puts "#{Color::BOLD}Current Score:#{Color::RESET} #{res[:current_score]} / 100"
|
|
79
|
+
puts ""
|
|
80
|
+
puts "#{Color::BOLD}Demand Trajectory:#{Color::RESET}"
|
|
81
|
+
puts " [ #{Color.c(res[:sparkline], Color::CYAN)} ]"
|
|
82
|
+
puts ""
|
|
83
|
+
|
|
84
|
+
if res[:regions].any?
|
|
85
|
+
puts "#{Color::BOLD}🌍 Top Geographic Regions:#{Color::RESET}"
|
|
86
|
+
res[:regions].each_with_index do |r, i|
|
|
87
|
+
bar_len = [(r[:score] / 10.0).round, 10].min
|
|
88
|
+
bar = "█" * bar_len + " " * (10 - bar_len)
|
|
89
|
+
puts " #{(i + 1).to_s.rjust(2)}. #{r[:name].ljust(22)} [#{Color.c(bar, Color::CYAN)}] #{r[:score].to_s.rjust(3)}/100"
|
|
90
|
+
end
|
|
91
|
+
puts ""
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
if res[:rising_queries].any?
|
|
95
|
+
puts "#{Color::BOLD}🔥 Rising & Breakout Related Searches:#{Color::RESET}"
|
|
96
|
+
res[:rising_queries].each do |q|
|
|
97
|
+
badge_color = q[:growth] == 'Breakout' ? Color::GREEN : Color::YELLOW
|
|
98
|
+
puts " • #{q[:query].ljust(35)} [#{Color.c(q[:growth], badge_color, Color::BOLD)}]"
|
|
99
|
+
end
|
|
100
|
+
puts ""
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if res[:top_queries].any?
|
|
104
|
+
puts "#{Color::BOLD}💡 Top Related Searches:#{Color::RESET}"
|
|
105
|
+
res[:top_queries].each do |q|
|
|
106
|
+
puts " • #{q[:query].ljust(35)} (#{q[:score]}/100)"
|
|
107
|
+
end
|
|
108
|
+
puts ""
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def render_keyword_table(keywords, limit: nil)
|
|
113
|
+
displayed = limit ? keywords.first(limit) : keywords
|
|
114
|
+
|
|
115
|
+
puts "#{Color::BOLD}Opp Score | Volume/mo | CPC | Comp | Tier | Trend% [12m] | GSC Status | Intent | Keyword#{Color::RESET}"
|
|
116
|
+
puts "--------------------------------------------------------------------------------------------------------------------------------"
|
|
117
|
+
displayed.each do |r|
|
|
118
|
+
score = r[:opportunity_score] || r['opportunity_score'] || 0
|
|
119
|
+
score_color = if score >= 80 then Color::GREEN
|
|
120
|
+
elsif score >= 50 then Color::CYAN
|
|
121
|
+
else Color::YELLOW
|
|
122
|
+
end
|
|
123
|
+
score_str = Color.c(score.to_s.rjust(9), score_color, Color::BOLD)
|
|
124
|
+
|
|
125
|
+
vol = r[:volume] || r['volume'] || 0
|
|
126
|
+
vol_str = vol.to_s.reverse.scan(/.{1,3}/).join(',').reverse.rjust(9)
|
|
127
|
+
|
|
128
|
+
cpc = r[:cpc] || r['cpc'] || '$0.00'
|
|
129
|
+
cpc_str = cpc.to_s.rjust(7)
|
|
130
|
+
|
|
131
|
+
comp = r[:competition] || r['competition'] || 0.0
|
|
132
|
+
comp_str = sprintf('%.2f', comp.to_f).rjust(4)
|
|
133
|
+
|
|
134
|
+
tier = r[:competition_tier] || r['competition_tier'] || 'Low'
|
|
135
|
+
tier_str = tier.to_s.ljust(6)
|
|
136
|
+
|
|
137
|
+
trend_pct = (r[:trend_pct] || r['trend_pct'] || 0).to_i
|
|
138
|
+
hist = r[:monthly_history] || r['monthly_history'] || {}
|
|
139
|
+
spark = if hist.is_a?(Hash) && hist.size >= 3
|
|
140
|
+
KeywordPlanner.render_sparkline(hist.values.reverse, max_points: 6)
|
|
141
|
+
else
|
|
142
|
+
""
|
|
143
|
+
end
|
|
144
|
+
trend_raw = (trend_pct >= 0 ? "+#{trend_pct}%" : "#{trend_pct}%")
|
|
145
|
+
trend_str = spark.empty? ? trend_raw.rjust(12) : "#{trend_raw.rjust(5)} #{spark}".ljust(12)
|
|
146
|
+
|
|
147
|
+
gsc_data = r[:gsc] || r['gsc'] || {}
|
|
148
|
+
gsc_status = gsc_data[:status] || gsc_data['status'] || '🚀 Untargeted'
|
|
149
|
+
gsc_str = gsc_status.ljust(27)
|
|
150
|
+
|
|
151
|
+
intent = r[:intent] || r['intent'] || 'Informational'
|
|
152
|
+
intent_str = intent.to_s.ljust(13)
|
|
153
|
+
|
|
154
|
+
kw = r[:keyword] || r['keyword'] || ''
|
|
155
|
+
kw_str = Color.c(kw, Color::BOLD)
|
|
156
|
+
|
|
157
|
+
puts "#{score_str} | #{vol_str} | #{cpc_str} | #{comp_str} | #{tier_str} | #{trend_str} | #{gsc_str} | #{intent_str} | #{kw_str}"
|
|
158
|
+
end
|
|
159
|
+
puts ""
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def handle_saved(subcmd, target, options)
|
|
163
|
+
hostname, site_url, _ = Base.resolve_domain(options[:domain], nil, options)
|
|
164
|
+
list = Config.list_saved_keywords(hostname)
|
|
165
|
+
sub = subcmd.to_s.downcase
|
|
166
|
+
|
|
167
|
+
if sub == 'delete' || sub == 'rm'
|
|
168
|
+
if target.nil? || target.strip.empty?
|
|
169
|
+
puts Color.c("❌ Error: Specify snapshot ID or filename to delete (e.g. gsc saved delete 1)", Color::RED)
|
|
170
|
+
return
|
|
171
|
+
end
|
|
172
|
+
ok = Config.delete_saved_keywords(hostname, target)
|
|
173
|
+
if ok
|
|
174
|
+
puts Color.c("🗑️ Deleted keyword research snapshot: #{target}", Color::GREEN)
|
|
175
|
+
else
|
|
176
|
+
puts Color.c("❌ Snapshot not found: #{target}", Color::RED)
|
|
177
|
+
end
|
|
178
|
+
return
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
if sub == 'view' || sub == 'show'
|
|
182
|
+
identifier = target || '1'
|
|
183
|
+
data = Config.load_saved_keywords(hostname, identifier)
|
|
184
|
+
if data.nil?
|
|
185
|
+
puts Color.c("❌ Could not find saved snapshot '#{identifier}' for #{hostname}", Color::RED)
|
|
186
|
+
return
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
if options[:json]
|
|
190
|
+
puts JSON.pretty_generate(data)
|
|
191
|
+
return
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
195
|
+
puts "📂 SAVED KEYWORD SNAPSHOT: #{Color.c(data['seed'].to_s, Color::CYAN, Color::BOLD)} (#{data['source']})"
|
|
196
|
+
puts " Domain: #{hostname} · Saved: #{data['savedAt']} · Total Keywords: #{data['totalKeywords']}\n\n"
|
|
197
|
+
|
|
198
|
+
render_keyword_table(data['keywords'] || [], limit: options[:limit] || 50)
|
|
199
|
+
puts Color.c("Showing Top #{[data['totalKeywords'], options[:limit] || 50].min} of #{data['totalKeywords']} keywords.", Color::GRAY)
|
|
200
|
+
return
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
if sub == 'check' || sub == 'track' || sub == 'rankings'
|
|
204
|
+
identifier = target || '1'
|
|
205
|
+
data = Config.load_saved_keywords(hostname, identifier)
|
|
206
|
+
if data.nil?
|
|
207
|
+
puts Color.c("❌ Could not find saved snapshot '#{identifier}' for #{hostname}", Color::RED)
|
|
208
|
+
return
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
212
|
+
puts "🔍 RE-CHECKING SAVED KEYWORDS AGAINST GOOGLE SEARCH CONSOLE"
|
|
213
|
+
puts " Snapshot: #{Color.c(data['seed'].to_s, Color::CYAN, Color::BOLD)} (#{data['totalKeywords']} keywords)"
|
|
214
|
+
puts " Target Property: #{Color.c(site_url || hostname, Color::CYAN)}\n\n"
|
|
215
|
+
|
|
216
|
+
key_path = Auth.find_key(options[:key])
|
|
217
|
+
unless key_path
|
|
218
|
+
puts Color.c("❌ Error: Service account JSON key not found. Run gsc connect to configure.", Color::RED)
|
|
219
|
+
return
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
service_account = JSON.parse(File.read(key_path))
|
|
223
|
+
token = Auth.fetch_access_token(service_account)
|
|
224
|
+
client = Client.new(token: token)
|
|
225
|
+
api = API.new(client)
|
|
226
|
+
|
|
227
|
+
raw_keywords = (data['keywords'] || []).map do |kw|
|
|
228
|
+
{
|
|
229
|
+
keyword: kw['keyword'] || kw[:keyword],
|
|
230
|
+
volume: (kw['volume'] || kw[:volume] || 0).to_i,
|
|
231
|
+
cpc: kw['cpc'] || kw[:cpc] || '$0.00',
|
|
232
|
+
competition: (kw['competition'] || kw[:competition] || 0.0).to_f,
|
|
233
|
+
competition_tier: kw['competition_tier'] || kw[:competition_tier] || 'Low',
|
|
234
|
+
trend_pct: (kw['trend_pct'] || kw[:trend_pct] || 0).to_i,
|
|
235
|
+
opportunity_score: (kw['opportunity_score'] || kw[:opportunity_score] || 0).to_i,
|
|
236
|
+
intent: kw['intent'] || kw[:intent] || 'Informational',
|
|
237
|
+
monthly_history: kw['monthly_history'] || kw[:monthly_history] || {}
|
|
238
|
+
}
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
updated = KeywordPlanner.correlate_with_gsc(raw_keywords, api, site_url, days: options[:days] || 30)
|
|
242
|
+
|
|
243
|
+
if options[:json]
|
|
244
|
+
puts JSON.pretty_generate({
|
|
245
|
+
domain: hostname,
|
|
246
|
+
siteUrl: site_url,
|
|
247
|
+
seed: data['seed'],
|
|
248
|
+
checkedAt: Time.now.utc.iso8601,
|
|
249
|
+
totalKeywords: updated.size,
|
|
250
|
+
keywords: updated
|
|
251
|
+
})
|
|
252
|
+
return
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
render_keyword_table(updated, limit: options[:limit] || 50)
|
|
256
|
+
|
|
257
|
+
ranking_count = updated.count { |k| k.dig(:gsc, :status) != '🚀 Untargeted' }
|
|
258
|
+
top3_count = updated.count { |k| k.dig(:gsc, :position).to_f > 0 && k.dig(:gsc, :position).to_f <= 3.0 }
|
|
259
|
+
page1_count = updated.count { |k| k.dig(:gsc, :position).to_f > 3.0 && k.dig(:gsc, :position).to_f <= 10.0 }
|
|
260
|
+
striking_count = updated.count { |k| k.dig(:gsc, :position).to_f > 10.0 && k.dig(:gsc, :position).to_f <= 20.0 }
|
|
261
|
+
|
|
262
|
+
puts "\n#{Color::BOLD}══════════════════════════════════════════════════════════════#{Color::RESET}"
|
|
263
|
+
puts "#{Color::BOLD}📊 KEYWORD RANKING & OPPORTUNITY TRACKER (#{hostname})#{Color::RESET}"
|
|
264
|
+
puts "#{Color::BOLD}══════════════════════════════════════════════════════════════#{Color::RESET}"
|
|
265
|
+
puts " 🏆 Top 3 Rankings: #{Color.c(top3_count.to_s, Color::GREEN, Color::BOLD)}"
|
|
266
|
+
puts " 🥇 Page 1 Rankings (4–10): #{Color.c(page1_count.to_s, Color::GREEN, Color::BOLD)}"
|
|
267
|
+
puts " 🎯 Striking Distance (11–20): #{Color.c(striking_count.to_s, Color::YELLOW, Color::BOLD)}"
|
|
268
|
+
puts " 🚀 Untargeted / Unranked: #{Color.c((updated.size - ranking_count).to_s, Color::GRAY)}"
|
|
269
|
+
puts " 📈 Total Tracked Keywords: #{updated.size}"
|
|
270
|
+
puts "#{Color::BOLD}══════════════════════════════════════════════════════════════#{Color::RESET}\n"
|
|
271
|
+
return
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
if options[:json]
|
|
275
|
+
puts JSON.pretty_generate({ domain: hostname, snapshots: list })
|
|
276
|
+
return
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
280
|
+
puts "📁 SAVED KEYWORD RESEARCH ARCHIVES (#{Color.c(hostname, Color::CYAN, Color::BOLD)})"
|
|
281
|
+
puts " Location: #{Config.domain_keywords_dir(hostname)}\n\n"
|
|
282
|
+
|
|
283
|
+
if list.empty?
|
|
284
|
+
puts Color.c("ℹ️ No saved keyword research found for #{hostname}.", Color::YELLOW)
|
|
285
|
+
puts "\n💡 Quick Start:"
|
|
286
|
+
puts " Run #{Color.c('gsc import docs/KW.md', Color::CYAN)} to import and save an export."
|
|
287
|
+
puts " Or run #{Color.c('gsc planner "seo audit" --save', Color::CYAN)} to save search ideas."
|
|
288
|
+
puts
|
|
289
|
+
return
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
puts "#{Color::BOLD} # | Date | Source | Keywords | Seed / File#{Color::RESET}"
|
|
293
|
+
puts " --------------------------------------------------------------------------------"
|
|
294
|
+
list.each_with_index do |item, idx|
|
|
295
|
+
num = "[#{idx + 1}]".rjust(4)
|
|
296
|
+
date_str = item['savedAt'].to_s.split('T').first.ljust(10)
|
|
297
|
+
source_str = item['source'].to_s.gsub('_', ' ').capitalize.ljust(21)
|
|
298
|
+
kw_count = item['totalKeywords'].to_s.rjust(8)
|
|
299
|
+
seed_str = Color.c(item['seed'].to_s, Color::CYAN)
|
|
300
|
+
puts "#{Color.c(num, Color::CYAN, Color::BOLD)} | #{date_str} | #{source_str} | #{kw_count} | #{seed_str}"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
puts "\n💡 Commands:"
|
|
304
|
+
puts " #{Color.c('gsc saved view 1', Color::CYAN)} # View stored keywords & metrics"
|
|
305
|
+
puts " #{Color.c('gsc saved check 1', Color::GREEN)} # Re-check live against Search Console rankings"
|
|
306
|
+
puts " #{Color.c('gsc saved delete 1', Color::RED)} # Delete archive snapshot"
|
|
307
|
+
puts
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def handle_planner_import(filepath, options)
|
|
311
|
+
src_arg = filepath.to_s.strip
|
|
312
|
+
if src_arg.empty?
|
|
313
|
+
clip_sample = KeywordPlanner.read_clipboard.to_s
|
|
314
|
+
if clip_sample.include?("\t") || clip_sample =~ /keyword/i
|
|
315
|
+
src_arg = 'clipboard'
|
|
316
|
+
else
|
|
317
|
+
if options[:json]
|
|
318
|
+
puts JSON.pretty_generate({ error: 'Please specify a file path or use clipboard (e.g. gsc import keywords.csv or gsc import clip)' })
|
|
319
|
+
else
|
|
320
|
+
puts Color.c("❌ Error: Please specify a file path to import or copy keyword data to clipboard.", Color::RED)
|
|
321
|
+
puts " Usage: #{Color.c('gsc import path/to/keywords.csv', Color::CYAN)}"
|
|
322
|
+
puts " #{Color.c('gsc import clip', Color::GREEN)} (Imports directly from copied table)"
|
|
323
|
+
end
|
|
324
|
+
return
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
puts Base::BANNER unless options[:json] || options[:in_dashboard]
|
|
329
|
+
src_label = src_arg =~ /clip/i ? 'macOS Clipboard' : src_arg
|
|
330
|
+
puts "📥 Importing keyword dataset from: #{Color.c(src_label, Color::CYAN)}...\n" unless options[:json]
|
|
331
|
+
|
|
332
|
+
raw_keywords = KeywordPlanner.import_file(src_arg)
|
|
333
|
+
|
|
334
|
+
api = nil
|
|
335
|
+
site_url = nil
|
|
336
|
+
begin
|
|
337
|
+
key_path = Auth.find_key(options[:key])
|
|
338
|
+
if key_path
|
|
339
|
+
service_account = JSON.parse(File.read(key_path))
|
|
340
|
+
token = Auth.fetch_access_token(service_account)
|
|
341
|
+
client = Client.new(token: token)
|
|
342
|
+
api = API.new(client)
|
|
343
|
+
hostname, site_url, _ = Base.resolve_domain(options[:domain], nil, options)
|
|
344
|
+
puts "🔗 Cross-referencing against Google Search Console: #{Color.c(site_url, Color::CYAN)}...\n" unless options[:json]
|
|
345
|
+
end
|
|
346
|
+
rescue StandardError
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
keywords = KeywordPlanner.correlate_with_gsc(raw_keywords, api, site_url, days: options[:days] || 30)
|
|
350
|
+
limit = options[:limit] || 35
|
|
351
|
+
displayed = keywords.first(limit)
|
|
352
|
+
|
|
353
|
+
if options[:json]
|
|
354
|
+
puts JSON.pretty_generate({
|
|
355
|
+
file: filepath,
|
|
356
|
+
totalKeywords: keywords.size,
|
|
357
|
+
siteUrl: site_url,
|
|
358
|
+
keywords: displayed
|
|
359
|
+
})
|
|
360
|
+
return
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
render_keyword_table(keywords, limit: limit)
|
|
364
|
+
|
|
365
|
+
active_dom = hostname || Config.default_domain || 'global'
|
|
366
|
+
src_label = (src_arg =~ /clip/i) ? 'clipboard-export' : File.basename(src_arg, '.*')
|
|
367
|
+
saved_path = Config.save_keyword_research(active_dom, src_label, keywords, source: 'import')
|
|
368
|
+
puts Color.c("💾 Saved snapshot to domain archive: #{saved_path}", Color::CYAN)
|
|
369
|
+
puts " Re-check anytime with: #{Color.c('gsc saved check 1', Color::GREEN)} or #{Color.c('gsc saved view 1', Color::YELLOW)}\n"
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def handle_connect_ke(key_arg = nil)
|
|
373
|
+
puts Base::BANNER unless $stdout.tty? == false
|
|
374
|
+
puts "#{Color::BOLD}🔑 KEYWORDS EVERYWHERE API SETUP#{Color::RESET}\n"
|
|
375
|
+
|
|
376
|
+
key = key_arg.to_s.strip
|
|
377
|
+
if key.empty?
|
|
378
|
+
existing = Config.keywords_everywhere_api_key
|
|
379
|
+
if existing && !existing.empty?
|
|
380
|
+
masked = "#{existing[0..5]}...#{existing[-4..]}" rescue "******"
|
|
381
|
+
puts "Current API Key: #{Color.c(masked, Color::CYAN)}"
|
|
382
|
+
print "Enter new API key (or press Enter to keep current): "
|
|
383
|
+
input = $stdin.gets&.strip
|
|
384
|
+
key = input unless input.nil? || input.empty?
|
|
385
|
+
key = existing if key.empty?
|
|
386
|
+
else
|
|
387
|
+
puts "Get your Keywords Everywhere API key at: #{Color.c('https://keywordseverywhere.com/', Color::UNDERLINE)}"
|
|
388
|
+
print "Paste your Keywords Everywhere API Key: "
|
|
389
|
+
key = $stdin.gets&.strip
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
if key.nil? || key.empty?
|
|
394
|
+
puts Color.c("\n❌ No API key provided.", Color::RED)
|
|
395
|
+
return
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
puts "\n⏳ Validating key with Keywords Everywhere API..."
|
|
399
|
+
acct = KeywordsEverywhere.check_account(key)
|
|
400
|
+
if acct[:ok]
|
|
401
|
+
Config.set_keywords_everywhere_api_key(key)
|
|
402
|
+
fmt_credits = acct[:credits].to_s.reverse.gsub(/(\d{3})(?=\d)/, ',').reverse
|
|
403
|
+
puts Color.c("\n✅ SUCCESS! Keywords Everywhere API key connected.", Color::GREEN, Color::BOLD)
|
|
404
|
+
puts " 💰 Remaining Credits: #{Color.c(fmt_credits, Color::CYAN, Color::BOLD)}"
|
|
405
|
+
puts " 📁 Saved to: #{Color.c(Config::CONFIG_FILE, Color::GRAY)}"
|
|
406
|
+
|
|
407
|
+
if acct[:credits].to_i == 0
|
|
408
|
+
puts "\n" + Color.c("ℹ️ FREE ACCOUNT DETECTED (0 REST API Credits)", Color::YELLOW, Color::BOLD)
|
|
409
|
+
puts "Keywords Everywhere gives free accounts #{Color.c('500 free lookups/day in their Web UI', Color::GREEN)},"
|
|
410
|
+
puts "while their REST API requires purchased credits (1 credit = 1 keyword).\n\n"
|
|
411
|
+
puts "#{Color::BOLD}🚀 3 FAST WORKFLOWS YOU CAN USE RIGHT NOW:#{Color::RESET}"
|
|
412
|
+
puts " 1. #{Color.c('Free 500/day on Web + 1-Click Clipboard Import:', Color::GREEN, Color::BOLD)}"
|
|
413
|
+
puts " • Check search volume at: #{Color.c('https://keywordseverywhere.com/tools/bulk-keywords-data', Color::UNDERLINE)}"
|
|
414
|
+
puts " • Click 'Copy' -> Run: #{Color.c('gsc import clip', Color::CYAN, Color::BOLD)}"
|
|
415
|
+
puts " 2. #{Color.c('100% Free Live Google Trends (Breakout & Related terms):', Color::BLUE, Color::BOLD)}"
|
|
416
|
+
puts " • Run: #{Color.c('gsc trends "seo audit"', Color::BLUE, Color::BOLD)}"
|
|
417
|
+
puts " 3. #{Color.c('Top Up Credits for Automated Background CLI Lookups:', Color::GRAY, Color::BOLD)}"
|
|
418
|
+
puts " • Purchase credits at: https://keywordseverywhere.com/credit-packages.html"
|
|
419
|
+
puts " • Your key is already saved; it will work immediately upon top-up.\n"
|
|
420
|
+
else
|
|
421
|
+
puts "\nTry searching keywords:"
|
|
422
|
+
puts " #{Color.c('gsc ke "seo audit"', Color::YELLOW)}"
|
|
423
|
+
end
|
|
424
|
+
else
|
|
425
|
+
puts Color.c("\n❌ Invalid API Key: #{acct[:error]}", Color::RED)
|
|
426
|
+
puts "Please check your key and try again: #{Color.c('gsc connect ke', Color::CYAN)}"
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def handle_ke_credits(options = {})
|
|
431
|
+
acct = KeywordsEverywhere.check_account
|
|
432
|
+
if options[:json]
|
|
433
|
+
puts JSON.pretty_generate(acct)
|
|
434
|
+
return
|
|
435
|
+
end
|
|
436
|
+
if acct[:ok]
|
|
437
|
+
fmt_credits = acct[:credits].to_s.reverse.gsub(/(\d{3})(?=\d)/, ',').reverse
|
|
438
|
+
puts "\n💰 #{Color::BOLD}Keywords Everywhere Account Credits:#{Color::RESET} #{Color.c(fmt_credits, Color::GREEN, Color::BOLD)}"
|
|
439
|
+
if acct[:credits].to_i == 0
|
|
440
|
+
puts "\n" + Color.c("ℹ️ You have 0 paid REST API credits remaining.", Color::YELLOW)
|
|
441
|
+
puts "• Free Web Alternative: Check volume on keywordseverywhere.com, click 'Copy', then run: #{Color.c('gsc import clip', Color::CYAN)}"
|
|
442
|
+
puts "• Free Live Trends: #{Color.c('gsc trends "seo audit"', Color::BLUE)}"
|
|
443
|
+
puts "• Top up credits at: https://keywordseverywhere.com/credit-packages.html"
|
|
444
|
+
end
|
|
445
|
+
else
|
|
446
|
+
if acct[:error].to_s =~ /401|Unauthorized/i
|
|
447
|
+
puts Color.c("\n❌ Invalid or expired API Key: #{acct[:error]}", Color::RED)
|
|
448
|
+
puts "Update your key: #{Color.c('gsc connect ke', Color::CYAN)}"
|
|
449
|
+
else
|
|
450
|
+
puts Color.c("\n❌ Error: #{acct[:error]}", Color::RED)
|
|
451
|
+
puts "Connect your key: #{Color.c('gsc connect ke', Color::CYAN)}"
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def handle_ke(target, options = {})
|
|
457
|
+
if target.nil? || target.strip.empty?
|
|
458
|
+
puts Color.c("❌ Please provide a seed keyword or file path. e.g.: gsc ke 'seo audit'", Color::RED)
|
|
459
|
+
return
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
country = options[:country] || 'us'
|
|
463
|
+
currency = options[:currency] || 'usd'
|
|
464
|
+
limit = (options[:limit] || 25).to_i
|
|
465
|
+
|
|
466
|
+
unless KeywordsEverywhere.api_key
|
|
467
|
+
if options[:json]
|
|
468
|
+
puts JSON.pretty_generate({
|
|
469
|
+
ok: false,
|
|
470
|
+
error: "Keywords Everywhere API key not configured",
|
|
471
|
+
recommendations: [
|
|
472
|
+
{ action: 'connect', cmd: 'gsc connect ke', desc: 'Connect Keywords Everywhere API key' },
|
|
473
|
+
{ action: 'google_trends', cmd: "gsc trends \"#{target}\"", desc: 'Use 100% free Google Trends without any API key' },
|
|
474
|
+
{ action: 'import_clipboard', cmd: 'gsc import clip', desc: 'Import 500 daily free web lookups from clipboard' }
|
|
475
|
+
]
|
|
476
|
+
})
|
|
477
|
+
else
|
|
478
|
+
puts Color.c("\n❌ Keywords Everywhere API key not configured!\n", Color::RED, Color::BOLD)
|
|
479
|
+
puts "#{Color::BOLD}RECOMMENDATIONS:#{Color::RESET}"
|
|
480
|
+
puts " 1. Connect a key: #{Color.c('gsc connect ke', Color::CYAN, Color::BOLD)} (Get key at https://keywordseverywhere.com/)"
|
|
481
|
+
puts " 2. Use 100% free Google Trends (No key needed!): #{Color.c("gsc trends \"#{target}\"", Color::BLUE, Color::BOLD)}"
|
|
482
|
+
puts " 3. Use 500 free daily web lookups: Copy web table -> #{Color.c('gsc import clip', Color::GREEN, Color::BOLD)}\n"
|
|
483
|
+
end
|
|
484
|
+
return
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
keywords_to_check = []
|
|
488
|
+
is_file = File.exist?(target) || target.end_with?('.csv', '.tsv', '.txt')
|
|
489
|
+
if is_file
|
|
490
|
+
if File.exist?(target)
|
|
491
|
+
content = File.read(target, encoding: 'UTF-8') rescue ''
|
|
492
|
+
keywords_to_check = content.lines.map(&:strip).reject { |l| l.empty? || l.start_with?('#') }
|
|
493
|
+
else
|
|
494
|
+
puts Color.c("❌ File not found: #{target}", Color::RED)
|
|
495
|
+
return
|
|
496
|
+
end
|
|
497
|
+
else
|
|
498
|
+
suggestions = KeywordPlanner.expand(target, country: country, limit: [limit, 50].min)
|
|
499
|
+
keywords_to_check = ([target] + suggestions).uniq
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
keywords_to_check = keywords_to_check.first([limit, 100].max)
|
|
503
|
+
|
|
504
|
+
puts "⏳ Fetching volume & CPC data from Keywords Everywhere API..." unless options[:json]
|
|
505
|
+
|
|
506
|
+
res = KeywordsEverywhere.fetch_data(keywords_to_check, country: country, currency: currency)
|
|
507
|
+
unless res[:ok]
|
|
508
|
+
err_str = res[:error].to_s
|
|
509
|
+
if options[:json]
|
|
510
|
+
reason = if err_str =~ /402|Insufficient Credits/i
|
|
511
|
+
'insufficient_credits'
|
|
512
|
+
elsif err_str =~ /401|Unauthorized/i
|
|
513
|
+
'unauthorized'
|
|
514
|
+
elsif err_str =~ /429|Rate Limit/i
|
|
515
|
+
'rate_limited'
|
|
516
|
+
else
|
|
517
|
+
'api_error'
|
|
518
|
+
end
|
|
519
|
+
puts JSON.pretty_generate({
|
|
520
|
+
ok: false,
|
|
521
|
+
error: err_str,
|
|
522
|
+
reason: reason,
|
|
523
|
+
recommendations: [
|
|
524
|
+
{ action: 'import_clipboard', cmd: 'gsc import clip', desc: 'Use 500 free daily web lookups on keywordseverywhere.com and import via clipboard' },
|
|
525
|
+
{ action: 'google_trends', cmd: "gsc trends \"#{target}\"", desc: '100% Free real-time search interest & rising breakout queries' },
|
|
526
|
+
{ action: 'gsc_analytics', cmd: 'gsc top-queries', desc: 'Real impressions & clicks from your active Google Search Console domain' },
|
|
527
|
+
{ action: 'topup', url: 'https://keywordseverywhere.com/credit-packages.html', desc: 'Purchase API credits to enable automated background CLI lookups' }
|
|
528
|
+
]
|
|
529
|
+
})
|
|
530
|
+
return
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
if err_str =~ /402|Insufficient Credits/i
|
|
534
|
+
puts Color.c("\n💳 Keywords Everywhere: Insufficient API Credits (0 remaining)\n", Color::YELLOW, Color::BOLD)
|
|
535
|
+
puts "Your API key is active, but Keywords Everywhere's REST API requires paid credits."
|
|
536
|
+
puts "The 500 free lookups/day are available on their website.\n\n"
|
|
537
|
+
puts "#{Color::BOLD}🚀 4 RECOMMENDED ALTERNATIVES (START RIGHT NOW):#{Color::RESET}\n"
|
|
538
|
+
puts " 1. #{Color.c('Free 500 Lookups / Day on Web + 1-Click Clipboard Import', Color::GREEN, Color::BOLD)}"
|
|
539
|
+
puts " • Go to: #{Color.c('https://keywordseverywhere.com/tools/bulk-keywords-data', Color::UNDERLINE)}"
|
|
540
|
+
puts " • Enter keywords -> Click #{Color.c('Check Volume', Color::BOLD)} -> Click #{Color.c('Copy', Color::CYAN)}"
|
|
541
|
+
puts " • In your terminal, run:"
|
|
542
|
+
puts " #{Color.c('gsc import clip', Color::CYAN, Color::BOLD)}\n"
|
|
543
|
+
puts " 2. #{Color.c('Google Trends Intelligence (100% Free, Zero Setup)', Color::BLUE, Color::BOLD)}"
|
|
544
|
+
puts " • Live interest curves, breakout queries, and seasonal demand:"
|
|
545
|
+
puts " #{Color.c("gsc trends \"#{target}\"", Color::BLUE, Color::BOLD)}\n"
|
|
546
|
+
puts " 3. #{Color.c('Your Real Search Console Rankings & Opportunities', Color::MAGENTA, Color::BOLD)}"
|
|
547
|
+
puts " • High-impression queries and Page 2 striking-distance keywords:"
|
|
548
|
+
puts " #{Color.c('gsc top-queries', Color::MAGENTA, Color::BOLD)} or #{Color.c('gsc opportunities', Color::MAGENTA, Color::BOLD)}\n"
|
|
549
|
+
puts " 4. #{Color.c('Top Up API Credits for Automated Background CLI Lookups', Color::GRAY, Color::BOLD)}"
|
|
550
|
+
puts " • Purchase credits at: #{Color.c('https://keywordseverywhere.com/credit-packages.html', Color::UNDERLINE)}"
|
|
551
|
+
puts " • Your key is already saved; it will work immediately once credited.\n"
|
|
552
|
+
elsif err_str =~ /401|Unauthorized/i
|
|
553
|
+
puts Color.c("\n🔑 Invalid or Expired Keywords Everywhere API Key", Color::RED, Color::BOLD)
|
|
554
|
+
puts "Your API key was rejected by api.keywordseverywhere.com (HTTP 401).\n\n"
|
|
555
|
+
puts "#{Color::BOLD}RECOMMENDATIONS:#{Color::RESET}"
|
|
556
|
+
puts " • Check or generate your API key at: #{Color.c('https://keywordseverywhere.com/first-install-addon.html', Color::UNDERLINE)}"
|
|
557
|
+
puts " • Reconnect key: #{Color.c('gsc connect ke <KEY>', Color::CYAN, Color::BOLD)}"
|
|
558
|
+
puts " • Or use free Google Trends without any key: #{Color.c("gsc trends \"#{target}\"", Color::BLUE)}"
|
|
559
|
+
elsif err_str =~ /429|Rate Limit/i
|
|
560
|
+
puts Color.c("\n⏳ Keywords Everywhere Rate Limit Exceeded", Color::YELLOW, Color::BOLD)
|
|
561
|
+
puts "Too many requests were sent in a short period.\n\n"
|
|
562
|
+
puts "#{Color::BOLD}RECOMMENDATIONS:#{Color::RESET}"
|
|
563
|
+
puts " • Wait 15-30 seconds and retry: #{Color.c("gsc ke \"#{target}\"", Color::CYAN)}"
|
|
564
|
+
puts " • Reduce batch size with: #{Color.c("gsc ke \"#{target}\" --limit 10", Color::CYAN)}"
|
|
565
|
+
else
|
|
566
|
+
puts Color.c("\n❌ Keywords Everywhere API Error: #{err_str}", Color::RED)
|
|
567
|
+
puts "\n#{Color::BOLD}RECOMMENDATIONS:#{Color::RESET}"
|
|
568
|
+
puts " • Try free Google Trends instead: #{Color.c("gsc trends \"#{target}\"", Color::BLUE)}"
|
|
569
|
+
puts " • Or check account status with: #{Color.c('gsc ke-credits', Color::CYAN)}"
|
|
570
|
+
end
|
|
571
|
+
return
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
items = res[:data]
|
|
575
|
+
|
|
576
|
+
site_url = options[:site] || Config.default_domain
|
|
577
|
+
has_gsc = false
|
|
578
|
+
if site_url && Auth.find_key(options[:key])
|
|
579
|
+
begin
|
|
580
|
+
auth = Auth.new(Auth.find_key(options[:key]))
|
|
581
|
+
api = API.new(auth.token)
|
|
582
|
+
items = KeywordPlanner.correlate_with_gsc(items, api, site_url, days: options[:days] || 30)
|
|
583
|
+
has_gsc = true
|
|
584
|
+
rescue StandardError
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
items.sort_by! { |i| [-i[:opportunity_score].to_i, -i[:volume].to_i] }
|
|
589
|
+
items = items.first(limit)
|
|
590
|
+
|
|
591
|
+
if options[:json]
|
|
592
|
+
puts JSON.pretty_generate({
|
|
593
|
+
seed: target,
|
|
594
|
+
provider: 'keywordseverywhere',
|
|
595
|
+
country: country,
|
|
596
|
+
currency: currency,
|
|
597
|
+
count: items.size,
|
|
598
|
+
siteUrl: site_url,
|
|
599
|
+
keywords: items
|
|
600
|
+
})
|
|
601
|
+
return
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
puts "\n#{Color::BOLD}🔍 KEYWORDS EVERYWHERE SEARCH DEMAND#{Color::RESET} (Seed: #{Color.c(target, Color::CYAN)})"
|
|
605
|
+
puts " Country: #{country.upcase} · Provider: Google Keyword Planner via Keywords Everywhere API"
|
|
606
|
+
puts " Correlated with GSC: #{has_gsc ? Color.c(site_url, Color::GREEN) : Color.c('None', Color::GRAY)}\n\n"
|
|
607
|
+
|
|
608
|
+
printf " %-34s %10s %8s %7s %10s %-14s %s\n",
|
|
609
|
+
"KEYWORD", "VOL/MO", "CPC", "COMP", "OPP SCORE", "INTENT", (has_gsc ? "GSC RANK STATUS" : "")
|
|
610
|
+
puts " " + ("─" * (has_gsc ? 110 : 85))
|
|
611
|
+
|
|
612
|
+
items.each do |item|
|
|
613
|
+
opp = item[:opportunity_score]
|
|
614
|
+
opp_color = opp >= 70 ? Color::GREEN : (opp >= 45 ? Color::YELLOW : Color::GRAY)
|
|
615
|
+
cpc_str = format("$%.2f", item[:cpc])
|
|
616
|
+
vol_str = item[:volume] > 0 ? item[:volume].to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse : '-'
|
|
617
|
+
comp_str = format("%.2f", item[:competition])
|
|
618
|
+
|
|
619
|
+
intent_color = case item[:intent]
|
|
620
|
+
when 'Transactional' then Color::GREEN
|
|
621
|
+
when 'Commercial' then Color::CYAN
|
|
622
|
+
when 'Informational' then Color::MAGENTA
|
|
623
|
+
else Color::GRAY
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
gsc_col = (has_gsc && item[:gsc]) ? item[:gsc][:status] : ""
|
|
627
|
+
|
|
628
|
+
printf " %-34s %10s %8s %7s %s %-14s %s\n",
|
|
629
|
+
Color.c(item[:keyword].to_s[0..33], Color::BOLD),
|
|
630
|
+
vol_str,
|
|
631
|
+
cpc_str,
|
|
632
|
+
comp_str,
|
|
633
|
+
Color.c(format("%3d/100", opp), opp_color, Color::BOLD),
|
|
634
|
+
Color.c(item[:intent], intent_color),
|
|
635
|
+
gsc_col
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
puts " " + ("─" * (has_gsc ? 110 : 85))
|
|
639
|
+
puts " 💡 #{Color::BOLD}Opportunity Score (0-100):#{Color::RESET} High search volume + low ad competition index."
|
|
640
|
+
puts
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def handle_planner_expand(seed, options)
|
|
644
|
+
if seed.nil? || seed.strip.empty?
|
|
645
|
+
if options[:json]
|
|
646
|
+
puts JSON.pretty_generate({ error: 'Please specify a seed keyword (e.g. gsc planner "seo audit")' })
|
|
647
|
+
else
|
|
648
|
+
puts Color.c("❌ Error: Please specify a seed keyword.", Color::RED)
|
|
649
|
+
puts " Usage: #{Color.c('gsc planner "seo audit"', Color::CYAN)}"
|
|
650
|
+
end
|
|
651
|
+
return
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
puts Base::BANNER unless options[:json] || options[:in_dashboard]
|
|
655
|
+
country = options[:country] || 'us'
|
|
656
|
+
limit = options[:limit] || 40
|
|
657
|
+
puts "💡 Expanding keyword ideas for seed: #{Color.c(seed, Color::CYAN)} (Country: #{country})...\n" unless options[:json]
|
|
658
|
+
|
|
659
|
+
raw_keywords = KeywordPlanner.expand(seed, country: country, limit: limit)
|
|
660
|
+
|
|
661
|
+
api = nil
|
|
662
|
+
site_url = nil
|
|
663
|
+
begin
|
|
664
|
+
key_path = Auth.find_key(options[:key])
|
|
665
|
+
if key_path
|
|
666
|
+
service_account = JSON.parse(File.read(key_path))
|
|
667
|
+
token = Auth.fetch_access_token(service_account)
|
|
668
|
+
client = Client.new(token: token)
|
|
669
|
+
api = API.new(client)
|
|
670
|
+
hostname, site_url, _ = Base.resolve_domain(options[:domain], nil, options)
|
|
671
|
+
puts "🔗 Cross-referencing against Google Search Console: #{Color.c(site_url, Color::CYAN)}...\n" unless options[:json]
|
|
672
|
+
end
|
|
673
|
+
rescue StandardError
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
keywords = KeywordPlanner.correlate_with_gsc(raw_keywords, api, site_url, days: options[:days] || 30)
|
|
677
|
+
|
|
678
|
+
if options[:json]
|
|
679
|
+
puts JSON.pretty_generate({
|
|
680
|
+
seed: seed,
|
|
681
|
+
country: country,
|
|
682
|
+
siteUrl: site_url,
|
|
683
|
+
count: keywords.size,
|
|
684
|
+
suggestions: keywords
|
|
685
|
+
})
|
|
686
|
+
return
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
puts "#{Color::BOLD}Intent | GSC Status | Keyword Suggestion#{Color::RESET}"
|
|
690
|
+
puts "--------------------------------------------------------------------------------------------------"
|
|
691
|
+
keywords.each do |r|
|
|
692
|
+
intent_color = case r[:intent]
|
|
693
|
+
when 'Commercial' then Color::YELLOW
|
|
694
|
+
when 'Transactional' then Color::GREEN
|
|
695
|
+
when 'Informational' then Color::CYAN
|
|
696
|
+
else Color::GRAY
|
|
697
|
+
end
|
|
698
|
+
intent_str = Color.c(r[:intent].ljust(13), intent_color)
|
|
699
|
+
gsc_status = r.dig(:gsc, :status) || '🚀 Untargeted'
|
|
700
|
+
gsc_str = gsc_status.ljust(27)
|
|
701
|
+
kw_str = Color.c(r[:keyword], Color::BOLD)
|
|
702
|
+
|
|
703
|
+
puts "#{intent_str} | #{gsc_str} | #{kw_str}"
|
|
704
|
+
end
|
|
705
|
+
puts ""
|
|
706
|
+
puts Color.c("Total Keyword Ideas Generated: #{keywords.size}", Color::GRAY)
|
|
707
|
+
puts ""
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def handle_suggest(target, options)
|
|
711
|
+
query = target.to_s.strip
|
|
712
|
+
if query.empty?
|
|
713
|
+
if options[:json]
|
|
714
|
+
puts JSON.pretty_generate({ error: 'Query required. Example: gsc suggest "seo audit"' })
|
|
715
|
+
else
|
|
716
|
+
puts Color.c("❌ Error: Query required. Example: gsc suggest \"seo audit\"", Color::RED)
|
|
717
|
+
end
|
|
718
|
+
return
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
alphabet = options[:alphabet] || false
|
|
722
|
+
suggest = GSC::GoogleSuggest.new(query, options)
|
|
723
|
+
results = suggest.fetch(alphabet: alphabet, questions: false)
|
|
724
|
+
|
|
725
|
+
if options[:json]
|
|
726
|
+
puts JSON.pretty_generate(results)
|
|
727
|
+
return
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
731
|
+
puts "💡 #{Color::BOLD}GOOGLE SEARCH SUGGESTIONS:#{Color::RESET} #{Color.c(query, Color::CYAN)}"
|
|
732
|
+
puts "─" * 70
|
|
733
|
+
|
|
734
|
+
if alphabet
|
|
735
|
+
results.each do |key, list|
|
|
736
|
+
next if list.empty?
|
|
737
|
+
prefix = (key == 'root') ? "Root" : "+ #{key.upcase}"
|
|
738
|
+
puts "\n#{Color.c(prefix, Color::BOLD, Color::YELLOW)}:"
|
|
739
|
+
list.each do |item|
|
|
740
|
+
puts " • #{item[:term]}"
|
|
741
|
+
end
|
|
742
|
+
end
|
|
743
|
+
else
|
|
744
|
+
if results.empty?
|
|
745
|
+
puts " (No search suggestions returned)"
|
|
746
|
+
else
|
|
747
|
+
results.each_with_index do |item, idx|
|
|
748
|
+
puts " #{Color.c((idx + 1).to_s.rjust(2), Color::DIM)}. #{Color.c(item[:term], Color::BOLD)}"
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
puts ""
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
def handle_questions(target, options)
|
|
756
|
+
query = target.to_s.strip
|
|
757
|
+
if query.empty?
|
|
758
|
+
if options[:json]
|
|
759
|
+
puts JSON.pretty_generate({ error: 'Query required. Example: gsc questions "seo audit"' })
|
|
760
|
+
else
|
|
761
|
+
puts Color.c("❌ Error: Query required. Example: gsc questions \"seo audit\"", Color::RED)
|
|
762
|
+
end
|
|
763
|
+
return
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
suggest = GSC::GoogleSuggest.new(query, options)
|
|
767
|
+
results = suggest.fetch(questions: true)
|
|
768
|
+
|
|
769
|
+
if options[:json]
|
|
770
|
+
puts JSON.pretty_generate(results)
|
|
771
|
+
return
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
puts Base::BANNER unless options[:in_dashboard]
|
|
775
|
+
puts "❓ #{Color::BOLD}SEARCH INTENT QUESTIONS & FAQs:#{Color::RESET} #{Color.c(query, Color::CYAN)}"
|
|
776
|
+
puts "─" * 70
|
|
777
|
+
|
|
778
|
+
total_found = 0
|
|
779
|
+
results.each do |prefix, list|
|
|
780
|
+
next if list.empty?
|
|
781
|
+
puts "\n#{Color.c(prefix.upcase, Color::BOLD, Color::CYAN)}:"
|
|
782
|
+
list.each do |item|
|
|
783
|
+
total_found += 1
|
|
784
|
+
puts " • #{item[:term]}"
|
|
785
|
+
end
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
if total_found.zero?
|
|
789
|
+
puts " (No question suggestions found for \"#{query}\")"
|
|
790
|
+
end
|
|
791
|
+
puts ""
|
|
792
|
+
end
|
|
793
|
+
end
|
|
794
|
+
end
|
|
795
|
+
end
|