gsc-cli 2.0.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +500 -0
- data/bin/gsc +8128 -0
- data/dist/gsc +8128 -0
- data/lib/gsc/api.rb +300 -0
- data/lib/gsc/auth.rb +92 -0
- data/lib/gsc/cli.rb +5435 -0
- data/lib/gsc/client.rb +79 -0
- data/lib/gsc/color.rb +22 -0
- data/lib/gsc/command_registry.rb +335 -0
- data/lib/gsc/config.rb +178 -0
- data/lib/gsc/google_trends.rb +187 -0
- data/lib/gsc/keyword_planner.rb +256 -0
- data/lib/gsc/keywords_everywhere.rb +144 -0
- data/lib/gsc/page_analyzer.rb +431 -0
- data/lib/gsc/prompts.rb +285 -0
- data/lib/gsc/site_crawler.rb +260 -0
- data/lib/gsc/sitemap_loader.rb +91 -0
- data/lib/gsc/version.rb +5 -0
- data/lib/gsc.rb +38 -0
- metadata +67 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'zlib'
|
|
6
|
+
require 'stringio'
|
|
7
|
+
require 'json'
|
|
8
|
+
require 'time'
|
|
9
|
+
|
|
10
|
+
module GSC
|
|
11
|
+
class PageAnalyzer
|
|
12
|
+
attr_reader :url, :html, :http_status, :response_time_ms, :headers, :error
|
|
13
|
+
|
|
14
|
+
def initialize(url_or_path, html: nil)
|
|
15
|
+
@target = url_or_path
|
|
16
|
+
@is_local_file = File.file?(url_or_path)
|
|
17
|
+
@url = @is_local_file ? "file://#{File.expand_path(url_or_path)}" : url_or_path
|
|
18
|
+
@html = html ? html.to_s.dup.force_encoding('UTF-8').scrub : ''
|
|
19
|
+
@http_status = html ? 200 : 0
|
|
20
|
+
@response_time_ms = 0
|
|
21
|
+
@headers = {}
|
|
22
|
+
@error = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fetch_and_analyze(check_links: false, gsc_api: nil, active_domain: nil)
|
|
26
|
+
load_content!
|
|
27
|
+
data = analyze_dom
|
|
28
|
+
|
|
29
|
+
if check_links && !data[:links][:internal].empty?
|
|
30
|
+
data[:links][:verification] = verify_links(data[:links][:internal].first(20))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if gsc_api && active_domain
|
|
34
|
+
data[:gsc_performance] = fetch_gsc_metrics(gsc_api, @url, active_domain)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
data
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def load_content!
|
|
41
|
+
return unless @html.empty?
|
|
42
|
+
|
|
43
|
+
if @is_local_file
|
|
44
|
+
raw = File.read(@target, encoding: 'UTF-8')
|
|
45
|
+
@html = raw.to_s.dup.force_encoding('UTF-8').scrub
|
|
46
|
+
@http_status = 200
|
|
47
|
+
@response_time_ms = 0
|
|
48
|
+
else
|
|
49
|
+
uri = URI.parse(@target)
|
|
50
|
+
start_t = Time.now
|
|
51
|
+
res = fetch_http(uri)
|
|
52
|
+
@response_time_ms = ((Time.now - start_t) * 1000).round(1)
|
|
53
|
+
@http_status = res.code.to_i
|
|
54
|
+
@headers = res.to_hash
|
|
55
|
+
|
|
56
|
+
raw_body = res.body || ''
|
|
57
|
+
decompressed = if res['content-encoding'] =~ /gzip/i && !raw_body.empty?
|
|
58
|
+
Zlib::GzipReader.new(StringIO.new(raw_body)).read
|
|
59
|
+
else
|
|
60
|
+
raw_body
|
|
61
|
+
end
|
|
62
|
+
@html = decompressed.to_s.dup.force_encoding('UTF-8').scrub
|
|
63
|
+
end
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
@html = ''
|
|
66
|
+
@http_status = 0
|
|
67
|
+
@error = e.message
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def analyze_dom
|
|
71
|
+
doc = @html.to_s.dup.force_encoding('UTF-8').scrub
|
|
72
|
+
|
|
73
|
+
# 1. Basic Title & Meta
|
|
74
|
+
title_raw = extract_tag_content(doc, 'title')
|
|
75
|
+
meta_desc = extract_meta_content(doc, 'name', 'description')
|
|
76
|
+
canonical_url = extract_link_attr(doc, 'canonical', 'href')
|
|
77
|
+
robots_meta = extract_meta_content(doc, 'name', 'robots')
|
|
78
|
+
|
|
79
|
+
# 2. Detailed Headings Structure
|
|
80
|
+
headings = extract_headings(doc)
|
|
81
|
+
h1_list = headings.select { |h| h[:tag] == 'h1' }
|
|
82
|
+
|
|
83
|
+
# 3. Images & Missing Alt Tags
|
|
84
|
+
images_data = extract_images(doc)
|
|
85
|
+
|
|
86
|
+
# 4. Links (Internal vs External & Nofollow)
|
|
87
|
+
links_data = extract_links(doc)
|
|
88
|
+
|
|
89
|
+
# 5. Schema / JSON-LD Data
|
|
90
|
+
schemas = extract_json_ld(doc)
|
|
91
|
+
|
|
92
|
+
# 6. Open Graph & Twitter Cards
|
|
93
|
+
og_data = {
|
|
94
|
+
title: extract_meta_content(doc, 'property', 'og:title'),
|
|
95
|
+
description: extract_meta_content(doc, 'property', 'og:description'),
|
|
96
|
+
image: extract_meta_content(doc, 'property', 'og:image'),
|
|
97
|
+
type: extract_meta_content(doc, 'property', 'og:type'),
|
|
98
|
+
url: extract_meta_content(doc, 'property', 'og:url')
|
|
99
|
+
}
|
|
100
|
+
twitter_data = {
|
|
101
|
+
card: extract_meta_content(doc, 'name', 'twitter:card'),
|
|
102
|
+
title: extract_meta_content(doc, 'name', 'twitter:title'),
|
|
103
|
+
description: extract_meta_content(doc, 'name', 'twitter:description'),
|
|
104
|
+
image: extract_meta_content(doc, 'name', 'twitter:image')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
# 7. Word Count & Content Ratio
|
|
108
|
+
text_content = clean_text(doc)
|
|
109
|
+
word_count = text_content.split(/\s+/).size
|
|
110
|
+
reading_time_mins = (word_count / 200.0).ceil
|
|
111
|
+
|
|
112
|
+
# 8. Indexability Diagnostics
|
|
113
|
+
x_robots = @headers['x-robots-tag']&.first
|
|
114
|
+
noindex = (robots_meta.to_s =~ /noindex/i) || (x_robots.to_s =~ /noindex/i)
|
|
115
|
+
nofollow = (robots_meta.to_s =~ /nofollow/i) || (x_robots.to_s =~ /nofollow/i)
|
|
116
|
+
|
|
117
|
+
# 9. Pixel Width & Character Limits
|
|
118
|
+
title_pixel_est = estimate_pixel_width(title_raw)
|
|
119
|
+
|
|
120
|
+
issues = []
|
|
121
|
+
issues << { level: :error, type: :title, message: "Missing <title> tag" } if title_raw.empty?
|
|
122
|
+
issues << { level: :warn, type: :title, message: "Title too long: > 60 chars (#{title_raw.length}c)" } if title_raw.length > 60
|
|
123
|
+
issues << { level: :warn, type: :title, message: "Title > 568px width (~#{title_pixel_est}px, risks SERP truncation)" } if title_pixel_est > 568.0
|
|
124
|
+
issues << { level: :warn, type: :meta, message: "Missing meta description" } if meta_desc.nil? || meta_desc.empty?
|
|
125
|
+
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
|
|
128
|
+
issues << { level: :warn, type: :images, message: "#{images_data[:missing_alt_count]} images missing alt tags" } if images_data[:missing_alt_count] > 0
|
|
129
|
+
issues << { level: :critical, type: :indexability, message: "Robots noindex tag detected (Blocking Googlebot)" } if noindex
|
|
130
|
+
|
|
131
|
+
{
|
|
132
|
+
url: @url,
|
|
133
|
+
http_status: @http_status,
|
|
134
|
+
response_time_ms: @response_time_ms,
|
|
135
|
+
indexability: {
|
|
136
|
+
status: noindex ? 'NOINDEX' : 'INDEXABLE',
|
|
137
|
+
noindex: !!noindex,
|
|
138
|
+
nofollow: !!nofollow,
|
|
139
|
+
robots_meta: robots_meta,
|
|
140
|
+
x_robots: x_robots
|
|
141
|
+
},
|
|
142
|
+
title: {
|
|
143
|
+
text: title_raw,
|
|
144
|
+
length: title_raw.length,
|
|
145
|
+
pixel_est: title_pixel_est,
|
|
146
|
+
ok: title_raw.length.between?(30, 60) && title_pixel_est <= 568.0
|
|
147
|
+
},
|
|
148
|
+
meta_description: {
|
|
149
|
+
text: meta_desc || '',
|
|
150
|
+
length: meta_desc ? meta_desc.length : 0,
|
|
151
|
+
ok: meta_desc ? meta_desc.length.between?(70, 155) : false
|
|
152
|
+
},
|
|
153
|
+
canonical: {
|
|
154
|
+
url: canonical_url,
|
|
155
|
+
self_referencing: canonical_url ? (canonical_url.chomp('/') == @url.chomp('/')) : false
|
|
156
|
+
},
|
|
157
|
+
headings: {
|
|
158
|
+
count: headings.size,
|
|
159
|
+
h1_count: h1_list.size,
|
|
160
|
+
list: headings
|
|
161
|
+
},
|
|
162
|
+
images: images_data,
|
|
163
|
+
links: links_data,
|
|
164
|
+
schema: schemas,
|
|
165
|
+
social: { og: og_data, twitter: twitter_data },
|
|
166
|
+
stats: { word_count: word_count, reading_time_mins: reading_time_mins },
|
|
167
|
+
issues: issues
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def fetch_http(uri, limit = 5)
|
|
174
|
+
raise 'Too many HTTP redirects' if limit == 0
|
|
175
|
+
|
|
176
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
177
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
178
|
+
http.open_timeout = 10
|
|
179
|
+
http.read_timeout = 15
|
|
180
|
+
|
|
181
|
+
req = Net::HTTP::Get.new(uri.request_uri)
|
|
182
|
+
req['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 (gsc-cli)'
|
|
183
|
+
req['Accept-Encoding'] = 'gzip'
|
|
184
|
+
|
|
185
|
+
res = http.request(req)
|
|
186
|
+
if res.is_a?(Net::HTTPRedirection) && res['location']
|
|
187
|
+
new_loc = URI.join(uri.to_s, res['location'])
|
|
188
|
+
fetch_http(new_loc, limit - 1)
|
|
189
|
+
else
|
|
190
|
+
res
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def extract_tag_content(html, tag)
|
|
195
|
+
match = html.match(%r{<#{tag}[^>]*>(.*?)</#{tag}>}im)
|
|
196
|
+
match ? clean_text(match[1]) : ''
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def extract_meta_content(html, attr_type, attr_name)
|
|
200
|
+
regex = %r{<meta\s+[^>]*#{attr_type}=["']#{Regexp.escape(attr_name)}["'][^>]*content=["'](.*?)["']}im
|
|
201
|
+
m = html.match(regex)
|
|
202
|
+
return clean_text(m[1]) if m
|
|
203
|
+
|
|
204
|
+
# Also test reversed attribute order: content="..." name="..."
|
|
205
|
+
regex_rev = %r{<meta\s+[^>]*content=["'](.*?)["'][^>]*#{attr_type}=["']#{Regexp.escape(attr_name)}["']}im
|
|
206
|
+
m2 = html.match(regex_rev)
|
|
207
|
+
m2 ? clean_text(m2[1]) : nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def extract_link_attr(html, rel_name, target_attr)
|
|
211
|
+
regex = %r{<link\s+[^>]*rel=["']#{Regexp.escape(rel_name)}["'][^>]*#{target_attr}=["'](.*?)["']}im
|
|
212
|
+
m = html.match(regex)
|
|
213
|
+
return m[1].strip if m
|
|
214
|
+
|
|
215
|
+
regex_rev = %r{<link\s+[^>]*#{target_attr}=["'](.*?)["'][^>]*rel=["']#{Regexp.escape(rel_name)}["']}im
|
|
216
|
+
m2 = html.match(regex_rev)
|
|
217
|
+
m2 ? m2[1].strip : nil
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def extract_headings(html)
|
|
221
|
+
headings = []
|
|
222
|
+
html.scan(%r{<(h[1-6])(?:\s+[^>]*)?>(.*?)</\1>}im) do |tag, content|
|
|
223
|
+
text = clean_text(content)
|
|
224
|
+
headings << { tag: tag.downcase, text: text } unless text.empty?
|
|
225
|
+
end
|
|
226
|
+
headings
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def extract_images(html)
|
|
230
|
+
all_imgs = []
|
|
231
|
+
missing_alt = []
|
|
232
|
+
|
|
233
|
+
html.scan(/<img\s+([^>]+)>/im) do |attrs_str|
|
|
234
|
+
src = attrs_str[0].match(/src=["'](.*?)["']/i)&.captures&.first
|
|
235
|
+
alt_match = attrs_str[0].match(/alt=(?:["'](.*?)["']|(\S+))/i)
|
|
236
|
+
alt = alt_match ? (alt_match[1] || alt_match[2] || '') : nil
|
|
237
|
+
|
|
238
|
+
img_info = { src: src, alt: alt }
|
|
239
|
+
all_imgs << img_info
|
|
240
|
+
|
|
241
|
+
if alt.nil? || alt.strip.empty?
|
|
242
|
+
missing_alt << img_info
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
{
|
|
247
|
+
total: all_imgs.size,
|
|
248
|
+
missing_alt_count: missing_alt.size,
|
|
249
|
+
missing_alt_images: missing_alt
|
|
250
|
+
}
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def extract_links(doc)
|
|
254
|
+
internal_links = []
|
|
255
|
+
external_links = []
|
|
256
|
+
all_links = []
|
|
257
|
+
|
|
258
|
+
base_host = begin
|
|
259
|
+
URI(@url).host.downcase
|
|
260
|
+
rescue StandardError
|
|
261
|
+
''
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
doc.scan(/<a\s+([^>]+)>(.*?)<\/a>/im) do |attrs_str, anchor_inner|
|
|
265
|
+
href = attrs_str.match(/href=["'](.*?)["']/i)&.captures&.first
|
|
266
|
+
next if !href || href.start_with?('#', 'javascript:', 'mailto:', 'tel:')
|
|
267
|
+
|
|
268
|
+
rel = attrs_str.match(/rel=["'](.*?)["']/i)&.captures&.first || ''
|
|
269
|
+
nofollow = rel.downcase.include?('nofollow')
|
|
270
|
+
anchor_text = clean_text(anchor_inner)
|
|
271
|
+
|
|
272
|
+
link_uri = begin
|
|
273
|
+
URI.join(@url, href)
|
|
274
|
+
rescue StandardError
|
|
275
|
+
nil
|
|
276
|
+
end
|
|
277
|
+
next unless link_uri
|
|
278
|
+
|
|
279
|
+
link_item = {
|
|
280
|
+
href: link_uri.to_s,
|
|
281
|
+
raw_href: href,
|
|
282
|
+
anchor: anchor_text,
|
|
283
|
+
nofollow: nofollow
|
|
284
|
+
}
|
|
285
|
+
all_links << link_item
|
|
286
|
+
|
|
287
|
+
if link_uri.host.nil? || link_uri.host.downcase == base_host || link_uri.host.downcase.end_with?(".#{base_host}")
|
|
288
|
+
internal_links << link_item
|
|
289
|
+
else
|
|
290
|
+
external_links << link_item
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
{
|
|
295
|
+
total: all_links.size,
|
|
296
|
+
internal_count: internal_links.size,
|
|
297
|
+
external_count: external_links.size,
|
|
298
|
+
nofollow_count: all_links.count { |l| l[:nofollow] },
|
|
299
|
+
all: all_links,
|
|
300
|
+
internal: internal_links,
|
|
301
|
+
external: external_links
|
|
302
|
+
}
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def verify_links(link_items)
|
|
306
|
+
verified = []
|
|
307
|
+
link_items.each do |link|
|
|
308
|
+
begin
|
|
309
|
+
uri = URI.parse(link[:href])
|
|
310
|
+
next unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
311
|
+
|
|
312
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
313
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
314
|
+
http.open_timeout = 3
|
|
315
|
+
http.read_timeout = 5
|
|
316
|
+
res = http.request_head(uri.request_uri.empty? ? '/' : uri.request_uri)
|
|
317
|
+
|
|
318
|
+
status = res.code.to_i
|
|
319
|
+
# If HEAD not allowed (405), fallback to quick GET
|
|
320
|
+
if status == 405
|
|
321
|
+
res = http.request_get(uri.request_uri.empty? ? '/' : uri.request_uri)
|
|
322
|
+
status = res.code.to_i
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
verified << link.merge(status: status, ok: status.between?(200, 399))
|
|
326
|
+
rescue StandardError => e
|
|
327
|
+
verified << link.merge(status: 0, error: e.message, ok: false)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
verified
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def extract_json_ld(doc)
|
|
334
|
+
schemas = []
|
|
335
|
+
doc.scan(%r{<script\s+[^>]*type=["']application/ld\+json["'][^>]*>(.*?)</script>}im) do |script_body|
|
|
336
|
+
clean_json = script_body[0].strip
|
|
337
|
+
begin
|
|
338
|
+
parsed = JSON.parse(clean_json)
|
|
339
|
+
types = extract_schema_types(parsed)
|
|
340
|
+
schemas << { valid: true, types: types, data: parsed }
|
|
341
|
+
rescue JSON::ParserError => e
|
|
342
|
+
schemas << { valid: false, error: e.message, raw: clean_json }
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
schemas
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def extract_schema_types(data)
|
|
349
|
+
types = []
|
|
350
|
+
if data.is_a?(Hash)
|
|
351
|
+
types << data['@type'] if data['@type']
|
|
352
|
+
data.each_value { |v| types.concat(extract_schema_types(v)) }
|
|
353
|
+
elsif data.is_a?(Array)
|
|
354
|
+
data.each { |item| types.concat(extract_schema_types(item)) }
|
|
355
|
+
end
|
|
356
|
+
types.compact.flatten.uniq
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def estimate_pixel_width(str)
|
|
360
|
+
# Proportional font estimation for Arial/Roboto 18px in Google SERP
|
|
361
|
+
width = 0.0
|
|
362
|
+
str.to_s.each_char do |ch|
|
|
363
|
+
width += case ch
|
|
364
|
+
when /[WMwm]/ then 13.5
|
|
365
|
+
when /[ABCDEFGHKNOPQRSTUVXYZ]/ then 10.5
|
|
366
|
+
when /[abcdeghnopqrsuvxyz]/ then 8.5
|
|
367
|
+
when /[fIjt1l\|\ \.\:\;]/ then 4.5
|
|
368
|
+
else 9.0
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
width.round(1)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def clean_text(str)
|
|
375
|
+
str.to_s
|
|
376
|
+
.dup
|
|
377
|
+
.force_encoding('UTF-8')
|
|
378
|
+
.scrub
|
|
379
|
+
.gsub(/<[^>]+>/, ' ')
|
|
380
|
+
.gsub(/&/, '&')
|
|
381
|
+
.gsub(/</, '<')
|
|
382
|
+
.gsub(/>/, '>')
|
|
383
|
+
.gsub(/"/, '"')
|
|
384
|
+
.gsub(/'/, "'")
|
|
385
|
+
.gsub(/\s+/, ' ')
|
|
386
|
+
.strip
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def fetch_gsc_metrics(api, page_url, domain)
|
|
390
|
+
return nil unless api
|
|
391
|
+
|
|
392
|
+
site_url = domain.start_with?('sc-domain:') ? domain : "sc-domain:#{domain}"
|
|
393
|
+
res = api.query_analytics(
|
|
394
|
+
site_url,
|
|
395
|
+
days: 90,
|
|
396
|
+
dimensions: ['query'],
|
|
397
|
+
row_limit: 50,
|
|
398
|
+
filters: [
|
|
399
|
+
{
|
|
400
|
+
dimension: 'page',
|
|
401
|
+
operator: 'equals',
|
|
402
|
+
expression: page_url
|
|
403
|
+
}
|
|
404
|
+
]
|
|
405
|
+
)
|
|
406
|
+
return nil unless res[:ok]
|
|
407
|
+
|
|
408
|
+
rows = res.dig(:data, 'rows') || []
|
|
409
|
+
return nil if rows.empty?
|
|
410
|
+
|
|
411
|
+
total_clicks = rows.sum { |r| r['clicks'] }
|
|
412
|
+
total_imp = rows.sum { |r| r['impressions'] }
|
|
413
|
+
avg_ctr = total_imp > 0 ? ((total_clicks.to_f / total_imp) * 100).round(2) : 0.0
|
|
414
|
+
avg_pos = (rows.sum { |r| r['position'] } / rows.size).round(1)
|
|
415
|
+
|
|
416
|
+
top_queries = rows.sort_by { |r| -r['impressions'] }.first(5).map do |r|
|
|
417
|
+
{ query: r['keys'][0], clicks: r['clicks'], impressions: r['impressions'], position: r['position'].round(1) }
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
{
|
|
421
|
+
total_clicks: total_clicks,
|
|
422
|
+
total_impressions: total_imp,
|
|
423
|
+
ctr: avg_ctr,
|
|
424
|
+
avg_position: avg_pos,
|
|
425
|
+
top_queries: top_queries
|
|
426
|
+
}
|
|
427
|
+
rescue StandardError
|
|
428
|
+
nil
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|