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,93 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'set'
7
+
8
+ module GSC
9
+ class NetworkTracer
10
+ attr_reader :start_url, :max_hops
11
+
12
+ def initialize(start_url, max_hops: 10)
13
+ @start_url = start_url.to_s.strip
14
+ @start_url = "http://#{@start_url}" unless @start_url =~ %r{^https?://}
15
+ @max_hops = max_hops
16
+ end
17
+
18
+ def trace
19
+ current_url = @start_url
20
+ hops = []
21
+ visited = Set.new
22
+
23
+ @max_hops.times do |hop_idx|
24
+ break if visited.include?(current_url)
25
+ visited << current_url
26
+
27
+ uri = URI.parse(current_url) rescue nil
28
+ break unless uri && uri.host
29
+
30
+ start_t = Time.now
31
+ res = nil
32
+
33
+ begin
34
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: (uri.scheme == 'https'), open_timeout: 5, read_timeout: 10) do |http|
35
+ req = Net::HTTP::Get.new(uri.request_uri)
36
+ req['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) gsc-cli/2.1'
37
+ res = http.request(req)
38
+ end
39
+ rescue StandardError => e
40
+ hops << {
41
+ hop: hop_idx + 1,
42
+ url: current_url,
43
+ error: e.message
44
+ }
45
+ break
46
+ end
47
+
48
+ duration_ms = ((Time.now - start_t) * 1000).round(1)
49
+ status_code = res.code.to_i
50
+ location = res['location']
51
+
52
+ hop_info = {
53
+ hop: hop_idx + 1,
54
+ url: current_url,
55
+ status_code: status_code,
56
+ duration_ms: duration_ms,
57
+ x_robots_tag: res['x-robots-tag'],
58
+ canonical_header: res['link'] =~ /rel="canonical"/i ? res['link'] : nil,
59
+ hsts: res['strict-transport-security'],
60
+ content_type: res['content-type'],
61
+ server: res['server']
62
+ }
63
+
64
+ hops << hop_info
65
+
66
+ if [301, 302, 303, 307, 308].include?(status_code) && location
67
+ next_url = begin
68
+ URI.join(current_url, location.strip).to_s
69
+ rescue StandardError
70
+ nil
71
+ end
72
+ break unless next_url
73
+ current_url = next_url
74
+ else
75
+ break
76
+ end
77
+ end
78
+
79
+ total_time = hops.sum { |h| h[:duration_ms] || 0 }.round(1)
80
+ final_hop = hops.last || {}
81
+ is_redirect_chain = hops.length > 2
82
+
83
+ {
84
+ start_url: @start_url,
85
+ final_url: final_hop[:url],
86
+ total_hops: hops.length,
87
+ total_duration_ms: total_time,
88
+ is_redirect_chain: is_redirect_chain,
89
+ hops: hops
90
+ }
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,72 @@
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 OpenPageRank
10
+ API_URL = 'https://openpagerank.com/api/v1.0/getPageRank'
11
+
12
+ attr_reader :api_key
13
+
14
+ def initialize(api_key = nil)
15
+ @api_key = api_key || ENV['OPENPAGERANK_API_KEY'] || GSC::Config.get('opr_api_key')
16
+ end
17
+
18
+ def configured?
19
+ !@api_key.nil? && !@api_key.strip.empty?
20
+ end
21
+
22
+ def check_domains(domains)
23
+ domains = Array(domains).map { |d| clean_domain(d) }.reject(&:empty?).uniq
24
+ return { error: 'No valid domains provided' } if domains.empty?
25
+ return { error: 'OpenPageRank API key not configured. Set via `gsc config set opr_api_key <key>` or OPENPAGERANK_API_KEY env (Get free 300k calls/mo at openpagerank.com)' } unless configured?
26
+
27
+ # Construct query params: domains[0]=a.com&domains[1]=b.com
28
+ params = domains.map.with_index { |d, idx| "domains%5B#{idx}%5D=#{URI.encode_www_form_component(d)}" }.join('&')
29
+ uri = URI("#{API_URL}?#{params}")
30
+
31
+ req = Net::HTTP::Get.new(uri)
32
+ req['API-OPR'] = @api_key
33
+ req['User-Agent'] = 'gsc-cli/2.1'
34
+
35
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 15) do |http|
36
+ http.request(req)
37
+ end
38
+
39
+ if res.code == '200'
40
+ data = JSON.parse(res.body)
41
+ records = (data['response'] || []).map do |r|
42
+ {
43
+ domain: r['domain'],
44
+ page_rank_decimal: r['page_rank_decimal'] || 0.0,
45
+ page_rank_integer: r['page_rank_integer'] || 0,
46
+ rank: r['rank'],
47
+ status_code: r['status_code']
48
+ }
49
+ end
50
+ {
51
+ status: 'success',
52
+ status_code: data['status_code'],
53
+ records: records
54
+ }
55
+ else
56
+ {
57
+ status: 'error',
58
+ code: res.code.to_i,
59
+ message: res.body
60
+ }
61
+ end
62
+ rescue StandardError => e
63
+ { status: 'error', message: e.message }
64
+ end
65
+
66
+ def clean_domain(input)
67
+ d = input.to_s.strip.downcase
68
+ d = d.sub(%r{^https?://}, '').sub(%r{/.*$}, '').sub(/^www\./, '')
69
+ d
70
+ end
71
+ end
72
+ end
@@ -76,9 +76,28 @@ module GSC
76
76
  canonical_url = extract_link_attr(doc, 'canonical', 'href')
77
77
  robots_meta = extract_meta_content(doc, 'name', 'robots')
78
78
 
79
- # 2. Detailed Headings Structure
80
- headings = extract_headings(doc)
81
- h1_list = headings.select { |h| h[:tag] == 'h1' }
79
+ # 2. Detailed Headings Structure & Hierarchy Depth
80
+ heading_audit = if defined?(HeadingValidator)
81
+ HeadingValidator.analyze(doc, target_keywords: title_raw)
82
+ else
83
+ h_list = extract_headings(doc)
84
+ h1_found = h_list.select { |h| h[:tag] == 'h1' }
85
+ {
86
+ score: h1_found.size == 1 ? 100 : 70,
87
+ grade: h1_found.size == 1 ? 'A' : 'C',
88
+ depth: h_list.map { |h| h[:tag][1].to_i }.max || 0,
89
+ count: h_list.size,
90
+ counts: h_list.group_by { |h| h[:tag] }.transform_values(&:size),
91
+ h1_count: h1_found.size,
92
+ empty_count: 0,
93
+ headings: h_list,
94
+ violations: [],
95
+ ascii_tree: '',
96
+ color_tree: ''
97
+ }
98
+ end
99
+ headings = heading_audit[:headings]
100
+ h1_list = headings.select { |h| h[:tag] == 'h1' && !h[:empty] }
82
101
 
83
102
  # 3. Images & Missing Alt Tags
84
103
  images_data = extract_images(doc)
@@ -123,8 +142,19 @@ module GSC
123
142
  issues << { level: :warn, type: :title, message: "Title > 568px width (~#{title_pixel_est}px, risks SERP truncation)" } if title_pixel_est > 568.0
124
143
  issues << { level: :warn, type: :meta, message: "Missing meta description" } if meta_desc.nil? || meta_desc.empty?
125
144
  issues << { level: :warn, type: :meta, message: "Meta description > 155 chars (#{meta_desc.length}c)" } if meta_desc && meta_desc.length > 155
126
- issues << { level: :error, type: :headings, message: "Missing <h1> tag (0 found)" } if h1_list.empty?
127
- issues << { level: :warn, type: :headings, message: "Multiple <h1> tags (#{h1_list.size} found)" } if h1_list.size > 1
145
+ if heading_audit[:violations] && !heading_audit[:violations].empty?
146
+ heading_audit[:violations].each do |v|
147
+ lvl = case v[:severity]
148
+ when :critical then :error
149
+ when :warning then :warn
150
+ else :info
151
+ end
152
+ issues << { level: lvl, type: :headings, message: v[:message] }
153
+ end
154
+ else
155
+ issues << { level: :error, type: :headings, message: "Missing <h1> tag (0 found)" } if h1_list.empty?
156
+ issues << { level: :warn, type: :headings, message: "Multiple <h1> tags (#{h1_list.size} found)" } if h1_list.size > 1
157
+ end
128
158
  issues << { level: :warn, type: :images, message: "#{images_data[:missing_alt_count]} images missing alt tags" } if images_data[:missing_alt_count] > 0
129
159
  issues << { level: :critical, type: :indexability, message: "Robots noindex tag detected (Blocking Googlebot)" } if noindex
130
160
 
@@ -155,13 +185,23 @@ module GSC
155
185
  self_referencing: canonical_url ? (canonical_url.chomp('/') == @url.chomp('/')) : false
156
186
  },
157
187
  headings: {
158
- count: headings.size,
159
- h1_count: h1_list.size,
188
+ count: heading_audit[:count],
189
+ h1_count: heading_audit[:h1_count],
190
+ score: heading_audit[:score],
191
+ grade: heading_audit[:grade],
192
+ depth: heading_audit[:depth],
193
+ counts: heading_audit[:counts],
194
+ empty_count: heading_audit[:empty_count],
195
+ violations: heading_audit[:violations],
196
+ ascii_tree: heading_audit[:ascii_tree],
197
+ color_tree: heading_audit[:color_tree],
198
+ keyword_analysis: heading_audit[:keyword_analysis],
160
199
  list: headings
161
200
  },
162
201
  images: images_data,
163
202
  links: links_data,
164
203
  schema: schemas,
204
+ structured_data: { schemas: schemas },
165
205
  social: { og: og_data, twitter: twitter_data },
166
206
  stats: { word_count: word_count, reading_time_mins: reading_time_mins },
167
207
  issues: issues
@@ -0,0 +1,108 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module GSC
5
+ class PageComparator
6
+ attr_reader :url1, :url2, :data1, :data2
7
+
8
+ def initialize(url1, url2)
9
+ @url1 = url1
10
+ @url2 = url2
11
+ end
12
+
13
+ def compare
14
+ pa1 = GSC::PageAnalyzer.new(@url1)
15
+ pa2 = GSC::PageAnalyzer.new(@url2)
16
+
17
+ @data1 = pa1.fetch_and_analyze
18
+ @data2 = pa2.fetch_and_analyze
19
+
20
+ diffs = {
21
+ meta: compare_meta,
22
+ headings: compare_headings,
23
+ images: compare_images,
24
+ links: compare_links,
25
+ performance: compare_perf,
26
+ structured_data: compare_schema
27
+ }
28
+
29
+ {
30
+ page1: { url: @url1, status: @data1[:http_status] },
31
+ page2: { url: @url2, status: @data2[:http_status] },
32
+ comparison: diffs
33
+ }
34
+ end
35
+
36
+ private
37
+
38
+ def compare_meta
39
+ t1 = @data1.dig(:title, :text) || ''
40
+ t2 = @data2.dig(:title, :text) || ''
41
+ m1 = @data1.dig(:meta_description, :text) || ''
42
+ m2 = @data2.dig(:meta_description, :text) || ''
43
+
44
+ {
45
+ title: {
46
+ page1: { text: t1, length: t1.length, optimal: t1.length.between?(30, 60) },
47
+ page2: { text: t2, length: t2.length, optimal: t2.length.between?(30, 60) }
48
+ },
49
+ meta_description: {
50
+ page1: { text: m1, length: m1.length, optimal: m1.length.between?(70, 155) },
51
+ page2: { text: m2, length: m2.length, optimal: m2.length.between?(70, 155) }
52
+ }
53
+ }
54
+ end
55
+
56
+ def compare_headings
57
+ h1_1 = @data1.dig(:headings, :h1) || []
58
+ h1_2 = @data2.dig(:headings, :h1) || []
59
+ h2_1 = @data1.dig(:headings, :h2) || []
60
+ h2_2 = @data2.dig(:headings, :h2) || []
61
+
62
+ {
63
+ h1_count: { page1: h1_1.length, page2: h1_2.length },
64
+ h1_text: { page1: h1_1.first, page2: h1_2.first },
65
+ h2_count: { page1: h2_1.length, page2: h2_2.length }
66
+ }
67
+ end
68
+
69
+ def compare_images
70
+ img1 = @data1[:images] || {}
71
+ img2 = @data2[:images] || {}
72
+
73
+ {
74
+ total_images: { page1: img1[:total] || 0, page2: img2[:total] || 0 },
75
+ missing_alt: { page1: img1[:missing_alt] || 0, page2: img2[:missing_alt] || 0 }
76
+ }
77
+ end
78
+
79
+ def compare_links
80
+ l1 = @data1[:links] || {}
81
+ l2 = @data2[:links] || {}
82
+
83
+ {
84
+ internal: { page1: l1[:internal_count] || 0, page2: l2[:internal_count] || 0 },
85
+ external: { page1: l1[:external_count] || 0, page2: l2[:external_count] || 0 }
86
+ }
87
+ end
88
+
89
+ def compare_perf
90
+ {
91
+ response_time_ms: { page1: @data1[:response_time_ms], page2: @data2[:response_time_ms] }
92
+ }
93
+ end
94
+
95
+ def compare_schema
96
+ s1 = @data1.dig(:structured_data, :schemas) || []
97
+ s2 = @data2.dig(:structured_data, :schemas) || []
98
+
99
+ types1 = s1.map { |s| s['@type'] }.compact
100
+ types2 = s2.map { |s| s['@type'] }.compact
101
+
102
+ {
103
+ schema_count: { page1: s1.length, page2: s2.length },
104
+ schema_types: { page1: types1, page2: types2 }
105
+ }
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,110 @@
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 PageSpeed
10
+ API_URL = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed'
11
+
12
+ attr_reader :url, :strategy, :api_key
13
+
14
+ def initialize(url, strategy: 'mobile', api_key: nil)
15
+ @url = url.to_s.strip
16
+ @strategy = strategy.to_s.downcase == 'desktop' ? 'desktop' : 'mobile'
17
+ @api_key = api_key || ENV['PAGESPEED_API_KEY'] || GSC::Config.get('pagespeed_api_key')
18
+ end
19
+
20
+ def run
21
+ query_params = [
22
+ "url=#{URI.encode_www_form_component(@url)}",
23
+ "strategy=#{@strategy}",
24
+ "category=performance",
25
+ "category=seo"
26
+ ]
27
+ query_params << "key=#{URI.encode_www_form_component(@api_key)}" if @api_key && !@api_key.empty?
28
+
29
+ uri = URI("#{API_URL}?#{query_params.join('&')}")
30
+ req = Net::HTTP::Get.new(uri)
31
+ req['User-Agent'] = 'gsc-cli/2.1'
32
+
33
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30) do |http|
34
+ http.request(req)
35
+ end
36
+
37
+ if res.code == '200'
38
+ parse_response(JSON.parse(res.body))
39
+ else
40
+ {
41
+ error: true,
42
+ status_code: res.code.to_i,
43
+ message: res.body
44
+ }
45
+ end
46
+ rescue StandardError => e
47
+ { error: true, message: e.message }
48
+ end
49
+
50
+ private
51
+
52
+ def parse_response(data)
53
+ lhr = data['lighthouseResult'] || {}
54
+ categories = lhr['categories'] || {}
55
+ perf_score = ((categories.dig('performance', 'score') || 0) * 100).round
56
+ seo_score = ((categories.dig('seo', 'score') || 0) * 100).round
57
+
58
+ audits = lhr['audits'] || {}
59
+
60
+ # Core Web Vitals
61
+ fcp = audits.dig('first-contentful-paint', 'displayValue')
62
+ lcp = audits.dig('largest-contentful-paint', 'displayValue')
63
+ cls = audits.dig('cumulative-layout-shift', 'displayValue')
64
+ tbt = audits.dig('total-blocking-time', 'displayValue')
65
+ si = audits.dig('speed-index', 'displayValue')
66
+
67
+ # CrUX Field Data (if available)
68
+ crux_metrics = {}
69
+ crux = data.dig('loadingExperience', 'metrics') || {}
70
+ crux.each do |k, v|
71
+ crux_metrics[k] = {
72
+ percentile: v['percentile'],
73
+ category: v['category']
74
+ }
75
+ end
76
+
77
+ # Opportunities
78
+ opportunities = []
79
+ audits.each do |k, v|
80
+ next unless v['details'] && v['details']['type'] == 'opportunity'
81
+ next unless v['numericValue'] && v['numericValue'] > 100
82
+
83
+ opportunities << {
84
+ id: k,
85
+ title: v['title'],
86
+ savings_ms: v['numericValue'] ? v['numericValue'].round : 0,
87
+ display: v['displayValue']
88
+ }
89
+ end
90
+ opportunities.sort_by! { |o| -o[:savings_ms] }
91
+
92
+ {
93
+ url: @url,
94
+ strategy: @strategy,
95
+ fetch_time: lhr['fetchTime'],
96
+ performance_score: perf_score,
97
+ seo_score: seo_score,
98
+ metrics: {
99
+ fcp: fcp,
100
+ lcp: lcp,
101
+ cls: cls,
102
+ tbt: tbt,
103
+ speed_index: si
104
+ },
105
+ field_data: crux_metrics,
106
+ opportunities: opportunities.first(5)
107
+ }
108
+ end
109
+ end
110
+ end