gsc-cli 2.0.1 → 2.1.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.
@@ -0,0 +1,570 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module GSC
8
+ class CLI
9
+ # 1. Google Suggest
10
+ def self.handle_suggest_command(target, options)
11
+ query = target.to_s.strip
12
+ if query.empty?
13
+ puts Color.c("āŒ Error: Query required. Example: gsc suggest \"moving boxes\"", Color::RED)
14
+ return
15
+ end
16
+
17
+ alphabet = options[:alphabet] || false
18
+ suggest = GSC::GoogleSuggest.new(query, options)
19
+ results = suggest.fetch(alphabet: alphabet, questions: false)
20
+
21
+ if options[:json]
22
+ puts JSON.pretty_generate(results)
23
+ return
24
+ end
25
+
26
+ puts BANNER unless options[:in_dashboard]
27
+ puts "šŸ’” #{Color::BOLD}GOOGLE SEARCH SUGGESTIONS:#{Color::RESET} #{Color.c(query, Color::CYAN)}"
28
+ puts "─" * 70
29
+
30
+ if alphabet
31
+ results.each do |key, list|
32
+ next if list.empty?
33
+ prefix = (key == 'root') ? "Root" : "+ #{key.upcase}"
34
+ puts "\n#{Color.c(prefix, Color::BOLD, Color::YELLOW)}:"
35
+ list.each do |item|
36
+ puts " • #{item[:term]}"
37
+ end
38
+ end
39
+ else
40
+ if results.empty?
41
+ puts " (No search suggestions returned)"
42
+ else
43
+ results.each_with_index do |item, idx|
44
+ puts " #{Color.c((idx + 1).to_s.rjust(2), Color::DIM)}. #{Color.c(item[:term], Color::BOLD)}"
45
+ end
46
+ end
47
+ end
48
+ puts ""
49
+ end
50
+
51
+ # 2. Questions / PAA
52
+ def self.handle_questions_command(target, options)
53
+ query = target.to_s.strip
54
+ if query.empty?
55
+ puts Color.c("āŒ Error: Query required. Example: gsc questions \"packing dishes\"", Color::RED)
56
+ return
57
+ end
58
+
59
+ suggest = GSC::GoogleSuggest.new(query, options)
60
+ results = suggest.fetch(questions: true)
61
+
62
+ if options[:json]
63
+ puts JSON.pretty_generate(results)
64
+ return
65
+ end
66
+
67
+ puts BANNER unless options[:in_dashboard]
68
+ puts "ā“ #{Color::BOLD}SEARCH INTENT QUESTIONS & FAQs:#{Color::RESET} #{Color.c(query, Color::CYAN)}"
69
+ puts "─" * 70
70
+
71
+ total_found = 0
72
+ results.each do |prefix, list|
73
+ next if list.empty?
74
+ puts "\n#{Color.c(prefix.upcase, Color::BOLD, Color::CYAN)}:"
75
+ list.each do |item|
76
+ total_found += 1
77
+ puts " • #{item[:term]}"
78
+ end
79
+ end
80
+
81
+ if total_found.zero?
82
+ puts " (No question suggestions found for \"#{query}\")"
83
+ end
84
+ puts ""
85
+ end
86
+
87
+ # 3. OpenPageRank Domain Authority
88
+ def self.handle_authority_command(target, extra, options)
89
+ domains = [target, extra].flatten.compact.reject { |d| d.to_s.strip.empty? }
90
+ domains << Config.default_domain if domains.empty?
91
+ domains = domains.compact
92
+
93
+ if domains.empty?
94
+ puts Color.c("āŒ Error: Domain required. Example: gsc authority packinglog.com", Color::RED)
95
+ return
96
+ end
97
+
98
+ opr = GSC::OpenPageRank.new
99
+ unless opr.configured?
100
+ puts Color.c("āš ļø OpenPageRank API key not configured.", Color::YELLOW, Color::BOLD)
101
+ puts " Get a 100% free key (300,000 free queries/month) at: #{Color.c('https://openpagerank.com', Color::CYAN)}"
102
+ puts " Then run: #{Color.c('gsc config set opr_api_key <YOUR_KEY>', Color::GREEN)}"
103
+ puts " Or pass: #{Color.c('OPENPAGERANK_API_KEY=<KEY> gsc authority ...', Color::DIM)}"
104
+ return
105
+ end
106
+
107
+ data = opr.check_domains(domains)
108
+
109
+ if options[:json]
110
+ puts JSON.pretty_generate(data)
111
+ return
112
+ end
113
+
114
+ puts BANNER unless options[:in_dashboard]
115
+ puts "🌐 #{Color::BOLD}OPEN PAGERANK & DOMAIN AUTHORITY (Common Crawl Graph):#{Color::RESET}"
116
+ puts "─" * 75
117
+
118
+ if data[:status] == 'error'
119
+ puts Color.c("āŒ Error: #{data[:message]}", Color::RED)
120
+ return
121
+ end
122
+
123
+ puts "#{'DOMAIN'.ljust(35)} #{'PAGERANK'.ljust(12)} #{'GLOBAL RANK'.ljust(18)} #{'STATUS'}"
124
+ puts "─" * 75
125
+
126
+ (data[:records] || []).each do |rec|
127
+ d_name = rec[:domain].to_s.ljust(35)
128
+ pr = sprintf("%.2f / 10", rec[:page_rank_decimal]).ljust(12)
129
+ gr = rec[:rank] ? "##{rec[:rank].to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}".ljust(18) : "N/A".ljust(18)
130
+ st = (rec[:status_code] == 200) ? Color.c("200 OK", Color::GREEN) : Color.c(rec[:status_code].to_s, Color::YELLOW)
131
+
132
+ puts "#{Color.c(d_name, Color::BOLD)} #{Color.c(pr, Color::CYAN)} #{Color.c(gr, Color::YELLOW)} #{st}"
133
+ end
134
+ puts ""
135
+ end
136
+
137
+ # 4. PageSpeed Core Web Vitals
138
+ def self.handle_speed_command(target, options)
139
+ url = target || "https://#{Config.default_domain || 'example.com'}"
140
+ url = "https://#{url}" unless url =~ %r{^https?://}
141
+ strategy = options[:strategy] || 'mobile'
142
+
143
+ puts BANNER unless options[:json] || options[:in_dashboard]
144
+ puts "⚔ Measuring Core Web Vitals via Google PageSpeed Insights (#{strategy.upcase}):" unless options[:json]
145
+ puts " #{Color.c(url, Color::CYAN)}\n" unless options[:json]
146
+
147
+ ps = GSC::PageSpeed.new(url, strategy: strategy)
148
+ data = ps.run
149
+
150
+ if options[:json]
151
+ puts JSON.pretty_generate(data)
152
+ return
153
+ end
154
+
155
+ if data[:error]
156
+ puts Color.c("āŒ PageSpeed API Error: #{data[:message]}", Color::RED)
157
+ return
158
+ end
159
+
160
+ perf_score = data[:performance_score]
161
+ score_color = perf_score >= 90 ? Color::GREEN : (perf_score >= 50 ? Color::YELLOW : Color::RED)
162
+
163
+ puts "╔══════════════════════════════════════════════════════════════╗"
164
+ puts "ā•‘ Lighthouse Performance Score: #{Color.c(perf_score.to_s.rjust(3) + ' / 100', score_color, Color::BOLD)} ā•‘"
165
+ puts "ā•‘ Lighthouse SEO Score : #{Color.c(data[:seo_score].to_s.rjust(3) + ' / 100', Color::GREEN, Color::BOLD)} ā•‘"
166
+ puts "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•"
167
+
168
+ m = data[:metrics] || {}
169
+ puts "\n#{Color::BOLD}šŸ“Š CORE WEB VITALS (Lab Metrics):#{Color::RESET}"
170
+ puts " • LCP (Largest Contentful Paint) : #{Color.c(m[:lcp] || 'N/A', Color::BOLD)}"
171
+ puts " • FCP (First Contentful Paint) : #{Color.c(m[:fcp] || 'N/A', Color::BOLD)}"
172
+ puts " • CLS (Cumulative Layout Shift) : #{Color.c(m[:cls] || 'N/A', Color::BOLD)}"
173
+ puts " • TBT (Total Blocking Time) : #{Color.c(m[:tbt] || 'N/A', Color::BOLD)}"
174
+ puts " • Speed Index : #{Color.c(m[:speed_index] || 'N/A', Color::BOLD)}"
175
+
176
+ opps = data[:opportunities] || []
177
+ unless opps.empty?
178
+ puts "\n#{Color::BOLD}šŸ’” TOP SPEED OPPORTUNITIES:#{Color::RESET}"
179
+ opps.each do |opp|
180
+ puts " • #{opp[:title]}: #{Color.c(opp[:display] || "#{opp[:savings_ms]}ms savings", Color::YELLOW)}"
181
+ end
182
+ end
183
+ puts ""
184
+ end
185
+
186
+ # 5. Page Comparison
187
+ def self.handle_compare_command(target, extra, options)
188
+ url1 = target
189
+ url2 = extra
190
+
191
+ if url1.nil? || url2.nil?
192
+ puts Color.c("āŒ Error: Two URLs required. Example: gsc compare https://site.com/p1 https://competitor.com/p2", Color::RED)
193
+ return
194
+ end
195
+
196
+ comp = GSC::PageComparator.new(url1, url2)
197
+ data = comp.compare
198
+
199
+ if options[:json]
200
+ puts JSON.pretty_generate(data)
201
+ return
202
+ end
203
+
204
+ puts BANNER unless options[:in_dashboard]
205
+ puts "🄊 #{Color::BOLD}HEAD-TO-HEAD SEO ON-PAGE COMPARISON:#{Color::RESET}"
206
+ puts " Page 1 (Target): #{Color.c(url1, Color::CYAN)}"
207
+ puts " Page 2 (Competitor): #{Color.c(url2, Color::YELLOW)}"
208
+ puts "─" * 80
209
+
210
+ c = data[:comparison] || {}
211
+
212
+ # Meta Titles
213
+ t1 = c.dig(:meta, :title, :page1) || {}
214
+ t2 = c.dig(:meta, :title, :page2) || {}
215
+ puts "\n#{Color::BOLD}šŸ“‘ TITLE TAG:#{Color::RESET}"
216
+ puts " P1: #{t1[:text]} (#{t1[:length]} chars) [#{t1[:optimal] ? Color.c('Optimal', Color::GREEN) : Color.c('Review', Color::YELLOW)}]"
217
+ puts " P2: #{t2[:text]} (#{t2[:length]} chars) [#{t2[:optimal] ? Color.c('Optimal', Color::GREEN) : Color.c('Review', Color::YELLOW)}]"
218
+
219
+ # Headings
220
+ h = c[:headings] || {}
221
+ puts "\n#{Color::BOLD}šŸ·ļø HEADINGS H1 / H2:#{Color::RESET}"
222
+ puts " P1: #{h.dig(:h1_count, :page1)} H1s | #{h.dig(:h2_count, :page1)} H2s"
223
+ puts " P2: #{h.dig(:h1_count, :page2)} H1s | #{h.dig(:h2_count, :page2)} H2s"
224
+
225
+ # Images
226
+ img = c[:images] || {}
227
+ puts "\n#{Color::BOLD}šŸ–¼ļø IMAGES & ACCESSIBILITY:#{Color::RESET}"
228
+ puts " P1: #{img.dig(:total_images, :page1)} images (#{img.dig(:missing_alt, :page1)} missing alt)"
229
+ puts " P2: #{img.dig(:total_images, :page2)} images (#{img.dig(:missing_alt, :page2)} missing alt)"
230
+
231
+ # Links
232
+ l = c[:links] || {}
233
+ puts "\n#{Color::BOLD}šŸ”— LINK COUNTS:#{Color::RESET}"
234
+ puts " P1: #{l.dig(:internal, :page1)} internal | #{l.dig(:external, :page1)} external"
235
+ puts " P2: #{l.dig(:internal, :page2)} internal | #{l.dig(:external, :page2)} external"
236
+
237
+ # Schema
238
+ s = c[:structured_data] || {}
239
+ puts "\n#{Color::BOLD}šŸ“¦ STRUCTURED DATA (JSON-LD):#{Color::RESET}"
240
+ puts " P1: #{s.dig(:schema_count, :page1)} schemas #{(s.dig(:schema_types, :page1) || []).inspect}"
241
+ puts " P2: #{s.dig(:schema_count, :page2)} schemas #{(s.dig(:schema_types, :page2) || []).inspect}"
242
+
243
+ # Speed
244
+ p_time = c[:performance] || {}
245
+ puts "\n#{Color::BOLD}⚔ RESPONSE TIME:#{Color::RESET}"
246
+ puts " P1: #{p_time.dig(:response_time_ms, :page1)}ms | P2: #{p_time.dig(:response_time_ms, :page2)}ms"
247
+ puts ""
248
+ end
249
+
250
+ # 6. Content Gap
251
+ def self.handle_content_gap_command(target, extra, options)
252
+ url1 = target
253
+ url2 = extra
254
+
255
+ if url1.nil? || url2.nil?
256
+ puts Color.c("āŒ Error: Two URLs required. Example: gsc content-gap https://mysite.com https://competitor.com", Color::RED)
257
+ return
258
+ end
259
+
260
+ gap = GSC::ContentGap.new(url1, url2)
261
+ data = gap.analyze
262
+
263
+ if options[:json]
264
+ puts JSON.pretty_generate(data)
265
+ return
266
+ end
267
+
268
+ puts BANNER unless options[:in_dashboard]
269
+ puts "šŸ” #{Color::BOLD}CONTENT & TOPICAL KEYWORD GAP (SurferSEO Style):#{Color::RESET}"
270
+ puts " My URL: #{Color.c(url1, Color::CYAN)} (#{data[:page1][:word_count]} words)"
271
+ puts " Competitor URL: #{Color.c(url2, Color::YELLOW)} (#{data[:page2][:word_count]} words)"
272
+ puts "─" * 80
273
+
274
+ puts "\n#{Color::BOLD}šŸŽÆ HIGH-FREQUENCY PHRASES IN COMPETITOR MISSING IN YOUR CONTENT:#{Color::RESET}"
275
+ unigrams = data[:missing_unigrams] || []
276
+ bigrams = data[:missing_bigrams] || []
277
+
278
+ if unigrams.empty? && bigrams.empty?
279
+ puts " (No major content gap detected! Your page covers competitor terminology well.)"
280
+ else
281
+ puts "\n #{Color.c('Top Missing 2-Word Keyphrases:', Color::BOLD, Color::YELLOW)}"
282
+ bigrams.first(8).each do |b|
283
+ puts " • \"#{Color.c(b[:term], Color::BOLD)}\" (Competitor uses #{b[:competitor_count]}x, You: #{b[:your_count]}x)"
284
+ end
285
+
286
+ puts "\n #{Color.c('Top Missing Keywords:', Color::BOLD, Color::CYAN)}"
287
+ unigrams.first(8).each do |u|
288
+ puts " • \"#{Color.c(u[:term], Color::BOLD)}\" (Competitor uses #{u[:competitor_count]}x, You: #{u[:your_count]}x)"
289
+ end
290
+ end
291
+
292
+ headings = data[:missing_headings] || []
293
+ unless headings.empty?
294
+ puts "\n#{Color::BOLD}šŸ“‘ COMPETITOR HEADINGS / TOPICS YOU OMITTED:#{Color::RESET}"
295
+ headings.each do |h|
296
+ puts " • #{h}"
297
+ end
298
+ end
299
+ puts ""
300
+ end
301
+
302
+ # 7. Internal Links Audit
303
+ def self.handle_internal_links_command(target, options)
304
+ base_url = target || "https://#{Config.default_domain || 'example.com'}"
305
+ il = GSC::InternalLinks.new(base_url, limit: options[:limit] || 50)
306
+
307
+ puts BANNER unless options[:json] || options[:in_dashboard]
308
+ puts "šŸ•øļø Auditing Internal Links & Orphan Pages for: #{Color.c(base_url, Color::CYAN)}...\n" unless options[:json]
309
+
310
+ data = il.audit
311
+
312
+ if options[:json]
313
+ puts JSON.pretty_generate(data)
314
+ return
315
+ end
316
+
317
+ puts "─" * 80
318
+ puts "Pages Discovered: #{Color.c(data[:total_pages].to_s, Color::BOLD)}"
319
+ puts "Orphan Pages: #{Color.c(data[:orphans].length.to_s, data[:orphans].empty? ? Color::GREEN : Color::RED, Color::BOLD)}"
320
+ puts "Weakly Linked: #{Color.c(data[:weak_pages].length.to_s, Color::YELLOW, Color::BOLD)} (Only 1 incoming internal link)"
321
+ puts "─" * 80
322
+
323
+ orphans = data[:orphans] || []
324
+ unless orphans.empty?
325
+ puts "\n#{Color::BOLD}🚨 ORPHAN PAGES (0 incoming internal links - Crawl Dead Ends):#{Color::RESET}"
326
+ orphans.each do |orp|
327
+ puts " • #{Color.c(orp, Color::RED)}"
328
+ end
329
+ end
330
+
331
+ puts "\n#{Color::BOLD}šŸ” MOST LINKED INTERNAL PAGES:#{Color::RESET}"
332
+ (data[:top_linked] || []).each do |top|
333
+ puts " • #{top[:url]} (#{Color.c(top[:incoming_count].to_s, Color::CYAN)} incoming links)"
334
+ end
335
+ puts ""
336
+ end
337
+
338
+ # 8. Schema Validator & Generator
339
+ def self.handle_schema_command(target, extra, options)
340
+ if target == 'generate' || target == 'gen'
341
+ schema_type = extra || 'faq'
342
+ tpl = GSC::SchemaValidator.generate_template(schema_type)
343
+ if options[:json]
344
+ puts JSON.pretty_generate(tpl)
345
+ else
346
+ puts Color.c("šŸ“‹ Generated JSON-LD Schema (#{schema_type}):", Color::GREEN, Color::BOLD)
347
+ puts "<script type=\"application/ld+json\">"
348
+ puts JSON.pretty_generate(tpl)
349
+ puts "</script>"
350
+ end
351
+ return
352
+ end
353
+
354
+ url = target || "https://#{Config.default_domain || 'example.com'}"
355
+ sv = GSC::SchemaValidator.new(url)
356
+ data = sv.audit
357
+
358
+ if options[:json]
359
+ puts JSON.pretty_generate(data)
360
+ return
361
+ end
362
+
363
+ puts BANNER unless options[:in_dashboard]
364
+ puts "šŸ“¦ #{Color::BOLD}STRUCTURED DATA & RICH SNIPPET VALIDATION:#{Color::RESET} #{Color.c(url, Color::CYAN)}"
365
+ puts "─" * 75
366
+
367
+ schemas = data[:schemas] || []
368
+ if schemas.empty?
369
+ puts " (No JSON-LD structured data schemas found on this page)"
370
+ puts " Tip: Run `gsc schema generate faq` to create valid JSON-LD schema."
371
+ else
372
+ schemas.each do |sc|
373
+ valid_badge = sc[:valid] ? Color.c("VALID", Color::GREEN, Color::BOLD) : Color.c("INVALID", Color::RED, Color::BOLD)
374
+ puts "\nSchema ##{sc[:index] + 1}: #{Color.c(sc[:type], Color::BOLD)} [#{valid_badge}]"
375
+ (sc[:errors] || []).each { |e| puts " āŒ Error: #{Color.c(e, Color::RED)}" }
376
+ (sc[:warnings] || []).each { |w| puts " āš ļø Warning: #{Color.c(w, Color::YELLOW)}" }
377
+ end
378
+ end
379
+ puts ""
380
+ end
381
+
382
+ # 9. LLMS.txt & AI Search
383
+ def self.handle_llms_command(target, extra, options)
384
+ base_url = target || "https://#{Config.default_domain || 'example.com'}"
385
+ llms = GSC::LlmsGenerator.new(base_url)
386
+
387
+ if extra == 'audit' || options[:audit]
388
+ data = llms.audit_ai_readability(base_url)
389
+ if options[:json]
390
+ puts JSON.pretty_generate(data)
391
+ else
392
+ puts BANNER unless options[:in_dashboard]
393
+ puts "šŸ¤– #{Color::BOLD}AI SEARCH ENGINE / LLM READABILITY AUDIT:#{Color::RESET} #{Color.c(base_url, Color::CYAN)}"
394
+ puts "─" * 70
395
+ puts "AI Citation Readiness Score: #{Color.c(data[:ai_readability_score].to_s + '/100', Color::GREEN, Color::BOLD)} [Grade: #{data[:grade]}]"
396
+ puts "\nFeatures Detected:"
397
+ puts " • Single H1 Heading : #{data.dig(:features, :h1_count) == 1 ? 'āœ… Yes' : 'āŒ No'}"
398
+ puts " • Tables for Data : #{data.dig(:features, :has_tables) ? 'āœ… Yes' : 'āŒ No'}"
399
+ puts " • Bullet Lists : #{data.dig(:features, :has_lists) ? 'āœ… Yes' : 'āŒ No'}"
400
+ puts " • Structured Data : #{data.dig(:features, :schemas_found)} schemas"
401
+ unless data[:issues].empty?
402
+ puts "\nOptimizations for Perplexity & ChatGPT:"
403
+ data[:issues].each { |iss| puts " • #{Color.c(iss, Color::YELLOW)}" }
404
+ end
405
+ puts ""
406
+ end
407
+ else
408
+ content = llms.generate_llms_txt
409
+ if options[:save]
410
+ File.write("llms.txt", content)
411
+ puts Color.c("āœ… Successfully wrote llms.txt to current directory!", Color::GREEN)
412
+ else
413
+ puts content
414
+ end
415
+ end
416
+ end
417
+
418
+ # 10. SERP & Social Preview
419
+ def self.handle_preview_command(target, options)
420
+ url = target || "https://#{Config.default_domain || 'example.com'}"
421
+ sp = GSC::SerpPreview.new(url)
422
+ data = sp.generate
423
+
424
+ if options[:json]
425
+ puts JSON.pretty_generate(data)
426
+ return
427
+ end
428
+
429
+ puts BANNER unless options[:in_dashboard]
430
+ puts "šŸ–„ļø #{Color::BOLD}GOOGLE SERP PREVIEW (Desktop Viewport):#{Color::RESET}"
431
+ puts "ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”"
432
+ puts "│ #{Color.c(data.dig(:desktop_serp, :breadcrumb), Color::DIM)}│"
433
+ puts "│ #{Color.c(data.dig(:desktop_serp, :title).ljust(59), Color::BLUE, Color::BOLD)}│"
434
+ puts "│ #{Color.c(data.dig(:desktop_serp, :snippet)[0..58].ljust(59), Color::DIM)}│"
435
+ puts "ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜"
436
+ if data[:truncation_risk]
437
+ puts Color.c("āš ļø Warning: Title exceeds 60 characters and may truncate with '...' on Google SERPs.", Color::YELLOW)
438
+ else
439
+ puts Color.c("āœ… Title length is optimal (< 60 chars / ~580px).", Color::GREEN)
440
+ end
441
+
442
+ puts "\nšŸ“± #{Color::BOLD}OPEN GRAPH / SOCIAL CARD PREVIEW:#{Color::RESET}"
443
+ soc = data[:social] || {}
444
+ puts " • Title : #{soc[:og_title]}"
445
+ puts " • Description : #{soc[:og_description]}"
446
+ puts " • Card Image : #{soc[:og_image] || '(No og:image specified)'}"
447
+ puts ""
448
+ end
449
+
450
+ # 11. Network & Redirect Tracer
451
+ def self.handle_trace_command(target, options)
452
+ url = target || Config.default_domain || 'example.com'
453
+ nt = GSC::NetworkTracer.new(url)
454
+ data = nt.trace
455
+
456
+ if options[:json]
457
+ puts JSON.pretty_generate(data)
458
+ return
459
+ end
460
+
461
+ puts BANNER unless options[:in_dashboard]
462
+ puts "šŸ›¤ļø #{Color::BOLD}REDIRECT CHAIN & HTTP HEADER TRACE:#{Color::RESET} #{Color.c(url, Color::CYAN)}"
463
+ puts "─" * 75
464
+ puts "Total Hops: #{data[:total_hops]} | Duration: #{data[:total_duration_ms]}ms"
465
+
466
+ (data[:hops] || []).each do |hop|
467
+ status_c = (hop[:status_code] == 200) ? Color::GREEN : Color::YELLOW
468
+ puts "\nHop ##{hop[:hop]}: #{Color.c(hop[:status_code].to_s, status_c, Color::BOLD)} (#{hop[:duration_ms]}ms)"
469
+ puts " URL: #{hop[:url]}"
470
+ puts " X-Robots-Tag: #{Color.c(hop[:x_robots_tag], Color::RED)}" if hop[:x_robots_tag]
471
+ puts " Canonical: #{hop[:canonical_header]}" if hop[:canonical_header]
472
+ puts " HSTS: #{hop[:hsts] ? 'Enabled' : 'Disabled'}"
473
+ end
474
+ puts ""
475
+ end
476
+
477
+ # 12. Robots.txt Checker
478
+ def self.handle_robots_command(target, extra, options)
479
+ url = target || "https://#{Config.default_domain || 'example.com'}"
480
+ path = extra || '/'
481
+ bot = options[:bot] || 'googlebot'
482
+
483
+ rc = GSC::RobotsChecker.new(url)
484
+ data = rc.check(path, bot)
485
+
486
+ if options[:json]
487
+ puts JSON.pretty_generate(data)
488
+ return
489
+ end
490
+
491
+ puts BANNER unless options[:in_dashboard]
492
+ puts "šŸ¤– #{Color::BOLD}ROBOTS.TXT CRAWLER SIMULATOR:#{Color::RESET}"
493
+ puts " Robots URL : #{data[:robots_url]}"
494
+ puts " User Agent : #{Color.c(bot, Color::CYAN)}"
495
+ puts " Test Path : #{Color.c(path, Color::BOLD)}"
496
+ puts "─" * 70
497
+
498
+ status_badge = data[:allowed] ? Color.c("āœ… ALLOWED", Color::GREEN, Color::BOLD) : Color.c("āŒ BLOCKED (DISALLOW)", Color::RED, Color::BOLD)
499
+ puts "Crawl Verdict : #{status_badge}"
500
+ if data[:matched_rule]
501
+ puts "Matched Rule : #{data[:matched_rule][:type].to_s.upcase}: #{data[:matched_rule][:path]}"
502
+ end
503
+ puts ""
504
+ end
505
+
506
+ # 13. Backlinks & GSC Links Ingestion
507
+ def self.handle_backlinks_command(target, extra, options)
508
+ domain = target || Config.default_domain || 'example.com'
509
+ bm = GSC::BacklinksManager.new(domain)
510
+
511
+ if target == 'import' || extra == 'import'
512
+ source = (target == 'import') ? extra : target
513
+ content = if source == 'clip' || source == 'clipboard'
514
+ `pbpaste 2>/dev/null`
515
+ elsif source && File.exist?(source)
516
+ File.read(source)
517
+ else
518
+ nil
519
+ end
520
+
521
+ if content.nil? || content.strip.empty?
522
+ puts Color.c("āŒ Error: No content provided. Usage: gsc backlinks import [file.csv|clip]", Color::RED)
523
+ return
524
+ end
525
+
526
+ res = bm.import_csv(content)
527
+ if options[:json]
528
+ puts JSON.pretty_generate(res)
529
+ else
530
+ puts Color.c("āœ… Successfully imported GSC backlink export!", Color::GREEN, Color::BOLD)
531
+ puts " Referring Domains : #{res[:sources_count]}"
532
+ puts " Target Pages : #{res[:targets_count]}"
533
+ end
534
+ return
535
+ end
536
+
537
+ data = bm.summary
538
+
539
+ if options[:json]
540
+ puts JSON.pretty_generate(data)
541
+ return
542
+ end
543
+
544
+ puts BANNER unless options[:in_dashboard]
545
+ puts "šŸ”— #{Color::BOLD}GSC BACKLINK & REFERRING DOMAIN INTELLIGENCE:#{Color::RESET} #{Color.c(domain, Color::CYAN)}"
546
+ puts "─" * 75
547
+ puts "Total Referring Domains : #{Color.c(data[:total_referring_domains].to_s, Color::BOLD)}"
548
+ puts "Total External Links : #{Color.c(data[:total_external_links].to_s, Color::BOLD)}"
549
+ puts "Last Updated : #{data[:updated_at] || 'Never (Run `gsc backlinks import` to ingest GSC export)'}"
550
+ puts "─" * 75
551
+
552
+ sources = data[:top_referring_domains] || []
553
+ unless sources.empty?
554
+ puts "\n#{Color::BOLD}🌐 TOP REFERRING SITES:#{Color::RESET}"
555
+ sources.each do |s|
556
+ puts " • #{s['domain'].to_s.ljust(45)} #{Color.c(s['links_count'].to_s.rjust(6) + ' links', Color::CYAN)}"
557
+ end
558
+ end
559
+
560
+ targets = data[:top_target_pages] || []
561
+ unless targets.empty?
562
+ puts "\n#{Color::BOLD}šŸŽÆ TOP LINKED LANDING PAGES:#{Color::RESET}"
563
+ targets.each do |t|
564
+ puts " • #{t['target_url'].to_s.ljust(45)} #{Color.c(t['incoming_count'].to_s.rjust(6) + ' links', Color::GREEN)}"
565
+ end
566
+ end
567
+ puts ""
568
+ end
569
+ end
570
+ end
data/lib/gsc/config.rb CHANGED
@@ -9,6 +9,15 @@ module GSC
9
9
  CONFIG_DIR = File.expand_path('~/.config/gsc')
10
10
  CONFIG_FILE = File.join(CONFIG_DIR, 'config.json')
11
11
 
12
+ def self.get(key)
13
+ load[key.to_s]
14
+ end
15
+
16
+ def self.set(key, value)
17
+ save(key.to_s => value)
18
+ value
19
+ end
20
+
12
21
  def self.load
13
22
  return {} unless File.exist?(CONFIG_FILE)
14
23
  JSON.parse(File.read(CONFIG_FILE))