gsc-cli 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +410 -414
  3. data/bin/gsc +28067 -5661
  4. data/dist/gsc +29121 -5046
  5. data/lib/gsc/aio_hunter.rb +343 -0
  6. data/lib/gsc/answer_synthesizer.rb +157 -0
  7. data/lib/gsc/api.rb +53 -1
  8. data/lib/gsc/auth.rb +26 -0
  9. data/lib/gsc/brand_segmenter.rb +140 -0
  10. data/lib/gsc/cache_manager.rb +806 -0
  11. data/lib/gsc/cannibalization_analyzer.rb +141 -0
  12. data/lib/gsc/canonical_chains.rb +367 -0
  13. data/lib/gsc/citation_simulator.rb +339 -0
  14. data/lib/gsc/cli/aio_hunter.rb +154 -0
  15. data/lib/gsc/cli/analytics.rb +788 -0
  16. data/lib/gsc/cli/audit.rb +1976 -0
  17. data/lib/gsc/cli/base.rb +384 -0
  18. data/lib/gsc/cli/cache.rb +266 -0
  19. data/lib/gsc/cli/canonical.rb +223 -0
  20. data/lib/gsc/cli/citation_simulator.rb +152 -0
  21. data/lib/gsc/cli/dashboard.rb +354 -0
  22. data/lib/gsc/cli/doctor.rb +129 -0
  23. data/lib/gsc/cli/eeat.rb +125 -0
  24. data/lib/gsc/cli/ga4.rb +852 -0
  25. data/lib/gsc/cli/growth.rb +650 -0
  26. data/lib/gsc/cli/hreflang.rb +164 -0
  27. data/lib/gsc/cli/image_seo.rb +162 -0
  28. data/lib/gsc/cli/indexing.rb +458 -0
  29. data/lib/gsc/cli/intent_shift.rb +125 -0
  30. data/lib/gsc/cli/keyword_value.rb +134 -0
  31. data/lib/gsc/cli/keywords.rb +795 -0
  32. data/lib/gsc/cli/landing_roi.rb +308 -0
  33. data/lib/gsc/cli/low_ctr.rb +213 -0
  34. data/lib/gsc/cli/mobile_parity.rb +150 -0
  35. data/lib/gsc/cli/report.rb +100 -0
  36. data/lib/gsc/cli/rich_results.rb +172 -0
  37. data/lib/gsc/cli/schema_generate.rb +149 -0
  38. data/lib/gsc/cli/seasonal.rb +232 -0
  39. data/lib/gsc/cli/security.rb +153 -0
  40. data/lib/gsc/cli/setup.rb +1291 -0
  41. data/lib/gsc/cli/sitemap_tree.rb +143 -0
  42. data/lib/gsc/cli/skill_pack.rb +62 -0
  43. data/lib/gsc/cli/soft_404.rb +199 -0
  44. data/lib/gsc/cli/sparkline.rb +227 -0
  45. data/lib/gsc/cli/watchdog.rb +150 -0
  46. data/lib/gsc/cli/zombie_purger.rb +208 -0
  47. data/lib/gsc/cli.rb +707 -5265
  48. data/lib/gsc/cli_advanced.rb +987 -44
  49. data/lib/gsc/client.rb +17 -2
  50. data/lib/gsc/color.rb +16 -1
  51. data/lib/gsc/command_registry.rb +47 -9
  52. data/lib/gsc/config.rb +2 -2
  53. data/lib/gsc/ctr_curve.rb +115 -0
  54. data/lib/gsc/decay_predictor.rb +322 -0
  55. data/lib/gsc/doctor.rb +434 -0
  56. data/lib/gsc/eeat_auditor.rb +428 -0
  57. data/lib/gsc/entity_auditor.rb +229 -0
  58. data/lib/gsc/firewall_scanner.rb +733 -0
  59. data/lib/gsc/geo_auditor.rb +368 -0
  60. data/lib/gsc/google_trends.rb +8 -1
  61. data/lib/gsc/heading_validator.rb +283 -0
  62. data/lib/gsc/hreflang_validator.rb +412 -0
  63. data/lib/gsc/image_seo.rb +286 -0
  64. data/lib/gsc/indexing_queue.rb +179 -0
  65. data/lib/gsc/indexnow.rb +93 -0
  66. data/lib/gsc/intent_shift.rb +188 -0
  67. data/lib/gsc/internal_links.rb +153 -36
  68. data/lib/gsc/keyword_value.rb +191 -0
  69. data/lib/gsc/landing_roi.rb +195 -0
  70. data/lib/gsc/llms_generator.rb +343 -22
  71. data/lib/gsc/low_ctr_rewriter.rb +408 -0
  72. data/lib/gsc/mobile_parity.rb +222 -0
  73. data/lib/gsc/network_tracer.rb +8 -1
  74. data/lib/gsc/page_analyzer.rb +47 -7
  75. data/lib/gsc/prompts.rb +38 -29
  76. data/lib/gsc/questions_harvester.rb +178 -0
  77. data/lib/gsc/report_generator.rb +461 -0
  78. data/lib/gsc/rich_results.rb +388 -0
  79. data/lib/gsc/robots_checker.rb +46 -15
  80. data/lib/gsc/schema_generator.rb +788 -0
  81. data/lib/gsc/schema_validator.rb +36 -38
  82. data/lib/gsc/seasonal_predictor.rb +381 -0
  83. data/lib/gsc/security_scanner.rb +496 -0
  84. data/lib/gsc/serp_feature_detector.rb +359 -0
  85. data/lib/gsc/serp_preview.rb +108 -22
  86. data/lib/gsc/site_crawler.rb +113 -21
  87. data/lib/gsc/sitemap_loader.rb +15 -4
  88. data/lib/gsc/sitemap_tree.rb +301 -0
  89. data/lib/gsc/skill_pack.rb +195 -0
  90. data/lib/gsc/soft_404_analyzer.rb +385 -0
  91. data/lib/gsc/sparkline.rb +171 -0
  92. data/lib/gsc/speed_correlator.rb +416 -0
  93. data/lib/gsc/striking_playbook.rb +190 -0
  94. data/lib/gsc/title_optimizer.rb +420 -0
  95. data/lib/gsc/vault.rb +260 -0
  96. data/lib/gsc/version.rb +1 -1
  97. data/lib/gsc/watchdog.rb +235 -0
  98. data/lib/gsc/zombie_purger.rb +366 -0
  99. data/lib/gsc.rb +118 -0
  100. metadata +75 -1
@@ -0,0 +1,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