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.
@@ -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(/&amp;/, '&')
381
+ .gsub(/&lt;/, '<')
382
+ .gsub(/&gt;/, '>')
383
+ .gsub(/&quot;/, '"')
384
+ .gsub(/&#39;/, "'")
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