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,96 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'csv'
5
+ require 'fileutils'
6
+ require 'json'
7
+
8
+ module GSC
9
+ class BacklinksManager
10
+ attr_reader :domain, :storage_file
11
+
12
+ def initialize(domain)
13
+ @domain = clean_domain(domain)
14
+ @domain_dir = File.join(Dir.home, '.config', 'gsc', 'domains', @domain)
15
+ FileUtils.mkdir_p(@domain_dir)
16
+ @storage_file = File.join(@domain_dir, 'backlinks.json')
17
+ end
18
+
19
+ def load_data
20
+ return { 'sources' => [], 'targets' => [], 'updated_at' => nil } unless File.exist?(@storage_file)
21
+
22
+ JSON.parse(File.read(@storage_file, encoding: 'UTF-8'))
23
+ rescue StandardError
24
+ { 'sources' => [], 'targets' => [], 'updated_at' => nil }
25
+ end
26
+
27
+ def save_data(data)
28
+ data['updated_at'] = Time.now.utc.iso8601
29
+ File.write(@storage_file, JSON.pretty_generate(data))
30
+ end
31
+
32
+ def import_csv(content)
33
+ data = load_data
34
+ lines = content.to_s.strip.lines
35
+
36
+ return { error: 'Empty content' } if lines.empty?
37
+
38
+ # Detect header
39
+ header = lines.first.downcase
40
+ if header.include?('top linking sites') || header.include?('root domain')
41
+ # Sources CSV
42
+ sources = []
43
+ CSV.parse(content, headers: true, skip_blanks: true) do |row|
44
+ domain_name = row['Top linking sites'] || row['Site'] || row[0]
45
+ links_count = (row['Target pages'] || row['Links'] || row[1] || '1').to_s.gsub(',', '').to_i
46
+ sources << { 'domain' => domain_name.to_s.strip, 'links_count' => links_count } if domain_name
47
+ end
48
+ data['sources'] = sources
49
+ else
50
+ # Target pages CSV
51
+ targets = []
52
+ CSV.parse(content, headers: true, skip_blanks: true) do |row|
53
+ target_url = row['Target page'] || row['Page'] || row[0]
54
+ incoming = (row['Incoming links'] || row['Links'] || row[1] || '1').to_s.gsub(',', '').to_i
55
+ targets << { 'target_url' => target_url.to_s.strip, 'incoming_count' => incoming } if target_url
56
+ end
57
+ data['targets'] = targets
58
+ end
59
+
60
+ save_data(data)
61
+ {
62
+ status: 'success',
63
+ sources_count: data['sources'].length,
64
+ targets_count: data['targets'].length,
65
+ updated_at: data['updated_at']
66
+ }
67
+ rescue StandardError => e
68
+ { error: "Failed to parse CSV: #{e.message}" }
69
+ end
70
+
71
+ def summary(target_page: nil)
72
+ data = load_data
73
+ sources = data['sources'] || []
74
+ targets = data['targets'] || []
75
+
76
+ filtered_targets = target_page ? targets.select { |t| t['target_url'].include?(target_page) } : targets
77
+
78
+ {
79
+ domain: @domain,
80
+ total_referring_domains: sources.length,
81
+ total_external_links: sources.sum { |s| s['links_count'] || 0 },
82
+ top_referring_domains: sources.sort_by { |s| -(s['links_count'] || 0) }.first(15),
83
+ top_target_pages: filtered_targets.sort_by { |t| -(t['incoming_count'] || 0) }.first(15),
84
+ updated_at: data['updated_at']
85
+ }
86
+ end
87
+
88
+ private
89
+
90
+ def clean_domain(input)
91
+ d = input.to_s.strip.downcase
92
+ d = d.sub(%r{^https?://}, '').sub(%r{/.*$}, '').sub(/^www\./, '')
93
+ d
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GSC
4
+ class BrandSegmenter
5
+ attr_reader :brand_tokens
6
+
7
+ # Suffixes to strip when deriving brand name from domain (e.g., exampleapp -> example)
8
+ STRIP_SUFFIXES = %w[app apps io co tech hq get try official shop store ltd inc net org].freeze
9
+
10
+ def initialize(domain: nil, custom_brand: nil)
11
+ @brand_tokens = resolve_tokens(domain, custom_brand)
12
+ end
13
+
14
+ def brand?(query)
15
+ return false if @brand_tokens.empty? || query.to_s.strip.empty?
16
+
17
+ q = normalize(query)
18
+ # Check exact token containment or multi-word phrase containment
19
+ @brand_tokens.any? do |token|
20
+ token_clean = normalize(token)
21
+ q.include?(token_clean) || q.gsub(/\s+/, '').include?(token_clean.gsub(/\s+/, ''))
22
+ end
23
+ end
24
+
25
+ def segment(rows)
26
+ brand_rows = []
27
+ non_brand_rows = []
28
+
29
+ rows.each do |row|
30
+ q = row[:query] || row['query'] || ''
31
+ if brand?(q)
32
+ brand_rows << row
33
+ else
34
+ non_brand_rows << row
35
+ end
36
+ end
37
+
38
+ {
39
+ brand: brand_rows,
40
+ non_brand: non_brand_rows,
41
+ summary: calculate_summary(brand_rows, non_brand_rows)
42
+ }
43
+ end
44
+
45
+ private
46
+
47
+ def normalize(str)
48
+ str.to_s.downcase.gsub(/[^a-z0-9\s]/, ' ').squeeze(' ').strip
49
+ end
50
+
51
+ def resolve_tokens(domain, custom_brand)
52
+ tokens = []
53
+
54
+ if custom_brand && !custom_brand.to_s.strip.empty?
55
+ cb = normalize(custom_brand)
56
+ tokens << cb
57
+ tokens << cb.gsub(/\s+/, '')
58
+ cb.split(/\s+/).each do |w|
59
+ tokens << w if w.length >= 4 && !%w[pro app apps the inc llc ltd corp].include?(w)
60
+ end
61
+ end
62
+
63
+ if domain && !domain.to_s.strip.empty?
64
+ # Clean domain
65
+ clean_dom = domain.to_s.sub(/^https?:\/\//i, '').sub(/^sc-domain:/i, '').split('/').first.to_s.downcase
66
+ parts = clean_dom.split('.')
67
+ # Main apex stem (e.g., exampleapp from exampleapp.com)
68
+ stem = parts.first || ''
69
+
70
+ tokens << stem unless stem.empty?
71
+
72
+ # Try stripping known suffixes
73
+ STRIP_SUFFIXES.each do |suffix|
74
+ if stem.end_with?(suffix) && stem.length > (suffix.length + 3)
75
+ base = stem[0...-suffix.length]
76
+ tokens << base
77
+ # Add spaced variant if it looks compound (e.g. quickcart -> quick cart)
78
+ tokens << base.sub(/(super|pack|cart|speed|quick|auto|mega|hyper)/i, '\1 ').strip
79
+ end
80
+ end
81
+
82
+ # Also add compound space variations
83
+ tokens << stem.sub(/(super|pack|cart|speed|quick|auto|mega|hyper)/i, '\1 ').strip
84
+ end
85
+
86
+ tokens.map { |t| normalize(t) }.reject { |t| t.length < 3 }.uniq
87
+ end
88
+
89
+ def calculate_summary(brand_rows, non_brand_rows)
90
+ total_rows = brand_rows.size + non_brand_rows.size
91
+
92
+ b_clicks = brand_rows.sum { |r| r[:clicks] || r['clicks'] || 0 }
93
+ b_imp = brand_rows.sum { |r| r[:impressions] || r['impressions'] || 0 }
94
+ b_ctr = b_imp > 0 ? (b_clicks.to_f / b_imp * 100).round(2) : 0.0
95
+ b_pos = if b_imp > 0
96
+ (brand_rows.sum { |r| (r[:position] || r['position'] || 0.0).to_f * (r[:impressions] || r['impressions'] || 0).to_f } / b_imp.to_f).round(1)
97
+ elsif brand_rows.any?
98
+ (brand_rows.sum { |r| (r[:position] || r['position'] || 0.0).to_f } / brand_rows.size).round(1)
99
+ else
100
+ 0.0
101
+ end
102
+
103
+ nb_clicks = non_brand_rows.sum { |r| r[:clicks] || r['clicks'] || 0 }
104
+ nb_imp = non_brand_rows.sum { |r| r[:impressions] || r['impressions'] || 0 }
105
+ nb_ctr = nb_imp > 0 ? (nb_clicks.to_f / nb_imp * 100).round(2) : 0.0
106
+ nb_pos = if nb_imp > 0
107
+ (non_brand_rows.sum { |r| (r[:position] || r['position'] || 0.0).to_f * (r[:impressions] || r['impressions'] || 0).to_f } / nb_imp.to_f).round(1)
108
+ elsif non_brand_rows.any?
109
+ (non_brand_rows.sum { |r| (r[:position] || r['position'] || 0.0).to_f } / non_brand_rows.size).round(1)
110
+ else
111
+ 0.0
112
+ end
113
+
114
+ total_clicks = b_clicks + nb_clicks
115
+ total_imp = b_imp + nb_imp
116
+
117
+ {
118
+ total_queries: total_rows,
119
+ brand: {
120
+ queries_count: brand_rows.size,
121
+ clicks: b_clicks,
122
+ impressions: b_imp,
123
+ ctr: b_ctr,
124
+ avg_position: b_pos,
125
+ click_share: total_clicks > 0 ? (b_clicks.to_f / total_clicks * 100).round(1) : 0.0,
126
+ impression_share: total_imp > 0 ? (b_imp.to_f / total_imp * 100).round(1) : 0.0
127
+ },
128
+ non_brand: {
129
+ queries_count: non_brand_rows.size,
130
+ clicks: nb_clicks,
131
+ impressions: nb_imp,
132
+ ctr: nb_ctr,
133
+ avg_position: nb_pos,
134
+ click_share: total_clicks > 0 ? (nb_clicks.to_f / total_clicks * 100).round(1) : 0.0,
135
+ impression_share: total_imp > 0 ? (nb_imp.to_f / total_imp * 100).round(1) : 0.0
136
+ }
137
+ }
138
+ end
139
+ end
140
+ end