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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/AUTH.md +205 -0
  3. data/FUNDING.md +120 -0
  4. data/README.md +463 -299
  5. data/bin/gsc +29158 -4921
  6. data/dist/gsc +29158 -4921
  7. data/lib/gsc/aio_hunter.rb +343 -0
  8. data/lib/gsc/answer_synthesizer.rb +157 -0
  9. data/lib/gsc/api.rb +53 -1
  10. data/lib/gsc/auth.rb +26 -0
  11. data/lib/gsc/backlinks_manager.rb +96 -0
  12. data/lib/gsc/brand_segmenter.rb +140 -0
  13. data/lib/gsc/cache_manager.rb +806 -0
  14. data/lib/gsc/cannibalization_analyzer.rb +141 -0
  15. data/lib/gsc/canonical_chains.rb +367 -0
  16. data/lib/gsc/citation_simulator.rb +339 -0
  17. data/lib/gsc/cli/aio_hunter.rb +154 -0
  18. data/lib/gsc/cli/analytics.rb +788 -0
  19. data/lib/gsc/cli/audit.rb +1976 -0
  20. data/lib/gsc/cli/base.rb +384 -0
  21. data/lib/gsc/cli/cache.rb +266 -0
  22. data/lib/gsc/cli/canonical.rb +223 -0
  23. data/lib/gsc/cli/citation_simulator.rb +152 -0
  24. data/lib/gsc/cli/dashboard.rb +354 -0
  25. data/lib/gsc/cli/doctor.rb +129 -0
  26. data/lib/gsc/cli/eeat.rb +125 -0
  27. data/lib/gsc/cli/ga4.rb +852 -0
  28. data/lib/gsc/cli/growth.rb +650 -0
  29. data/lib/gsc/cli/hreflang.rb +164 -0
  30. data/lib/gsc/cli/image_seo.rb +162 -0
  31. data/lib/gsc/cli/indexing.rb +458 -0
  32. data/lib/gsc/cli/intent_shift.rb +125 -0
  33. data/lib/gsc/cli/keyword_value.rb +134 -0
  34. data/lib/gsc/cli/keywords.rb +795 -0
  35. data/lib/gsc/cli/landing_roi.rb +308 -0
  36. data/lib/gsc/cli/low_ctr.rb +213 -0
  37. data/lib/gsc/cli/mobile_parity.rb +150 -0
  38. data/lib/gsc/cli/report.rb +100 -0
  39. data/lib/gsc/cli/rich_results.rb +172 -0
  40. data/lib/gsc/cli/schema_generate.rb +149 -0
  41. data/lib/gsc/cli/seasonal.rb +232 -0
  42. data/lib/gsc/cli/security.rb +153 -0
  43. data/lib/gsc/cli/setup.rb +1291 -0
  44. data/lib/gsc/cli/sitemap_tree.rb +143 -0
  45. data/lib/gsc/cli/skill_pack.rb +62 -0
  46. data/lib/gsc/cli/soft_404.rb +199 -0
  47. data/lib/gsc/cli/sparkline.rb +227 -0
  48. data/lib/gsc/cli/watchdog.rb +150 -0
  49. data/lib/gsc/cli/zombie_purger.rb +208 -0
  50. data/lib/gsc/cli.rb +706 -5111
  51. data/lib/gsc/cli_advanced.rb +1513 -0
  52. data/lib/gsc/client.rb +17 -2
  53. data/lib/gsc/color.rb +16 -1
  54. data/lib/gsc/command_registry.rb +47 -9
  55. data/lib/gsc/config.rb +11 -2
  56. data/lib/gsc/content_gap.rb +112 -0
  57. data/lib/gsc/ctr_curve.rb +115 -0
  58. data/lib/gsc/decay_predictor.rb +322 -0
  59. data/lib/gsc/doctor.rb +434 -0
  60. data/lib/gsc/eeat_auditor.rb +428 -0
  61. data/lib/gsc/entity_auditor.rb +229 -0
  62. data/lib/gsc/firewall_scanner.rb +733 -0
  63. data/lib/gsc/geo_auditor.rb +368 -0
  64. data/lib/gsc/google_suggest.rb +109 -0
  65. data/lib/gsc/google_trends.rb +8 -1
  66. data/lib/gsc/heading_validator.rb +283 -0
  67. data/lib/gsc/hreflang_validator.rb +412 -0
  68. data/lib/gsc/image_seo.rb +286 -0
  69. data/lib/gsc/indexing_queue.rb +179 -0
  70. data/lib/gsc/indexnow.rb +93 -0
  71. data/lib/gsc/intent_shift.rb +188 -0
  72. data/lib/gsc/internal_links.rb +249 -0
  73. data/lib/gsc/keyword_value.rb +191 -0
  74. data/lib/gsc/landing_roi.rb +195 -0
  75. data/lib/gsc/llms_generator.rb +425 -0
  76. data/lib/gsc/low_ctr_rewriter.rb +408 -0
  77. data/lib/gsc/mobile_parity.rb +222 -0
  78. data/lib/gsc/network_tracer.rb +93 -0
  79. data/lib/gsc/open_page_rank.rb +72 -0
  80. data/lib/gsc/page_analyzer.rb +47 -7
  81. data/lib/gsc/page_comparator.rb +108 -0
  82. data/lib/gsc/page_speed.rb +110 -0
  83. data/lib/gsc/prompts.rb +38 -29
  84. data/lib/gsc/questions_harvester.rb +178 -0
  85. data/lib/gsc/report_generator.rb +461 -0
  86. data/lib/gsc/rich_results.rb +388 -0
  87. data/lib/gsc/robots_checker.rb +114 -0
  88. data/lib/gsc/schema_generator.rb +788 -0
  89. data/lib/gsc/schema_validator.rb +120 -0
  90. data/lib/gsc/seasonal_predictor.rb +381 -0
  91. data/lib/gsc/security_scanner.rb +496 -0
  92. data/lib/gsc/serp_feature_detector.rb +359 -0
  93. data/lib/gsc/serp_preview.rb +152 -0
  94. data/lib/gsc/site_crawler.rb +113 -21
  95. data/lib/gsc/sitemap_loader.rb +15 -4
  96. data/lib/gsc/sitemap_tree.rb +301 -0
  97. data/lib/gsc/skill_pack.rb +195 -0
  98. data/lib/gsc/soft_404_analyzer.rb +385 -0
  99. data/lib/gsc/sparkline.rb +171 -0
  100. data/lib/gsc/speed_correlator.rb +416 -0
  101. data/lib/gsc/striking_playbook.rb +190 -0
  102. data/lib/gsc/title_optimizer.rb +420 -0
  103. data/lib/gsc/vault.rb +260 -0
  104. data/lib/gsc/version.rb +1 -1
  105. data/lib/gsc/watchdog.rb +235 -0
  106. data/lib/gsc/zombie_purger.rb +366 -0
  107. data/lib/gsc.rb +144 -0
  108. metadata +91 -2
@@ -0,0 +1,368 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'json'
7
+ require 'zlib'
8
+ require 'stringio'
9
+
10
+ module GSC
11
+ class GeoAuditor
12
+ AI_BOTS = {
13
+ 'GPTBot' => { provider: 'OpenAI (ChatGPT Search)', critical: true },
14
+ 'ClaudeBot' => { provider: 'Anthropic (Claude AI)', critical: true },
15
+ 'PerplexityBot' => { provider: 'Perplexity AI Search', critical: true },
16
+ 'Google-Extended' => { provider: 'Google (Gemini & AI Overviews)', critical: true },
17
+ 'Applebot-Extended' => { provider: 'Apple Intelligence', critical: false },
18
+ 'CCBot' => { provider: 'Common Crawl (Open LLM Datasets)', critical: false }
19
+ }.freeze
20
+
21
+ QUESTION_HEADING_REGEX = /\b(what|why|how|can|does|is|are|which|best|vs|versus|difference|guide|review|pricing|steps|definition)\b/i
22
+
23
+ attr_reader :url, :html, :http_status, :headers, :response_time_ms
24
+
25
+ def initialize(url_or_domain)
26
+ raw = url_or_domain.to_s.strip
27
+ raw = "https://#{raw}" unless raw =~ %r{^https?://}
28
+ @url = raw
29
+ @uri = URI.parse(@url)
30
+ end
31
+
32
+ def audit
33
+ fetch_page
34
+ return { error: true, message: "HTTP #{@http_status} fetching #{@url}" } unless @http_status == 200 && !@html.empty?
35
+
36
+ ai_crawler_status = audit_ai_crawlers
37
+ answer_density = audit_direct_answers
38
+ fact_density = audit_factual_citability
39
+ entity_authority = audit_entity_schema
40
+ temporal_freshness = audit_freshness
41
+
42
+ # Scoring (0-100)
43
+ crawler_score = calculate_crawler_score(ai_crawler_status)
44
+ answer_score = calculate_answer_score(answer_density)
45
+ fact_score = calculate_fact_score(fact_density)
46
+ entity_score = calculate_entity_score(entity_authority)
47
+
48
+ total_score = crawler_score + answer_score + fact_score + entity_score
49
+
50
+ recommendations = generate_recommendations(
51
+ ai_crawler_status,
52
+ answer_density,
53
+ fact_density,
54
+ entity_authority,
55
+ temporal_freshness
56
+ )
57
+
58
+ {
59
+ url: @url,
60
+ score: total_score,
61
+ grade: score_grade(total_score),
62
+ category_scores: {
63
+ ai_bot_access: { score: crawler_score, max: 25 },
64
+ direct_answers: { score: answer_score, max: 25 },
65
+ facts_and_data: { score: fact_score, max: 25 },
66
+ entity_schema: { score: entity_score, max: 25 }
67
+ },
68
+ ai_crawlers: ai_crawler_status,
69
+ direct_answers: answer_density,
70
+ facts_and_citability: fact_density,
71
+ entity_knowledge_graph: entity_authority,
72
+ freshness: temporal_freshness,
73
+ recommendations: recommendations
74
+ }
75
+ end
76
+
77
+ private
78
+
79
+ def fetch_page
80
+ start_t = Time.now
81
+ http = Net::HTTP.new(@uri.host, @uri.port)
82
+ http.use_ssl = (@uri.scheme == 'https')
83
+ http.open_timeout = 8
84
+ http.read_timeout = 15
85
+
86
+ req = Net::HTTP::Get.new(@uri.request_uri.empty? ? '/' : @uri.request_uri)
87
+ req['User-Agent'] = "Mozilla/5.0 (compatible; GSC-GEO-Auditor/#{GSC::VERSION}; +https://apolloswave.com)"
88
+ req['Accept-Encoding'] = 'gzip'
89
+
90
+ res = http.request(req)
91
+ @response_time_ms = ((Time.now - start_t) * 1000).round(1)
92
+ @http_status = res.code.to_i
93
+ @headers = res.to_hash
94
+
95
+ raw_body = res.body || ''
96
+ body_str = if res['content-encoding'] =~ /gzip/i && !raw_body.empty?
97
+ begin
98
+ Zlib::GzipReader.new(StringIO.new(raw_body)).read
99
+ rescue StandardError
100
+ raw_body
101
+ end
102
+ else
103
+ raw_body
104
+ end
105
+ @html = body_str.to_s.dup.force_encoding('UTF-8').scrub
106
+ rescue StandardError => e
107
+ @http_status = 0
108
+ @html = ''
109
+ @error_msg = e.message
110
+ end
111
+
112
+ def audit_ai_crawlers
113
+ robots_url = "#{@uri.scheme}://#{@uri.host}:#{@uri.port}/robots.txt"
114
+ robots_txt = ''
115
+ begin
116
+ res = Net::HTTP.get_response(URI.parse(robots_url))
117
+ robots_txt = res.body.to_s.dup.force_encoding('UTF-8').scrub if res.code == '200'
118
+ rescue StandardError
119
+ robots_txt = ''
120
+ end
121
+
122
+ # Check robots meta in page
123
+ robots_meta = @html[/<meta\s+[^>]*name=['"]robots['"][^>]*content=['"]([^'"]+)['"]/i, 1] || ''
124
+ noindex = robots_meta =~ /noindex/i
125
+ nosnippet = robots_meta =~ /nosnippet/i
126
+
127
+ checker = RobotsChecker.new(@url)
128
+ bot_results = {}
129
+
130
+ AI_BOTS.each do |bot_name, meta|
131
+ rule = checker.check(@uri.path, bot_name)
132
+ allowed = rule[:allowed] && !noindex
133
+ bot_results[bot_name] = {
134
+ provider: meta[:provider],
135
+ allowed: allowed,
136
+ critical: meta[:critical],
137
+ rule: rule[:matched_rule] ? "#{rule[:matched_rule][:type]}: #{rule[:matched_rule][:path]}" : 'Default (Allowed)'
138
+ }
139
+ end
140
+
141
+ {
142
+ robots_txt_found: !robots_txt.empty?,
143
+ meta_noindex: !!noindex,
144
+ meta_nosnippet: !!nosnippet,
145
+ bots: bot_results,
146
+ all_critical_allowed: bot_results.select { |_, v| v[:critical] }.all? { |_, v| v[:allowed] }
147
+ }
148
+ end
149
+
150
+ def audit_direct_answers
151
+ # Find headings that look like user questions
152
+ headings = []
153
+ @html.scan(/<(h[23])[^>]*>(.*?)<\/\1>/im) do |tag, text|
154
+ clean = text.gsub(/<[^>]+>/, '').strip
155
+ next if clean.empty?
156
+
157
+ is_question = (clean =~ QUESTION_HEADING_REGEX) || clean.end_with?('?')
158
+ headings << { tag: tag, text: clean, is_question: !!is_question }
159
+ end
160
+
161
+ # Analyze paragraphs following question headings
162
+ answer_blocks = []
163
+ headings.select { |h| h[:is_question] }.each do |h|
164
+ pattern = /<#{h[:tag]}[^>]*>#{Regexp.escape(h[:text])}<\/#{h[:tag]}>\s*<p[^>]*>(.*?)<\/p>/im
165
+ match = @html[pattern, 1]
166
+ if match
167
+ ans_text = match.gsub(/<[^>]+>/, '').strip
168
+ words = ans_text.split(/\s+/).size
169
+ # Ideal direct answer for LLM citation is 30-80 words
170
+ optimal = words >= 30 && words <= 80
171
+ answer_blocks << {
172
+ question: h[:text],
173
+ answer_preview: ans_text[0..120] + (ans_text.length > 120 ? '...' : ''),
174
+ word_count: words,
175
+ optimal_length: optimal
176
+ }
177
+ end
178
+ end
179
+
180
+ {
181
+ total_question_headings: headings.count { |h| h[:is_question] },
182
+ direct_answer_blocks_found: answer_blocks.size,
183
+ optimal_answers_count: answer_blocks.count { |a| a[:optimal_length] },
184
+ samples: answer_blocks.first(3)
185
+ }
186
+ end
187
+
188
+ def audit_factual_citability
189
+ clean = @html.gsub(/<script\b[^>]*>.*?<\/script>/im, ' ')
190
+ .gsub(/<style\b[^>]*>.*?<\/style>/im, ' ')
191
+
192
+ # Count statistical data points
193
+ percentages = clean.scan(/\b\d+(?:\.\d+)?%/).size
194
+ currency_points = clean.scan(/(?:\$|€|£)\s*\d+(?:,\d{3})*(?:\.\d+)?/).size
195
+ numbers = clean.scan(/\b\d+(?:,\d{3})+(?:\.\d+)?\b/).size
196
+ recent_years = clean.scan(/\b(202[4-6])\b/).size
197
+
198
+ # Count structured list items
199
+ list_items = clean.scan(/<li\b[^>]*>(.*?)<\/li>/im).size
200
+
201
+ # Count table rows
202
+ table_rows = clean.scan(/<tr\b[^>]*>/im).size
203
+
204
+ {
205
+ percentage_mentions: percentages,
206
+ currency_mentions: currency_points,
207
+ large_numbers: numbers,
208
+ recent_year_mentions: recent_years,
209
+ bullet_list_items: list_items,
210
+ comparison_table_rows: table_rows,
211
+ high_fact_density: (percentages + currency_points + numbers >= 5) || (list_items >= 6)
212
+ }
213
+ end
214
+
215
+ def audit_entity_schema
216
+ schemas = []
217
+ @html.scan(/<script\s+[^>]*type=['"]application\/ld\+json['"][^>]*>(.*?)<\/script>/im) do |m|
218
+ content = m.first.to_s.strip
219
+ begin
220
+ parsed = JSON.parse(content)
221
+ schemas.concat(parsed.is_a?(Array) ? parsed : [parsed])
222
+ rescue StandardError
223
+ nil
224
+ end
225
+ end
226
+
227
+ types = schemas.map { |s| s['@type'] }.compact.flatten
228
+ same_as = []
229
+ author_info = nil
230
+ brand_info = nil
231
+
232
+ schemas.each do |s|
233
+ same_as.concat(Array(s['sameAs'])) if s['sameAs']
234
+ author_info ||= s['author'] if s['author']
235
+ brand_info ||= (s['brand'] || s['name']) if %w[Organization Brand WebSite Product].include?(s['@type'])
236
+ end
237
+
238
+ same_as_domains = same_as.map do |link|
239
+ begin
240
+ URI.parse(link.to_s).host
241
+ rescue StandardError
242
+ link.to_s
243
+ end
244
+ end.compact.uniq
245
+
246
+ has_wiki = same_as_domains.any? { |d| d =~ /wikipedia|wikidata/i }
247
+ has_social_entity = same_as_domains.any? { |d| d =~ /linkedin|twitter|x\.com|youtube|github/i }
248
+
249
+ {
250
+ schema_count: schemas.size,
251
+ schema_types: types.uniq,
252
+ has_organization_or_brand: types.any? { |t| %w[Organization Brand WebSite].include?(t) },
253
+ has_faq_or_howto: types.any? { |t| %w[FAQPage HowTo QAPage].include?(t) },
254
+ has_article_schema: types.any? { |t| %w[Article NewsArticle BlogPosting TechArticle].include?(t) },
255
+ has_author: !author_info.nil?,
256
+ same_as_links_count: same_as.size,
257
+ same_as_domains: same_as_domains,
258
+ wikidata_or_wikipedia_linked: has_wiki,
259
+ social_entity_linked: has_social_entity
260
+ }
261
+ end
262
+
263
+ def audit_freshness
264
+ published = @html[/<meta\s+[^>]*property=['"]article:published_time['"][^>]*content=['"]([^'"]+)['"]/i, 1] ||
265
+ @html[/<meta\s+[^>]*name=['"]pubdate['"][^>]*content=['"]([^'"]+)['"]/i, 1]
266
+ modified = @html[/<meta\s+[^>]*property=['"]article:modified_time['"][^>]*content=['"]([^'"]+)['"]/i, 1] ||
267
+ @html[/<meta\s+[^>]*name=['"]last-modified['"][^>]*content=['"]([^'"]+)['"]/i, 1]
268
+
269
+ {
270
+ published_date: published,
271
+ modified_date: modified,
272
+ has_dates: !published.nil? || !modified.nil?
273
+ }
274
+ end
275
+
276
+ def calculate_crawler_score(ai_crawlers)
277
+ score = 0
278
+ critical_bots = ai_crawlers[:bots].select { |_, b| b[:critical] }
279
+ allowed_count = critical_bots.count { |_, b| b[:allowed] }
280
+
281
+ score += (allowed_count.to_f / [critical_bots.size, 1].max * 20).round
282
+ score += 5 unless ai_crawlers[:meta_nosnippet] || ai_crawlers[:meta_noindex]
283
+ score
284
+ end
285
+
286
+ def calculate_answer_score(answers)
287
+ score = 0
288
+ score += 8 if answers[:total_question_headings] >= 2
289
+ score += 10 if answers[:direct_answer_blocks_found] >= 2
290
+ score += 7 if answers[:optimal_answers_count] >= 1
291
+ [score, 25].min
292
+ end
293
+
294
+ def calculate_fact_score(facts)
295
+ score = 0
296
+ score += 8 if (facts[:percentage_mentions] + facts[:currency_mentions] + facts[:large_numbers]) >= 3
297
+ score += 9 if facts[:bullet_list_items] >= 5
298
+ score += 5 if facts[:recent_year_mentions] >= 1
299
+ score += 3 if facts[:comparison_table_rows] >= 2
300
+ [score, 25].min
301
+ end
302
+
303
+ def calculate_entity_score(entity)
304
+ score = 0
305
+ score += 7 if entity[:has_organization_or_brand]
306
+ score += 7 if entity[:has_faq_or_howto] || entity[:has_article_schema]
307
+ score += 6 if entity[:same_as_links_count] >= 1
308
+ score += 5 if entity[:wikidata_or_wikipedia_linked] || entity[:social_entity_linked]
309
+ [score, 25].min
310
+ end
311
+
312
+ def score_grade(score)
313
+ case score
314
+ when 85..100 then 'A (Exceptional AI Search Citability)'
315
+ when 70..84 then 'B (Good - Minor Entity & Direct Answer Gaps)'
316
+ when 50..69 then 'C (Moderate - Missing Key AI Bot Access or Schema)'
317
+ else 'D/F (Poor - High Risk of Being Ignored by LLMs)'
318
+ end
319
+ end
320
+
321
+ def generate_recommendations(crawlers, answers, facts, entity, freshness)
322
+ recs = []
323
+
324
+ # AI Crawlers
325
+ blocked = crawlers[:bots].select { |_, b| !b[:allowed] }
326
+ if blocked.any?
327
+ names = blocked.keys.join(', ')
328
+ recs << "Unblock #{names} in /robots.txt to permit ChatGPT, Claude, and Perplexity from quoting this URL."
329
+ end
330
+ if crawlers[:meta_nosnippet]
331
+ recs << "Remove 'nosnippet' directive from robots meta tag; LLMs require snippet extraction to cite your content."
332
+ end
333
+
334
+ # Direct Answers
335
+ if answers[:direct_answer_blocks_found] == 0
336
+ recs << "Add 2+ question headings (H2/H3 'What is...', 'How to...') followed immediately by a concise 40-60 word definition paragraph."
337
+ elsif answers[:optimal_answers_count] == 0
338
+ recs << "Tighten paragraph lengths following question headings to 40-70 words. Overly long prose reduces LLM snippet selection."
339
+ end
340
+
341
+ # Factual Citability
342
+ if !facts[:high_fact_density]
343
+ recs << "Inject concrete statistics (percentages, metrics, pricing) and bulleted key takeaways; LLMs favor citing verified data points over generic prose."
344
+ end
345
+ if facts[:bullet_list_items] < 4
346
+ recs << "Add structured bulleted summaries (<ul>/<li>) beneath major section headers for easy machine extraction."
347
+ end
348
+
349
+ # Entity Schema
350
+ if !entity[:has_organization_or_brand]
351
+ recs << "Add JSON-LD 'Organization' or 'Brand' structured data with official entity name and logo."
352
+ end
353
+ if entity[:same_as_links_count] == 0
354
+ recs << "Add 'sameAs' links inside your Organization JSON-LD pointing to Wikipedia, Wikidata, LinkedIn, or Twitter/X to disambiguate your brand in LLM Knowledge Graphs."
355
+ end
356
+ if !entity[:has_faq_or_howto]
357
+ recs << "Implement FAQPage JSON-LD schema wrapping your common questions and answers."
358
+ end
359
+
360
+ # Freshness
361
+ unless freshness[:has_dates]
362
+ recs << "Include visible publication and modification dates (and 'datePublished'/'dateModified' in schema) to signal content freshness to AI answer engines."
363
+ end
364
+
365
+ recs
366
+ end
367
+ end
368
+ end
@@ -0,0 +1,109 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'json'
7
+
8
+ module GSC
9
+ class GoogleSuggest
10
+ SUGGEST_URL = 'https://suggestqueries.google.com/complete/search'
11
+
12
+ attr_reader :query, :options
13
+
14
+ def initialize(query, options = {})
15
+ @query = query.to_s.strip
16
+ @options = options
17
+ end
18
+
19
+ def fetch(alphabet: false, questions: false)
20
+ if questions
21
+ fetch_questions
22
+ elsif alphabet
23
+ fetch_alphabet_soup
24
+ else
25
+ fetch_single(@query)
26
+ end
27
+ end
28
+
29
+ def fetch_single(search_term)
30
+ uri = URI("#{SUGGEST_URL}?client=chrome&q=#{URI.encode_www_form_component(search_term)}")
31
+ req = Net::HTTP::Get.new(uri)
32
+ req['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
33
+ req['Accept'] = 'application/json, text/javascript, */*; q=0.01'
34
+
35
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10) do |http|
36
+ http.request(req)
37
+ end
38
+
39
+ return [] unless res.code == '200'
40
+
41
+ parsed = JSON.parse(res.body.force_encoding('UTF-8'))
42
+ terms = parsed[1] || []
43
+ types = parsed[4] ? parsed[4]['google:suggesttype'] || [] : []
44
+
45
+ terms.map.with_index do |term, idx|
46
+ {
47
+ term: term,
48
+ type: types[idx] || 'QUERY',
49
+ root: search_term
50
+ }
51
+ end
52
+ rescue StandardError => e
53
+ []
54
+ end
55
+
56
+ def fetch_alphabet_soup
57
+ results = {}
58
+ base = @query.strip
59
+
60
+ # Root query first
61
+ results['root'] = fetch_single(base)
62
+
63
+ # A-Z permutations
64
+ ('a'..'z').each do |letter|
65
+ term = "#{base} #{letter}"
66
+ items = fetch_single(term)
67
+ results[letter] = items unless items.empty?
68
+ sleep(0.05) # Polite throttle
69
+ end
70
+
71
+ # 0-9 permutations if requested
72
+ if @options[:numbers]
73
+ ('0'..'9').each do |num|
74
+ term = "#{base} #{num}"
75
+ items = fetch_single(term)
76
+ results[num] = items unless items.empty?
77
+ sleep(0.05)
78
+ end
79
+ end
80
+
81
+ results
82
+ end
83
+
84
+ def fetch_questions
85
+ prefixes = [
86
+ 'how to',
87
+ 'how do',
88
+ 'why do',
89
+ 'why does',
90
+ 'what is',
91
+ 'what are',
92
+ 'can you',
93
+ 'best',
94
+ 'where to',
95
+ 'which'
96
+ ]
97
+
98
+ results = {}
99
+ prefixes.each do |pfx|
100
+ term = "#{pfx} #{@query}"
101
+ items = fetch_single(term)
102
+ results[pfx] = items unless items.empty?
103
+ sleep(0.05)
104
+ end
105
+
106
+ results
107
+ end
108
+ end
109
+ end
@@ -63,7 +63,14 @@ class GoogleTrends
63
63
  explore_req['Cookie'] = cookies if cookies
64
64
 
65
65
  explore_res = http.request(explore_req)
66
- return { ok: false, error: "Google Trends Explore API error (HTTP #{explore_res.code})" } unless explore_res.is_a?(Net::HTTPSuccess)
66
+ if explore_res.code == '302'
67
+ loc = explore_res['location'].to_s
68
+ return { ok: false, error: "Google Trends rate limit or bot challenge triggered (HTTP 302 redirect to #{loc.include?('sorry') ? 'Google CAPTCHA' : 'redirect'}). Google is temporarily throttling Trends requests from this network. Try again in a few minutes, or use 'gsc suggest' / 'gsc planner'." }
69
+ elsif explore_res.code == '429' || init_res.code == '429'
70
+ return { ok: false, error: "Google Trends rate limit reached (HTTP 429 - Too Many Requests). Google is temporarily throttling Trends requests from this network. Try again in a few minutes, or use 'gsc suggest' / 'gsc planner'." }
71
+ elsif !explore_res.is_a?(Net::HTTPSuccess)
72
+ return { ok: false, error: "Google Trends Explore API error (HTTP #{explore_res.code})" }
73
+ end
67
74
 
68
75
  raw_exp = explore_res.body.to_s
69
76
  clean_body = raw_exp.index('{') ? raw_exp[raw_exp.index('{')..] : raw_exp